Compare commits

..
Author SHA1 Message Date
Vitor PamplonaandGitHub 8d75a50df9 Merge pull request #4120 from vitorpamplona/claude/sweet-fermat-10uvw5
Update Arti to 2.6.0 and jni to 0.22, pin and enforce the NDK revision
2026-09-13 17:17:52 -04:00
Claude fde0b7f689 fix: harden the Arti build gates and drop initialize()'s sentinel
Follow-up to the toolchain update, from an audit of that diff.

Build script:
- The NDK pin rejected machines that have the pinned revision installed.
  An exported ANDROID_NDK_HOME or ANDROID_NDK_ROOT short-circuited the
  search and then failed the revision check, and GitHub runners export both
  at their own bundled NDK. Every candidate is now checked against its own
  source.properties and a mismatch moves on, so the build fails only when
  the pinned revision is genuinely absent, listing what it found instead.
- verify_jni_symbols printed missing exports and exited 0, so a library that
  would throw UnsatisfiedLinkError on every call could ship. It now fails the
  build, and checks only the ABIs this run built.
- Both post-build checks now use the pinned NDK's own llvm-readelf and
  llvm-nm. The stamp check silently skipped on macOS, which has no readelf,
  and Apple's nm cannot read ELF at all, so the symbol check would have
  reported every symbol missing there.
- The stamp check read its note through `readelf | grep -q`, the same
  SIGPIPE-plus-pipefail shape this branch removed from the symbol check.
- $HOME is expanded with a default, so `set -u` no longer aborts before the
  "NDK not found" message in an environment without HOME.

verify-reproducible.sh hashed every .so under jniLibs, so --release, which
rebuilds arm64 only, hashed the untouched x86_64 library identically in both
runs and reported the whole tree reproducible and matching the commit. It now
hashes and diffs only the ABIs the run builds, and prints which those are.

lib.rs:
- initialize() signalled "already initialized" out of the JNI closure as an
  empty string, re-tested after it. A destroy() landing in between would let
  the empty string through as the data directory, which resolves to relative
  state/ and cache/ paths against the process working directory. The check
  now reads the whole Option outside the closure and no sentinel exists.
- Corrected the comments claiming the error policy keeps a panic from
  crossing extern "C". It does not: the policy's panic arm runs through
  catch_unwind, which catches nothing under this crate's panic = "abort"
  profile. The Err arm, which is what the code relies on, is unaffected.

README: the troubleshooting section still told readers to install cargo-ndk
unpinned and to export ANDROID_NDK_HOME at an arbitrary revision, which was
the exact way to trip the old gate.

Verified: two clean builds byte-for-byte identical, both ABIs stamped r30,
JNI exports present, 16 KiB alignment kept. JVM tier-3 smoke test green, and
a scratch harness drove getVersion, setLogCallback, initialize, a second
initialize on a live client (the reuse path the sentinel used to carry) and
destroy over real JNI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
2026-09-13 20:46:31 +00:00
Claude e32cadc250 build: update Arti to 2.6.0, jni to 0.22, NDK r30, Rust 1.98.1
Moves every pin in tools/arti-build forward and rebuilds all three shipped
libraries from them. Arti 2.5.0 carried two medium-severity fixes that a
client reaches in normal use, so the shipped 2.3.0 was the reason to do this
now: TROVE-2026-24, where a malicious directory mirror crashes the
tor-netdoc parser and eventually stops tor-dirmgr, and TROVE-2026-27, an
inefficient algorithm an attacker can drive into a CPU stall.

Pins:
- Arti 2.3.0 -> 2.6.0 (arti-client / tor-rtcompat 0.42 -> 0.46). New MSRV is
  1.91, satisfied by the pinned toolchain.
- Android NDK 27.3.13750724 (r27d) -> 30.0.16248370 (r30), the current LTS.
  clang and lld move 18.0.4 -> 21.0.0.
- rustc 1.94.1 -> 1.98.1.
- jni 0.21 -> 0.22.
- Cargo.lock regenerated, so every transitive dependency moves to its latest
  semver-compatible release. cargo-ndk was already on the pinned 4.1.2.

Source changes the upgrades required:
- Arti 2.4.0 made every TorClient constructor return an Arc<TorClient> and
  dropped Clone from TorClient, so the wrapper no longer wraps it itself.
- jni 0.22 splits the FFI environment pointer (EnvUnowned) from the API type
  (Env), which is only borrowed inside a closure. Native methods now acquire
  it via with_env and map failures through an ErrorPolicy instead of
  unwinding out of extern "C", which aborts. initialize() reads everything
  JNI-owned up front and resolves through Option<String>, because the policy
  default for jint is 0, the value that API reports as success. GlobalRef
  became Global<JObject>, thread attachment takes a closure (which also
  scopes a local-reference frame per log line), and the method name and
  signature are encoded at compile time via jni_str! / jni_sig!.

Verification:
- verify-reproducible.sh: two clean builds byte-for-byte identical, JNI
  symbols exported, 16 KiB LOAD alignment kept, both ABIs stamped r30, same
  libc/libm/libdl dependency set as before.
- JVM tier-3 smoke test passes against the rebuilt host shim, and a scratch
  harness drove setLogCallback, getVersion, initialize and destroy through
  real JNI: log lines arrive over the migrated callback and initialize
  returns 0.
- cargo audit: rsa 0.9.10 (RUSTSEC-2023-0071) remains, with no fixed version
  published upstream; it arrives via ssh-key-fork-arti and needs RSA private
  key operations, which a client without hosted onion services never does.
  The event-listener unsound and spin yanked warnings are gone.

Not verified here: the network-dependent integration tier and the on-device
instrumented test. This container blocks most outbound TCP (directory
authority port 9131 among them), so Tor circuits time out regardless of
which library is loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
2026-09-13 18:51:50 +00:00
Claude b52f5651de build: pin the Arti NDK and rebuild libarti_android.so on r27d
The committed libraries were built with NDK r25b (25.1.8937393) while the
build docs told everyone to install r27. Nothing pinned the NDK, so
build-arti.sh took the first directory matching ~/Android/Sdk/ndk/*/. The
NDK supplies the clang that compiles Arti's C dependencies (ring, zstd-sys,
libsqlite3-sys) and the lld that links the cdylib, so its revision is baked
into the output bytes exactly like rustc's is. The reproducible-build
promise therefore only held by accident of which NDK a verifier happened to
have installed.

- Pin the revision in ANDROID_NDK_VERSION (27.3.13750724, r27d) and resolve
  it by name. A different revision now fails the build with the sdkmanager
  line that fixes it, instead of silently producing unverifiable bytes.
- Record the verified cargo-ndk release in CARGO_NDK_VERSION. Warning only:
  it wraps the NDK rather than generating code.
- Re-read .note.android.ident after each build, so the output has to carry
  the pinned NDK's stamp to pass.
- Rebuild both ABIs on r27d (clang 18.0.4, lld 18.0.4, rustc 1.94.1).
  verify-reproducible.sh: two clean builds byte-for-byte identical, all 8
  JNI symbols exported, 16 KiB LOAD alignment kept, same libc/libm/libdl
  dependency set as before.
- Fix verify_jni_symbols reporting every exported symbol as missing: piping
  nm into `grep -q` per symbol lets grep exit first, nm dies of SIGPIPE, and
  `set -o pipefail` fails the pipeline. Pre-existing, reproduces on the old
  binary too.
- Docs: the 16 KiB page alignment comes from rustc's Android target spec,
  not from "NDK 25+".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
2026-09-13 16:43:33 +00:00
David KasparandGitHub 513b21e9af Merge pull request #4119 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 18:27:27 +02:00
vitorpamplonaandgithub-actions[bot] cba56f6304 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-13 16:26:48 +00:00
Vitor PamplonaandGitHub bd1021c6f4 Merge pull request #4111 from vitorpamplona/claude/beautiful-brahmagupta-17clq5
Split commons into headless + commonsUI; fix 34 bugs
2026-09-13 12:12:38 -04:00
Claude 00379719c8 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 15:00:55 +00:00
Vitor PamplonaandGitHub 0c59d0ef2e Merge pull request #4113 from vitorpamplona/claude/app-relay-connection-count-jo13xd
fix(relay): honest connected-relay count by finishing the WebSocket close handshake
2026-09-13 10:59:09 -04:00
Claude 3e9b629b8d refactor(relay): one adapter is one session; drop the lock and the socket identity checks
The adapters compared the socket OkHttp named in each callback against
the one they held, and took a monitor to make the compare-and-null
atomic and to close a window in connect() where a callback could arrive
before the field was assigned. Neither is needed: the relay client builds
a fresh adapter per dial and OkHttp binds exactly one socket to the
listener created in connect(), so anything that reaches that listener is
from this session by construction. The only question a callback has to
ask is whether the session already ended, which is one AtomicBoolean
claimed by whichever of onClosed, onFailure or disconnect() gets there
first. A compare-and-set keeps the "exactly one terminal report"
guarantee without a monitor, and there is no assignment window left to
guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 13:02:47 +00:00
Vitor PamplonaandGitHub 032546f70b Merge pull request #4114 from vitorpamplona/claude/image-blurhash-load-delay-y0o5f3
fix(media): stop a no-imeta GIF rendering as an invisible note while it loads
2026-09-13 08:49:07 -04:00
Claude e2bbe41991 Merge remote-tracking branch 'origin/claude/image-blurhash-load-delay-y0o5f3' into claude/image-blurhash-load-delay-y0o5f3 2026-09-13 12:17:39 +00:00
Claude 25e61542ba fix: three defects this branch introduced, found auditing its own diff
**Tor's control flow was being pinned alive for the process lifetime.** The
eviction wiring subscribed to `torManager.activePortOrNull` from a never-cancelled
coroutine on `applicationIOScope`, in both managers. That flow chains to
`TorManager.status`, whose upstream is `WhileSubscribed` and runs
`launch { service.start() }` when collected — so a permanent subscriber starts
Arti at process construction and never lets it unsubscribe on background.
AppModules documents this exact hazard for the battery ledger and deliberately
watches the raw `TorService.status` instead; I wired the poisoned well four lines
away from the warning.

Rewired from signals that are plain StateFlows and therefore free to observe:
`torPrefs.torType`, `torPrefs.externalSocksPort`, and `torService.status`'s socks
port. It moves to AppModules, which is where those live and where the precedent
is; commons had no business knowing Tor's subscription hazards anyway.

**`drop(1)` promised more than it delivered.** In the manager it skipped whatever
was present when the *coroutine started*, not when the call was made, so a route
change landing in that window was swallowed. In its new home the two coincide —
this runs during AppModules construction, before anything is pooled — so the
operator now means what the comment says.

**Animated media in a Crop cell flashed its raw URL.** The loading ladder keyed
its last branch on `ratio != null`, but `mediaSizingModifier` also bounds the
height for `ContentScale.Crop`. MyAsyncImage passes dimensions/blurhash/thumbhash
all null, so every gif in a card slot — DVM covers, long-form headers,
follow-set/calendar/music cards — hit the unbounded branch on first load and drew
URL text where it used to draw nothing. The predicate wanted "is the height
bounded", so it now says so: `contentScale == Crop || ratio != null`.

Drops ProxyRouteChange.kt and its tests with the rewire. The trigger is now three
stdlib flow operators; what needed judgement was which signals are safe to watch,
and that is recorded in the comment rather than in a test of combine().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 12:16:56 +00:00
Claude 624e38ce34 Merge remote-tracking branch 'origin/claude/beautiful-brahmagupta-17clq5' into claude/beautiful-brahmagupta-17clq5 2026-09-13 12:09:54 +00:00
Claude b243184026 refactor(relay): count from the connected flow itself; drop the members snapshot
The first commit on this branch added INostrClient.connectedRelays(), a
snapshot read from the pool members, because connectedRelaysFlow could not
be trusted: a relay the pool had dropped could sit in it for minutes. That
has since been fixed at the source -- the pool clears the flow itself when
it lets a relay go, and every transport reports its session end exactly
once -- so the flow's value and the members' isConnected() move on the
same transitions, and re-reading the members on every emission was a
redundant pool walk. The notification takes its count from the emitted
set, the breakdown and the Active Subscriptions screen go back to the
flow, and the snapshot API is removed. The pool test keeps pinning what
the app actually reads: the flow drops a relay on removal and on
disconnect even when the socket layer never confirms the close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 12:07:46 +00:00
Vitor PamplonaandGitHub 41113ca259 Merge pull request #4118 from vitorpamplona/claude/confident-darwin-rbzz0f
Remove unused Guardian Project Maven repository
2026-09-13 08:04:21 -04:00
Claude 2b5ce62c6e build: drop the unused Guardian Project maven repository
Nothing in the version catalog or any module references an
info.guardianproject artifact anymore (the old tor-android/jtorctl
dependencies were replaced by kmp-tor from Maven Central). Resolving
every module's dependency graph with and without the repository yields
an identical result, so the repository is dead weight that only adds
a network lookup to every miss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmSZEn7Ub9URvxow4etZc
2026-09-13 12:01:59 +00:00
Claude 5dcaa0bf5d Revert "test(commons): make the macOS keychain probe path testable on every host"
This reverts commit 047eb197a4.
2026-09-13 11:59:46 +00:00
Claude 2afe9b9221 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 11:59:00 +00:00
Vitor PamplonaandGitHub 1590fca9b7 Merge pull request #4117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 07:52:50 -04:00
davotoulaandgithub-actions[bot] afeb70b15b chore: sync Crowdin translations and seed translator npub placeholders 2026-09-13 11:47:56 +00:00
davotoula 68a9f77337 fix(cli): fail fast when a buzz-agent wrapper can't be made executable
setExecutable's result was ignored (Sonar). The runner launches the wrapper
via sh -c <path>, so a missing exec bit only surfaced later as an opaque
permission-denied; check it at extraction time instead.
2026-09-13 13:41:25 +02:00
David KasparandGitHub 153fd89f8d Merge pull request #4116 from davotoula/fix/sonar-http-literals
fix(amethyst): use https in preview data and image-URL placeholders
2026-09-13 13:36:19 +02:00
davotoula f9b11ef68b fix(amethyst): use https in preview data and image-URL placeholders
Clears Sonar 'Using HTTP protocol is insecure' hits. None were real requests:
six are @Preview dummy strings, four are picture-URL field hints that
suggested http:// to users. The Namecoin RPC onion placeholder stays
http:// on purpose (Tor encrypts; Namecoin Core RPC has no TLS).
2026-09-13 12:19:49 +02:00
David KasparandGitHub c8262d440b Merge pull request #4107 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 10:58:43 +02:00
davotoulaandgithub-actions[bot] 686975e322 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-13 08:22:05 +00:00
David KasparandGitHub 5943dfe3d7 Merge branch 'main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 10:20:46 +02:00
David KasparandGitHub 8e6cb1f7ac Merge branch 'main' into claude/image-blurhash-load-delay-y0o5f3 2026-09-13 10:19:56 +02:00
David KasparandGitHub 0a4a0e60ff Merge pull request #4115 from davotoula/fix/vault-test-non-mac-strict-contract
test(commons): make the vault strict-lookup test pass off macOS
2026-09-13 10:19:20 +02:00
davotoula 1725425658 test(commons): make the vault strict-lookup test pass off macOS
`strict lookup keeps the strict contract for aliases outside the vault`
drove the miss through the macSecurityLookup seam, which getPrivateKeyOrThrow
only consults when isMacOs(). On Linux and Windows the strict path is
javakeyring, which cannot tell a miss from a denial and deliberately throws,
so the test's assertNull failed there — red on the Android job and the
Linux/Windows desktop builds of main.

Branch on the host like SecureKeyStorageOrThrowTest does: off macOS assert
the miss throws SecureStorageException with the PasswordAccessException in
its cause chain (coroutine stack-trace recovery may wrap it); on macOS keep
the null-on-miss / throw-on-ambiguous assertions.

Verified with os.name forced to Linux and natively on macOS (29/29
SecureKeyStorage* tests).
2026-09-13 09:12:48 +02:00
Claude 047eb197a4 test(commons): make the macOS keychain probe path testable on every host
SecureKeyStorageVaultTest's "strict lookup keeps the strict contract for
aliases outside the vault" fails on Linux (red on main's own CI run for
38bbfa8c and reproduced in a clean worktree of origin/main): the test wires a
fake mac `security` probe through macSecurityLookup, but the strict lookup
gates that path on the real os.name, so on a Linux runner it takes the
javakeyring branch, where an unknown alias throws instead of answering null.

Make the OS check injectable like the other test hooks (keyringFactory,
macSecurityLookup) and have wireMacProbe force it on, so the suite exercises
the probe path it was written for on any host.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-13 02:54:11 +00:00
Claude 795394c454 Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 02:43:34 +00:00
Claude a2b0fd9405 fix(http): drop pooled connections once per real Tor route change
Removing the per-rebuild eviction left one sliver: when the user switches Tor ON,
the direct client's idle sockets to real hosts stayed pooled for the 5-minute
keepalive. Nothing could route a request through them — OkHttp keys the pool by
`Address`, which includes the proxy — but they are real connections to real hosts
outliving the moment the user asked for everything to go through Tor.

[evictOnProxyRouteChange] closes that by watching the one signal that means the
route actually moved: `torManager.activePortOrNull`. Tor coming up (null -> 9050),
going away (9050 -> null), or moving (9050 -> 9150) each evict exactly once.
`drop(1)` keeps subscribing from counting as a change.

Deliberately not driven by the two things that misled the old code:

  - Client rebuilds. One factory mints both the proxied and the direct client and
    they share a pool, so a per-rebuild check fired on every isMobileDataProvider
    emission and every resubscribe — constantly, and never specifically on a Tor
    toggle.
  - The per-feature Tor switches (imagesViaTor, videosViaTor, …). Those change
    which of the two existing clients a request picks, not the route either one
    uses, so no pooled connection goes stale.

Wired in both managers' init, so the media and relay pools behave identically.
Six tests cover the trigger, including that re-emitting the same port never
evicts — the regression the old design had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 02:42:06 +00:00
Vitor PamplonaandClaude Opus 5 38bbfa8c74 Merge PR: fix(desktop): consolidate keychain items so cold-boot prompts once
Merges nostr proposal 1c931194 (v4) into main:
- fix(commons): consolidate desktop keychain items into a single vault-v1 item
- fix(desktop): wire two-phase vault bootstrap into AccountManager
- fix(commons): make the keychain vault authoritative without blinding lookups
- fix(desktop): migrate the keychain vault before the first account-store read
- style(desktop): import CancellationException instead of inlining its name

Every nsec, bunker ephemeral and NWC URI now lives in one vault-v1 keychain
item behind a single ACL, so cold boot prompts once. One approval releases
every secret; this single-ACL model is an accepted maintainer decision.

v2-v4 fixed two account-store wipes (strict lookup ignoring the vault;
migration running after refreshAccountListOnStartup's first read), a
missing legacy fallback, and orphaned nsecs on logout. Verified with 17
mutation-checked tests and three consecutive launches against a real
macOS Keychain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHCtgMDNkAHvRDXnthuXQc
2026-09-12 22:01:52 -04:00
Vitor PamplonaandClaude Opus 5 94a229e983 style(desktop): import CancellationException instead of inlining its name
The vault bootstrap added two more inline
kotlin.coroutines.cancellation.CancellationException references next to
two existing ones; CLAUDE.md forbids fully-qualified names in function
bodies. One import, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHCtgMDNkAHvRDXnthuXQc
2026-09-12 21:52:31 -04:00
Vitor PamplonaandClaude Opus 5 81b9ba853a fix(desktop): migrate the keychain vault before the first account-store read
Found by running the migration against a real macOS Keychain: the first
launch migrated and loaded fine, the second launch wiped the account store.

bootstrapConsolidatedVault() was called from loadSavedAccount(), and its
comment claimed it ran "before any other keychain read on the hot startup
path". It did not. Main.kt's startup DisposableEffect calls
refreshAccountListOnStartup() first, which reads accounts.json.enc and so
needs the metadata AES key.

On the first launch that read still found the legacy per-alias item and
cached the key, so the migration that followed looked harmless. On the
second launch the item was gone -- migrated into vault-v1 and deleted --
and the vault had not been activated yet, so the strict lookup answered
"definitively absent", getOrCreateKey minted a fresh AES key, wrote it back
as a legacy item, and the decrypt that followed failed with a GCM tag
mismatch. Observed exactly that: accounts.json.enc renamed to
.corrupt.<ts>, account-metadata-key resurrected as a per-alias item, and
the original key still sitting unused inside vault-v1.

Phase 1 is now a run-once, mutex-guarded ensureVaultMetadataKeyMigrated()
that every account-store entry point calls, including refreshAccountList().
Phase 2 still runs from loadSavedAccount() once the npub list is known.

Verified on macOS against the real Keychain and the real account store:
three consecutive launches across the migration boundary all load the
account, no corruption events, accounts.json.enc byte-identical throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 21:50:36 -04:00
Vitor PamplonaandClaude Opus 5 9193502748 fix(commons): make the keychain vault authoritative without blinding lookups
Three defects found reviewing the vault consolidation against current main.

1. The vault wiped every account on the first cold boot after upgrade.

   getPrivateKeyOrThrow was the only accessor without a vaultActive branch
   (savePrivateKey, getPrivateKey and deletePrivateKey all had one). The
   migration deletes the legacy per-alias items after writing vault-v1, so
   the strict path probed the OS for an item that no longer existed, read
   macOS exit 44 / NotFound as "definitively absent", and let
   DesktopAccountStorage.getOrCreateKey mint a fresh AES key over the one
   that decrypts accounts.json.enc -- the exact silent wipe proposal
   5d31b68e added that method to prevent.

   It was also unrecoverable: phase 1 preserves the original key inside the
   vault, but getOrCreateKey then persists the new key over the same alias,
   destroying the only key that could decrypt the .corrupt backup. And
   because phase 2 calls loadAccounts(), the wipe happened inside the
   bootstrap itself, so no nsec was ever folded in.

   The strict path now consults the vault first. A vault miss still falls
   through to the strict per-alias probe, so uncovered aliases keep the
   strict contract.

2. The documented legacy fallback did not exist. getPrivateKey was
   `vaultActive -> vaultGet(npub)` with no fallback, so once the vault was
   active any alias it did not cover read as absent. Phase 1 activates it
   with only the metadata key, and a phase 2 that throws is swallowed, so
   the real worst case was every nsec reading null rather than the
   advertised "old two-prompt behaviour". A vault miss now falls back to
   the legacy per-alias item.

3. deletePrivateKey left orphaned secrets. vaultDelete returned false for
   an alias outside the vault and never touched the legacy item, so logging
   out of an account whose nsec had not been folded in left the nsec in the
   OS keychain indefinitely -- still readable via the fallback in 2. It now
   unlinks the legacy item as well.

Also logs the swallowed vault-bootstrap failure; silently discarding it
made a half-migrated keychain impossible to diagnose from a user report.

All three are pinned by new tests in SecureKeyStorageVaultTest and
mutation-checked: reverting any one fix fails exactly its own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 21:50:36 -04:00
mstrofnoneandVitor Pamplona e524d38dc5 fix(desktop): wire two-phase vault bootstrap into AccountManager
Runs SecureKeyStorage.enableConsolidatedVault at the top of
loadSavedAccount, before any other keychain read on the cold-boot
hot path, so migrated setups pay exactly one Keychain Access prompt
regardless of how many accounts, per-account bunker-ephemerals, and
NWC URIs the user has.

Phase 1 migrates only account-metadata-key (the AES key that
decrypts accounts.json.enc). Nothing else can be enumerated before
that file is readable, so this phase runs against a single-alias
candidate list. It is a no-op on fresh installs (no legacy item)
and on already-migrated setups (vault-v1 exists).

Phase 2 runs after accounts.json.enc has been decrypted and the
full npub list is known. For each npub we add the nsec alias
itself, the per-account bunker-ephemeral alias, and the NWC alias.
The legacy shared bunker-ephemeral alias is included for the
pre-per-account migration compatibility branch in
loadBunkerAccount. Phase 2 is idempotent so it is safe to run on
every startup, including when the vault already covers every alias.

DesktopAccountStorage.METADATA_KEY_ALIAS is promoted from private
to internal so AccountManager.bootstrapConsolidatedVault can name
it without duplicating the alias string. The alias itself, its
storage, and its read/write path all still live in
DesktopAccountStorage.

Bootstrap failures are swallowed: SecureKeyStorage falls back to
legacy per-alias reads for anything the vault does not cover, so
the worst case is the old two-prompt behaviour. Nothing on the
account-load path breaks.

No API change on AccountManager (bootstrapConsolidatedVault is
private) and no visible behaviour change on the fallback
(no-keyring) storage path, which already uses a single encrypted
file and does not have the per-item ACL problem the vault exists
to solve.
2026-09-12 21:50:36 -04:00
mstrofnoneandVitor Pamplona bfdeaa1ace fix(commons): consolidate desktop keychain items into a single vault-v1 item
Follow-up to ae3218249a. Caching the Keyring handle collapsed
Keyring.create() calls but did not fix the double prompt on macOS,
because macOS Keychain gates access per item, not per session.
Amethyst's cold-boot path reads at minimum two distinct items,
account-metadata-key (the DesktopAccountStorage AES key) and the
active account's nsec, so the OS still surfaces two Keychain Access
prompts unless the user explicitly picked "Always Allow" on every
single item, which many users don't.

Fix: consolidate all Amethyst-managed keychain items into a single
vault-v1 item, so the OS sees exactly one item to gate.

SecureKeyStorage.enableConsolidatedVault(candidateAliases):

  1. If vault-v1 already exists, load it and mark the vault active.
     Legacy per-alias items in candidateAliases are still folded in
     on this pass so a crash-during-migration leaves nothing stranded.
     Aliases not in candidateAliases are ignored, so pre-existing
     items from unrelated accounts do not get pulled in and re-prompt.
  2. If vault-v1 is absent, batch-read each candidate alias in the
     legacy per-item layout (paying the migration prompt once), pack
     the recovered entries into vault-v1, and delete the originals.
     The vault item is written first, legacy items are deleted only
     after that write succeeds, so a crash mid-migration leaves the
     legacy items in place and the next run retries cleanly.
  3. Fresh installs write an empty vault so future savePrivateKey
     calls stay inside it.

Migration is idempotent and cheap when the vault already exists
(one keychain read plus a JSON parse). Safe to call on every cold
boot, and safe to call twice per process for the two-phase
bootstrap pattern (see the AccountManager change in the next
commit).

Once the vault is active, savePrivateKey / getPrivateKey /
deletePrivateKey / hasPrivateKey read and write the in-memory
LinkedHashMap and persist the whole map back to the vault item on
mutation. Fresh SecureKeyStorage instances that have not opted into
the vault continue to use the legacy per-alias layout, so callers
that never call enableConsolidatedVault keep working unchanged.

The vault contents are stored as a JSON envelope of the form
{"schemaVersion":1,"entries":{alias: base64(secret), ...}}.
schemaVersion reserves room for future migrations. Values are
base64-encoded so alias / secret contents that contain quotes,
backslashes, control chars, or non-ASCII round-trip cleanly through
the hand-rolled JSON codec (kept hand-rolled so the keystorage
module does not need Jackson; Jackson lives in desktopApp and
quartz). Amethyst does not layer additional crypto over the OS
keyring for legacy per-item storage; the OS keychain is the trust
boundary. The vault follows the same policy.

Tests (SecureKeyStorageVaultTest, hermetic CountingKeyring
KeyringHandle fake, no real OS keychain):

- fresh install writes an empty vault item
- legacy items are migrated into one vault-v1 item and originals
  deleted
- an existing vault is loaded without re-probing aliases it already
  contains
- legacy leftovers from an interrupted earlier migration get
  absorbed on the next boot when still named in the candidate list
- savePrivateKey after vault enabled persists to the vault
- getPrivateKey after vault enabled reads from in-memory contents
  without further keychain traffic
- enableConsolidatedVault is idempotent across repeated calls
- two-phase migration folds in aliases discovered after phase 1
- partial legacy migration survives a relaunch (two SecureKeyStorage
  instances against one backing store)
- delete removes from the vault and unlinks the whole item when the
  last alias goes
- vault round-trips keys with quotes, newlines, backslashes, and
  Unicode

All existing SecureKeyStorageKeyringCacheTest cases still pass;
the changes are additive and back-compatible when
enableConsolidatedVault is never called.
2026-09-12 21:50:36 -04:00
Claude 9e380cf9dc perf(http): drop the proxy-change pool eviction in both OkHttp factories
Both factories emptied the whole connection pool whenever the proxy differed
from the last one they were handed. It was defensive, and it was not free.

It is not needed. OkHttp keys the pool by `Address`, and `Address.equalsNonHost`
compares `proxy` — so a call is only ever given a connection opened through the
very same route. A connection left over from an old proxy is already unreachable
by anything using the new one; it just ages out of the pool on its own. There was
never a stale-route connection to protect against.

The cost was real, though. `evictAll()` empties the ENTIRE shared pool, and each
factory mints BOTH clients: `DualHttpClientManager` builds defaultHttpClient
(always SOCKS, since buildLocalSocksProxy falls back to 9050 rather than
returning null) and defaultHttpClientWithoutProxy (always null) from one
instance, and they share one `rootClient.connectionPool`. A single "last proxy"
field therefore alternated forever, and every rebuild read as a route change and
dropped every warm connection the other client was relying on. Both are
stateIn(WhileSubscribed(1000)) flows collected from a composable, so it fired on
each isMobileDataProvider change and each foreground round trip — and the next
image then paid a fresh DNS + TCP + TLS. Plausibly the "first image after a pause
takes forever" stall the pingInterval above it was added for.

DualHttpClientManagerForRelays has the identical shape, so the relay factory is
fixed the same way. Less damaging there — evictAll spares connections with active
calls, so live relay sockets survived — but it was still discarding idle pooled
connections on every network flap.

This replaces the ProxyRouteTracker approach from earlier on this branch, which
kept the eviction and merely made its bookkeeping correct. Deleting the mechanism
is the better answer, and it takes the class and its tests with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 01:43:43 +00:00
Claude 0067db3608 refactor(notifications): trigger the count refresh on the connected flow alone
availableRelaysFlow was merged in when connectedRelaysFlow could not be
trusted to move on a removal: a relay the pool had dropped could sit in it
for minutes, so the one flow that did move on membership changes was used
as an extra wake-up and the count re-read from the pool members. The pool
now clears the connected flow itself whenever it lets a relay go, and every
transport reports its session end exactly once, so that flow emits on every
change the count can reflect and the extra trigger only added wake-ups on
membership churn. The count still comes from client.connectedRelays(),
the members' live socket state, which is what it is meant to show.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 01:43:11 +00:00
Claude 08529c3a74 refactor(relay): make the socket adapters own the session, and take the guard out of BasicRelayClient
The previous commit taught BasicRelayClient to remember which listener it
had wired and to ignore callbacks from any other. That put the knowledge
"this report is about a socket I already threw away" in the wrong layer:
the transport adapter is the one that owns the socket, and OkHttp names
the socket in every callback, so the adapter can tell for free.

BasicRelayClient goes back to exactly its previous code. The contract it
relies on is now written on the WebSocket interface and kept by every
transport: a session ends with exactly one terminal callback, and
disconnect() reports onClosed synchronously and forwards nothing from that
socket afterwards -- what InProcessWebSocket has always done. Both OkHttp
adapters (quartz's BasicOkHttpWebSocket and the Android app's
OkHttpWebSocket) now check ownership on every callback, claim the terminal
report under a lock so a session cannot be reported twice, and answer
disconnect() themselves instead of waiting for OkHttp, which raises nothing
for a cancel when no reader is left to fail and otherwise raises it later
on its own thread. The reconnect race this closes is the same one as
before: with disconnect()+connect() back to back, the old socket's late
failure used to land on the new connection and cancel it.

The client-level stale-socket test is replaced by adapter-level tests on
both modules: disconnect() reports once and synchronously, OkHttp's own
reaction to the cancel never surfaces, and a relay-initiated close followed
by disconnect() is still one report.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-13 01:36:28 +00:00
Claude 5f53568204 Merge remote-tracking branch 'origin/main' into claude/image-blurhash-load-delay-y0o5f3
One conflict, in BlossomReadAuthTokenProvider.signOnce(). Main already carries
the same fix via PR #4108 (davotoula), landed while this branch was open. The two
are functionally identical — same third cache look, in the same place, for the
same reason — so the conflict is resolved by taking main's wholesale and dropping
mine. The file is now byte-identical to main; my add7bfe3 contributes nothing
beyond it.

What this branch still adds over main:
  - GifVideoView loading fallback (the invisible no-imeta note)
  - ProxyRouteTracker + its tests (the shared connection pool being evicted
    whenever the proxied and direct clients alternate)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 01:20:04 +00:00
Claude c33256a08e Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5 2026-09-13 01:16:42 +00:00
Claude 64e0ce4b3e revert(http): restore the dispatcher limits, and record why they stay
I lowered these to 32/8 on reasoning that does not survive reading the source.

`maxRequests` is the thread ceiling — Dispatcher's executor really is
corePoolSize=0 / maxPoolSize=MAX_VALUE over a SynchronousQueue, and its own kdoc
notes a pool sized exactly to maxRequests is not even sufficient. That much was
right. The conclusions drawn from it were not:

- "128 concurrent TLS handshakes" is wrong. These hosts are HTTP/2, so concurrent
  calls to one host multiplex over a single connection; handshake count is bounded
  by distinct hosts and the pool, not by maxRequests.
- "A tighter total lets the visible images finish first" is backwards.
  promoteAndExecute walks readyAsyncCalls as a strict FIFO with no priority, and
  PrefetchFeedMedia enqueues notes before the user reaches them — so a lower cap
  makes the on-screen image queue behind those prefetches rather than start
  immediately. That is the very symptom under investigation.
- Halving maxRequestsPerHost also halves HTTP/2 stream concurrency against the
  single Blossom host a feed pulls from, which is what these were tuned for.

What is left is thread memory, and blocked threads commit little. No measurement
justified the change, so the values go back as they were. The comment now carries
the analysis so the next reader does not re-derive the same wrong intuition.

The ProxyRouteTracker fix from the same commit is unaffected and stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-13 00:46:21 +00:00
Vitor PamplonaandGitHub 71f72a0910 Merge pull request #4112 from vitorpamplona/claude/intelligent-newton-bw5dmf
BOLT12 offers in the profile payment rail and zap picker, with BOLT11 fallback on refused offers
2026-09-12 20:40:50 -04:00
Claude 6030d22aa9 fix(commons): declare okio so the Apple targets compile after the Compose split
test-quartz-ios failed on :commons:compileKotlinIosSimulatorArm64 with
"Unresolved reference 'okio'" in service/image/DeferredDeleteFileSystem.kt.
The file always used okio, but commons only ever got it transitively:
through Coil on every target, and through OkHttp on JVM. Moving Coil to
:commonsUI removed the only Apple-side provider, and the JVM builds kept
passing, so nothing caught it locally.

Declare okio (3.18.1, the version already resolved everywhere; Apache-2.0
per its POM) in commons commonMain. Reproduced and re-verified on Linux with
:commons:compileCommonMainKotlinMetadata, which resolves commonMain against
the shared-dependency set exactly like the iOS compile. That task, for all
three KMP library modules, is now part of the lint job so this class of gap
fails fast on Linux.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-13 00:36:31 +00:00
Claude d108ba0cc8 fix(relay): answer CLOSE in the Android socket too, and stop trusting socket callbacks for pool bookkeeping
Follow-ups from an audit of the two previous commits on this branch.

The Android app does not use quartz's BasicOkHttpWebSocket: AppModules wires
its own OkHttpWebSocket, a near-twin that decides needsReconnect() from the
OkHttpClient in use. The onClosing answer therefore only reached desktop,
CLI and geode. Mirror it here, with the same loopback RFC 6455 test. The
unit-test android.util.Log stub gets a primitive-signature isLoggable (the
boxed one it had was a different method to the JVM, which is why OkHttp
could never be built in this module's tests) plus println, so the socket can
be exercised for real.

RelayPool now clears its connected flow itself on every path where it lets
a relay go -- removeRelay, removeAllRelays and disconnect -- instead of
waiting for a callback the socket layer may never deliver. The previous
commit added a members-read snapshot and migrated three callers; the other
consumers of connectedRelaysFlow (drawer status, connection-time accounting,
"wait until connected" loops) were still reading the stale set.

BasicRelayClient retires its listener before tearing a socket down and
ignores anything a retired socket reports afterwards. OkHttp delivers
onClosed from its writer thread and a cancelled socket's failure later
still; with the pool rebuilding sessions via disconnect()+connect(), a late
callback from the old socket could null the new one, orphaning a live
connection and dialing a third. disconnect() also reports onDisconnected
itself now, so a client-initiated teardown never depends on the socket
layer confirming it.

KDoc and comments that described the unanswered-close behaviour in the
present tense, or claimed a still-desired relay reads isConnected()=false
on a silent close, are corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 23:28:47 +00:00
Claude 5142c88629 build: restore upstream's Gradle 10 source-set cleanup in quartz after the merge
The merge of origin/main resolved quartz/build.gradle.kts with the branch
side wholesale, which kept the shared purity-gate apply but dropped the
`by getting` -> `getByName(...)` cleanup from 6e3af61e. Re-applied on top.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 23:20:12 +00:00
Claude 732baf8a8a Merge remote-tracking branch 'origin/main' into claude/intelligent-newton-bw5dmf
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt
2026-09-12 23:18:43 +00:00
Claude ae0b70728e Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5
# Conflicts:
#	commons/build.gradle.kts
#	quartz/build.gradle.kts
2026-09-12 23:11:15 +00:00
Vitor Pamplona ea28ea2f76 Merge remote-tracking branch 'upstream/main' into main 2026-09-12 19:06:40 -04:00
Vitor PamplonaandGitHub 8c6a38a7bf Merge pull request #4110 from vitorpamplona/claude/stoic-faraday-2qv9f5
fix: clear every compiler, Gradle and mechanical lint warning, and two things the sweep turned up
2026-09-12 19:06:31 -04:00
Vitor PamplonaandGitHub 49fb3a7a00 Merge pull request #4109 from vitorpamplona/claude/determined-volta-u4l2rf
test: run relay-backed tests against geode; fix the relay bugs that surfaced
2026-09-12 19:06:20 -04:00
Vitor PamplonaandClaude Opus 5 78fef44e2f Merge PR: fix(desktop): stop silent account wipe on keychain-access errors
Merges nostr proposal 5d31b68e into main:
- fix(desktop): stop silent account wipe on keychain errors and upgrade races
- fix(desktop): allow first-launch key bootstrap and stop caching unwritten state

Desktop lost every logged-in account whenever the OS keychain answered
ambiguously. getOrCreateKey() treated any failed lookup as "key absent" and
minted a fresh AES key, orphaning accounts.json.enc; the read path then
renamed the unreadable file to .corrupt.<ts>; and nothing serialised access,
so a Homebrew upgrade race could interleave two instances.

Now: getPrivateKeyOrThrow() distinguishes confirmed-absent from refused or
ambiguous (macOS via /usr/bin/security exit codes) and only the former
rotates; genuine corruption (AEAD tag, bad padding, malformed JSON) is
separated from transient failures, which preserve the file and surface as
StorageCorruption.TransientError; and a cross-process advisory lock plus
in-process mutexes guard the file.

Review follow-ups in the second commit: non-macOS keyring backends throw for
a genuinely absent credential, so a fresh Linux/Windows install could never
mint the key -- creation is now allowed when accounts.json.enc does not yet
exist, where there is no ciphertext to orphan. And the metadata cache is
populated only after the disk write succeeds, so a failed save no longer
leaves the session serving accounts that were never persisted.

Verified on macOS against the real Keychain and the real account store:
security lookup exit 0, accounts load and survive a restart, ciphertext
byte-identical, zero corruption events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 19:03:09 -04:00
Claude add7bfe3ca fix(http): close the read-auth single-flight window left open before putIfAbsent
signOnce() looked at the cache a second time to catch a leader that had already
finished, but it looked too early — before claiming the in-flight slot. The gap
between that look and putIfAbsent still spans signerProvider() and an allocation,
and a leader caches its token and retires its entry inside it. A straggler in
that gap therefore put into a map the leader had just emptied, won the slot, and
signed a duplicate.

Winning the slot is not proof that nobody signed; only a look from inside it is.
Once we hold the entry no one else can be leader, and our successful put observed
the map after that leader's removal, which its cache write is ordered before — so
a token visible at that point is the last word. Hand it over and stand down.

This is pre-existing, not fallout from the dispatcher change: the same test fails
on an unmodified origin/main worktree, and aFastSignerStillSharesOneSignature
already says the window is "microseconds wide, so one round hits it only now and
then" and runs 200 rounds to catch it. It cost a duplicate signature — with a
NIP-55 external signer that is a second IPC round trip, and potentially a second
prompt, for a burst of images from one gated host.

Verified with 8 consecutive runs of the suite (1600 signing rounds), all green,
against a baseline that failed inside the first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 22:52:59 +00:00
Claude b8135e76c4 fix(relay): answer a relay's WebSocket CLOSE frame so OkHttp can finish the handshake
OkHttp fires onClosed only once both peers have sent a CLOSE frame, and
sending ours is the application's job (WebSocketListener KDoc; RealWebSocket
emits onClosed solely from the writer once our Close is dequeued with the
peer's code already set; the bundled WebSocketEcho recipe answers onClosing
with close(1000, null)). BasicOkHttpWebSocket never implemented onClosing,
so a relay-initiated close left the socket half-closed: no onClosed, no
onFailure, send() still accepted and silently discarded, and a later
cancel() silent as well because no reader was left to fail. The relay
client kept believing it was connected, with its REQs live, until the 120s
ping path finally failed up to two intervals later.

Answer onClosing with close(1000, null). Verified against OkHttp 5.5.0:
onClosed then fires at once whether the relay still holds the TCP session
or has already dropped it, and the existing onClosed path in
BasicRelayClient marks the connection closed and lets the pool reconnect
under its normal backoff. Always 1000 rather than echoing the relay's code,
since close() validates the code it writes and relays may send reserved
ones.

The test drives the wrapper against a minimal RFC 6455 server on a loopback
ServerSocket (no new dependency): the relay sends CLOSE, the client must
answer with its own CLOSE frame and report onClosed with the relay's code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 22:52:37 +00:00
Claude 18c576a068 fix(relay-server): acknowledge a superseded replaceable with OK true duplicate:
Second finding from the Marmot headless harness on geode. wn's `keys
publish` mints a KeyPackage (kind 30443, same `d` tag) in the same second
as the one its bootstrap already published; NIP-01's lowest-id-wins tie
keeps the stored one, and the insert of the loser trips the addressable
unique index. The store classified that as a rejection carrying SQLite's
text — "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey,
event_headers.d_tag" — so the relay answered OK false with a reason no
client can classify, and MDK filed it as "publish acknowledgement
unknown" and retried forever.

nostr-rs-relay, which this harness was validated against, does not even
attempt the insert when a newer version exists and acknowledges the event
as `OK true "duplicate: ..."` (its Duplicate status maps to true). Match
that: the replaceable and addressable unique-index failures now classify
as RejectionReason.SUPERSEDED, a `duplicate:`-prefixed reason the session
already answers with OK true. The stored version is untouched, nothing is
fanned out, and the STORE-W01/W02 contract in the event-store-semantics
skill is updated to say so.

Tests: NostrServerTest covers an older kind-0 re-insert and the
same-second kind-30443 tie, asserting the OK true duplicate: reply and
that the winner remains the only stored version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 22:18:04 +00:00
Claude 5b846d64d6 fix(relay-server): answer a duplicate EVENT with OK true, per NIP-01
Running the Marmot headless harness against the embedded geode relay
failed 10 of 29 scenarios, every one on the same reply: the relay
answered a resent EVENT with

  ["OK", <id>, false, "Error code: 2067, message: UNIQUE constraint
   failed: event_headers.id"]

NIP-01 says a relay that already holds the event answers
["OK", <id>, true, "duplicate: already have this event"], and every
client here depends on that: amethyst's outbox writes an event as soon
as the socket is ready and resends it when the connection finishes
syncing, so one of the two copies is always a duplicate; MDK's wn
counts a `duplicate:` prefix as idempotent success but files an
unclassified OK false as "publish acknowledgement unknown" and keeps
retrying. Both amy's group commits and wn's KeyPackage publish were
failing on it, while nostr-rs-relay had answered the resend correctly.

SQLiteEventStore now recognises the unique-index violation on
event_headers.id and reports RejectionReason.DUPLICATE, the constant
that already carried NIP-01's exact wording but was never produced;
RelaySession sends `OK true` for a `duplicate:` reason and keeps
`OK false` for every other rejection. The store outcome stays Rejected,
so a duplicate is still not fanned out to live subscriptions or counted
as a new write by the mirror worker and importer. The filesystem store
already treated a duplicate insert as a no-op.

Two tests pinned the old OK false behaviour (NostrServerTest,
KtorRelayTest) and now assert the NIP-01 reply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 22:11:23 +00:00
Claude 4fa619d2a2 fix: audit of commons and commonsUI — 34 verified bug and performance fixes
Three read-only sweeps over commons (feeds, relayClient, model, viewmodels,
services) and commonsUI produced 44 candidates; each was re-read in the
source, 38 held up, 34 are fixed here, 4 are deferred with rationale in
commons/plans/2026-09-12-audit-findings.md.

Correctness highlights:
- SecureKeyStorage (jvm): AES-GCM was initialised with IvParameterSpec, which
  every JDK rejects, so the no-keyring fallback never worked. GCMParameterSpec.
- OnionLocationInterceptor cached any Onion-Location header and re-pointed all
  Tor traffic at it for 24h over plain http; only .onion targets now.
- BasicBundledInsert wedged forever after one exception (no finally).
- CachedRichTextParser keyed parses on a 32-bit hash without checking inputs.
- noProtocolUrlValidator backtracked exponentially per composer keystroke.
- Base83 indexed a 255-entry table with any char code from an imeta tag.
- MetadataRateLimiter never flushed a batch smaller than 20 pubkeys.
- FeedMetadataCoordinator mutated six HashSets from two threads and marked
  pubkeys 101+ as requested without asking for them.
- Note: two discarded boolean/relay expressions, an NPE window in flow(),
  removeReport leaving empty buckets, unlocked read-modify-write on
  replies/boosts/edits/reactions/reports/labels.
- EventCollectionState restarted its flush timer on every insert.
- Chatroom prune outside the lock; top-zappers publish outside the mutex;
  hashCode used as dedup/feed keys; OnlyLatestVersionSet.addAll always true.
- UI: ClickableTexts remembered a stale onClick, ZonedSwipeModifier a stale
  openDrawer, two robohash light-theme predicates thrashed one cache, a chess
  remember key summed two counters.

Performance: user-cache search off the Compose dispatcher, regexes hoisted
in SearchResultSorter, frame-rate animation reads moved out of composition
in Shimmer/LoadingAnimation/BunkerHeartbeat, GlowingCard allocations cached,
emoji inlineContent remembered, itemsIndexed in the search pickers.

Ten regression tests added (rate limiter flush/dedup/rate, GCM round trip,
regex timing, Base83 bounds, CosineCache key). Verified: commons and
commonsUI jvmTest, compiles of cli, desktopApp, nappletHost, amethyst.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 22:03:11 +00:00
Claude fe8580ca56 chore: share the KMP purity gate, fix stale doc paths and dead imports after the split
Follow-ups from a review of the commons/commonsUI split:

- `verifyKmpPurity` was copy-pasted into quartz, commons and commonsUI and the
  three copies had already drifted (checked dirs, hint text). It now lives
  once in gradle/kmp-purity.gradle.kts and each module applies it; the
  checked-dir list is the union, filtered by existence. Verified the shared
  task still fails on a deliberate java.util.UUID reference in commons.
- amethyst/src/main/res/CLAUDE.md and the compose-expert catalog reference
  still pointed at the pre-split composeResources / ui paths.
- Same-package imports left behind in the files moved to commons.feeds and
  commons.model are removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 21:43:20 +00:00
Vitor PamplonaandClaude Opus 5 1ea820e699 fix(desktop): allow first-launch key bootstrap and stop caching unwritten state
Two defects found reviewing the strict-keychain fix.

1. Fresh Linux/Windows installs could never persist an account.

   getPrivateKeyOrThrow turns any PasswordAccessException into a
   SecureStorageException on non-macOS backends, but every backend
   java-keyring ships throws that exact exception for a *genuinely absent*
   credential:

     - WinCredentialStoreBackend: CredReadA false (ERROR_NOT_FOUND) -> throw
     - FreedesktopKeyringBackend: empty object paths ->
       throwNoExistingCredentialException
     - KWalletBackend: hasEntry false -> "Password is not in wallet"

   So the strict lookup structurally cannot report "definitively absent"
   there, the create branch in getOrCreateKey was unreachable, and nothing
   between it and AccountManager.addAccountToStorage catches the throw.

   getOrCreateKey now bootstraps a fresh key when the strict lookup fails
   *and* accounts.json.enc does not exist. With no ciphertext on disk there
   is nothing a new key can orphan, so the invariant the strict contract
   protects is untouched: once the file exists the exception propagates
   exactly as before.

2. writeCachedMetadata updated the in-memory cache before the disk write,
   so a failed write (keychain refusal, I/O error, disk full) left the
   session serving accounts that were never persisted -- a save that
   reported success and vanished on the next launch. Persist first, cache
   second.

Both are pinned by new tests, and both were mutation-checked: reverting
either fix fails exactly one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 17:43:12 -04:00
Claude 9e7ffe6854 Merge remote-tracking branch 'origin/main' into claude/determined-volta-u4l2rf
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt
2026-09-12 21:39:53 +00:00
Claude 501c9a5b5a Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt
2026-09-12 21:38:48 +00:00
mstrofnoneandVitor Pamplona 285a51e98f fix(desktop): stop silent account wipe on keychain errors and upgrade races
Three independent bugs in DesktopAccountStorage / SecureKeyStorage could
turn one ambiguous macOS Keychain reply, one transient read error, or
one Homebrew upgrade race into permanent account-metadata loss on
~/.amethyst/accounts.json.enc.

1. Silent AES metadata-key rotation on ambiguous keychain miss.
   getOrCreateKey() treated null from getPrivateKey("account-metadata-
   key") as "no key exists" and generated a fresh AES key. On macOS
   javakeyring collapses errSecItemNotFound (-25300), errSecAuthFailed
   (-25293), errSecUserCanceled (-128), and errSecInteractionNotAllowed
   (-25308) into the same PasswordAccessException; getFromKeyring turns
   them all into null. A single Deny click on the OS Keychain dialog
   silently rotated the AES key and destroyed the ability to decrypt
   the existing accounts.json.enc.

   Fix: add a new strict SecureKeyStorage.getPrivateKeyOrThrow(npub)
   on the common expect. On JVM/macOS it wraps /usr/bin/security
   find-generic-password whose exit codes (0 = found, 44 = not found,
   others = ambiguous) are documented and unambiguous. On JVM
   Windows/Linux it uses javakeyring but throws on any
   PasswordAccessException from the strict path. On Android it uses
   EncryptedSharedPreferences.contains(). On iOS it mirrors the
   existing "pending (iOS Phase 4)" stub. getOrCreateKey now calls
   getPrivateKeyOrThrow and propagates SecureStorageException without
   ever rotating the key. The permissive getPrivateKey(npub) is
   unchanged; its callers (per-account nsec, ephemeral bunker keys)
   still tolerate null on any error.

2. Any read failure resets the file. readMetadataFromDisk() used to
   rename to accounts.json.enc.corrupt.<ts> and return empty
   AccountMetadata() on any exception, including transient IO and
   the newly-throwing keychain path from bug 1.

   Fix: distinguish exception types.
   - AEADBadTagException / BadPaddingException: back up to
     .corrupt.<ts>, reset, fire StorageCorruption.FileCorrupted
     (unchanged).
   - JacksonException: back up but to .jsonerror.<ts> so it is
     distinguishable from ciphertext corruption; fire
     StorageCorruption.JsonMalformed.
   - Anything else (IO error, OOM, thrown keychain path): do NOT
     rename; rethrow to caller and fire a new
     StorageCorruption.TransientError(cause) subtype. The file stays
     untouched. AccountManager.loadSavedAccount already wraps in
     try/catch and turns the throw into Result.failure.
   - Truncated file (size < GCM IV size): still backup + reset,
     genuinely unusable.

3. No cross-process advisory lock. Homebrew replacing the .app while
   the old process is mid-save, or an accidental double-launch of
   Compose Desktop (no built-in single-instance guard), could produce
   a truncated file that trips bug 2.

   Fix: withAccountsFileLock helper (mirrors SecureKeyStorage.
   withFileLock) wraps read + write in a
   RandomAccessFile(lockFile, "rw").channel.lock() on
   ~/.amethyst/accounts.json.enc.lock (0600). Because FileChannel.lock
   is per-JVM, an in-process Mutex is held before acquiring the
   channel lock. A separate stateMutex guards the read-modify-write
   cycle in saveAccount / deleteAccount / setCurrentAccount so two
   concurrent writers cannot each read the same base metadata and
   each rewrite it.

Backward compatibility: existing keychain items are read unchanged;
no schema migration for accounts.json.enc; the file lock adds a
.lock sidecar older builds ignore.

Tests: new SecureKeyStorageOrThrowTest (pure exit-code parser,
mac lookup Found/NotFound/Ambiguous, non-mac keyring hit and
throw-on-PasswordAccessException). DesktopAccountStorageTest gains
five cases: getOrCreateKey ambiguous-error preserves file and does
not rotate; getOrCreateKey definitive-not-found happy path;
readMetadataFromDisk transient-IO preserves file with no backup
sibling; GCM tag mismatch keeps .corrupt.<ts> backup; JSON malformed
uses new .jsonerror.<ts> suffix; eight concurrent saveAccount calls
serialize under the file lock with no lost updates. All existing
AccountManager* MockK setups extended to also stub
getPrivateKeyOrThrow.

Local verify: :desktopApp:test + :commons:jvmTest, 2490 tests, all
pass. Spotless clean.
2026-09-12 17:28:09 -04:00
Vitor PamplonaandGitHub 8fa4a72a9a Merge pull request #4108 from davotoula/fix/blossom-read-auth-single-flight-race
fix(blossom): re-check the token cache after winning the in-flight slot
2026-09-12 17:18:43 -04:00
Claude 97b13b7c47 refactor: move reply-context logic to commons.model; keep the Compose compiler in commons by measurement
Closes the last two items of documented debt from the commons/commonsUI split.

ParentNote (replyingDirectlyTo, isCommunityDefinition) and ReplyContext are
pure thread logic used by ViewModels, so they move from the misleading
`ui.note` package to `commons.model`, next to ThreadAssembler, together with
their tests and the StubCache fixture that shared the package. No `ui.*`
package is left in commons. The `ui.note` composables in commonsUI gain
explicit imports; consumer imports rewritten.

Whether commons still needs the Compose compiler plugin was an open question;
it is now measured. With compiler reports on the three GUI modules and full,
non-incremental recompiles in both configurations, removing the plugin flips
composable parameters typed with unannotated commons classes (TopFilter,
TorSettings, ProfileBroadcastStatus, ScheduledPost, EmojiPackState, ...) from
runtime-stable to unstable: 20→28 in commonsUI, 33→65 in desktopApp,
90→149 in amethyst. The plugin stays; the numbers are recorded in the build
file, ARCHITECTURE.md and the split plan so the question is not reopened.

Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli/desktopApp JVM
test suites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 20:58:28 +00:00
Claude 42698ed59c fix(eventsync): drain the outbox before closing and count each send once
Audit of the sync path the new geode-backed EventSyncTest exercises found
two bugs in EventSync itself, plus review nits on the harness changes.

- runSync closed its client (`use {}`) the moment the last page arrived,
  while `publish` is fire-and-forget through the client's outbox. Events
  forwarded from the final page of the last relay were still waiting for
  a socket or an OK when the outbox was destroyed, so the sync reported
  Done and silently never delivered them. Wait, bounded by the existing
  per-relay timeout, until no forwarded event has a relay left pending.
- The "events sent" counters incremented on every onSent, including the
  failed write to a destination still connecting and the outbox's
  at-least-once resend of an unacknowledged event after the connection
  syncs. Every cold destination therefore reported at least one extra
  event sent. Count only successful writes, once per (event, relay).
  The test now asserts the sent total equals the routed total.
- Harness: the 127.0.0.2 rationale claimed it survives Quartz's
  isLocalHost() strip; that filter now covers all of 127.0.0.0/8, so
  say so and note what it means for the strict-inbox DM cases. The
  interactive Marmot harness gets an overridable RELAY_HOST/RELAY_BIND
  and documents the loopback/RFC1918 stripping limit it inherits, and
  its new --port guards a missing value instead of dying on set -u.
- EventSyncTest builds both scenarios through one helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 20:57:11 +00:00
Claude 943e137d4e perf(http): stop wiping the media connection pool, and size the dispatcher for a phone
Two problems in the shared non-relay client, both of which make every HTTP
request cost more than it should.

The connection pool was being emptied constantly. buildHttpClient() evicted the
whole pool whenever the proxy differed from the last one it was handed, tracked
in a single field. But one factory mints BOTH long-lived variants:
DualHttpClientManager builds defaultHttpClient (always SOCKS, since
buildLocalSocksProxy falls back to 9050 rather than returning null) and
defaultHttpClientWithoutProxy (always null) from the same instance, and the two
share one rootClient.connectionPool. So the field alternated between the proxy
and null forever, and every rebuild read as a route change and wiped the pool
they share. Both are stateIn(WhileSubscribed(1000)) flows collected from a
composable, so that happened on every isMobileDataProvider change and every
foreground round trip — and the next image then paid a fresh DNS + TCP + TLS.
This is a plausible source of the "first image after a pause takes forever"
stall the pingInterval above it was added for.

ProxyRouteTracker narrows it to what the eviction was actually for: a direct
build never evicts (null is that variant's permanent route), and a proxied build
evicts only when the Tor port really moved. Nothing is lost by being this narrow
— OkHttp's Address, the pool key, already includes the proxy, so proxied and
direct connections to the same host are distinct entries that can never be
handed to each other's calls.

Second, maxRequests was 128. Dispatcher's executor is an unbounded cached pool,
so that is the thread ceiling: up to 128 threads running 128 concurrent TLS
handshakes on a handset. Nothing upstream bounds the arrival rate either —
Coil's enqueue is unbounded and PrefetchFeedMedia warms ±3 notes on both sides
of the viewport on every visible-range change — so a fast scroll really does
reach it. Past saturation, more concurrency slices the same bandwidth thinner
and pushes every image's completion out together, the on-screen one included.
32 total / 8 per host keeps the per-host lift that feeds need while letting
visible images finish and paint.

The pool fix is covered by tests. The dispatcher numbers are a reasoned choice,
not a measured one — worth a run against benchmark/ before release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 20:55:41 +00:00
Claude e076b1eaee fix(media): stop a no-imeta GIF rendering as an invisible note while it loads
GifVideoView's Loading branch emitted DisplayBlurHash unconditionally, and
DisplayBlurHash renders nothing at all when both hashes are absent —
placeholderModel(null, null) returns null and the composable early-returns.

That is exactly the state a post with no imeta lands in. With no `dim` tag and
nothing in MediaAspectRatioCache, `ratio` is null, so mediaSizingModifier falls
to a bare fillMaxWidth() with no height constraint. The container then wraps an
empty loading state and the whole note collapses to zero height: no picture, no
URL, no spinner, just a gap in the feed for however long the fetch takes, and
then the image appearing from nowhere. Seen on a kind-1111 comment from Sidecar
whose content is a single blossom .gif URL.

UrlImageView already has the ladder this needs, so mirror it: blurhash/thumbhash
when there is one, a spinner in the reserved box when only a ratio is known, and
otherwise the URL plus a loading symbol via WaitAndDisplay. Every branch now
emits something with a height.

This is the missing feedback, not the latency — a slow fetch still takes as long
as it takes, it just stops being invisible while it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
2026-09-12 20:55:22 +00:00
Claude 2ab934b2d2 perf: stop debugState walking the whole cache when its output is dropped
MainActivity.onPause() calls debugState() unconditionally, and every line it
emits is Log.d built through the *eager* overload — so the arguments are
evaluated before the level check. Those arguments are the expensive part:
nine materialising LargeCache.filter scans over notes/addressables/users and
the three channel maps, nested sums over every channel's and chatroom's
notes, two sorted passes, and three passes calling Event.countMemory(), which
itself walks every tag and tag element of every cached event.

Release builds sit at WARN, so all of that ran on every backgrounding and the
result was discarded. Gated on `Log.minLevel > LogLevel.DEBUG` rather than
`isDebug`, because that is exactly the condition under which the lines are
dropped: benchmark builds are `isDebug` but sit at INFO, so an isDebug gate
would have left the one variant whose numbers are meant to be trustworthy
still paying the cost. The function only reads and logs — no mutation — so
the early return cannot skip a side effect.

The same shape is already gated elsewhere: AppModules builds relayReqStats
and bootDiagnostics as `if (isDebug) ... else null`, and BootRelayDiagnostics
does comparable work. debugState was the one that was not.

fix: rethrow CancellationException in six catch blocks

All six sat in suspend functions whose try body contains a suspension point,
so a cancelled scope surfaced as CancellationException and was swallowed,
against the `if (e is CancellationException) throw e` convention this
codebase follows in 192 files. What cancellation used to mean:

- VanishRequestsState: published ComplianceStatus.ERROR, showing a relay as
  having answered badly when it was never asked.
- NappletResourceFetcher: reported ERROR_NETWORK to the sandboxed page, so a
  cancelled fetch was indistinguishable from an upstream failure.
- MarmotAgentStreamWatcher (two sites): the per-candidate handler does
  `continue`, so a cancelled watcher kept dialling the remaining broker
  candidates.
- CallSession: logged as a PeerConnection creation failure.
- NamecoinSharedPreferences: returned emptyList(), i.e. "no pinned certs".

Uses kotlin.coroutines.cancellation.CancellationException, the import that
also works from commons/commonMain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 20:49:24 +00:00
Claude d4ac5149f7 fix(zaps): audit follow-ups on the BOLT12 fallback and profile chips
- Only retry a refused BOLT12 offer over BOLT11 when the wallet rejected it
  before attempting a payment (EXPIRED, NOT_FOUND, BAD_REQUEST,
  NOT_IMPLEMENTED, UNSUPPORTED_PAYMENT_INSTRUCTION, UNSUPPORTED_NETWORK).
  NIP-47 defines PAYMENT_FAILED as possibly "due to a timeout", so an HTLC
  can still settle after that reply; retrying on it, or on INTERNAL / OTHER
  / a missing code, could pay the recipient twice. The decision is now an
  allowlist and the test locks the non-retry set.
- NwcInfoCache exposes an `updates` counter bumped on every stored entry.
  The zap picker keys its rail recompute on it, so a BOLT12-only recipient's
  bolt appears when the wallet's kind:13194 info lands after the popup
  opened, instead of only after closing and reopening it.
- A BOLT12 refusal with neither message nor code no longer toasts the raw
  "%1$s" placeholder; the code name (or OTHER) fills the detail.
- One abbreviateBolt12Offer() replaces three copies of the lno1 truncation
  in the profile chip, the offers dialog and the settings screen.
- Reword the "these four" recompute-key comment so it covers the BOLT12
  reads added alongside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 20:47:05 +00:00
davotoula ae2e971dd9 fix(blossom): re-check the token cache after winning the in-flight slot
The pre-insert cache look added in 6dc631e85d narrowed the single-flight
gap but didn't close it: a fast leader can insert, sign, cache and retire
its entry entirely between a straggler's cache read and its putIfAbsent,
so the straggler wins an empty map and signs a second time. That is the
intermittent `expected:<1> but was:<2>` in aFastSignerStillSharesOneSignature
failing main CI.

Check the cache again once this caller owns the slot. A leader always caches
before retiring its entry, so any token minted before the insert is visible
there; take it and give the slot back instead of re-signing.

Reproduced locally at round 2431 of 5000; two 5000-round runs pass with the fix.
2026-09-12 22:29:04 +02:00
Claude 88ae3d0f5a fix: close the fast-signer single-flight race in BlossomReadAuthTokenProvider
`aFastSignerStillSharesOneSignature` failed on a loaded machine (rounds 11,
18 and 31 across three runs, on the branch head and on the already-pushed
commit alike): a straggler could miss the in-flight map, miss the cache,
get descheduled, and then win `putIfAbsent` only because the fast leader
had already cached *and* retired its entry — and sign a second time.

Take one more look at the cache after winning the in-flight slot. A prior
leader's cache write happens before its `remove`, and winning the slot
after that `remove` goes through the same ConcurrentHashMap bin, so the
just-minted token is visible there; hand it out and retire the slot instead
of launching a duplicate signature. The test class now passes four runs in
a row where it previously failed three in a row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 20:20:30 +00:00
Claude 1093b3ce8a fix(notifications): count connected relays from the pool's live socket state
The always-on notification read its "Connected to N relays" from
RelayPool.connectedRelays, a set that only moves on the onConnected /
onDisconnected callbacks. That set over-reports: a relay that sent a
WebSocket CLOSE frame never produces a callback (the app does not answer
onClosing, so OkHttp fires neither onClosed nor onFailure, and the later
cancel() is silent too), so the URL lingers until the 120s ping path
finally fails. After the feeds tore down in the background this left
hundreds of already-dropped relays in the count for minutes, with no
subscription in the "show details" breakdown to justify any of them.

Expose the pool's ground truth instead: RelayPool.connectedRelayUrls()
reads each member's isConnected(), surfaced as INostrClient.connectedRelays()
(defaulting to the flow's value for pool-less clients). The notification
keeps the flows only as a trigger, merging in availableRelaysFlow because
that is the flow that moves when the pool drops such a relay, and re-reads
the live count on each sample. The "show details" breakdown and the
Active Subscriptions screen read the same source so all three agree.

A pool test pins the drift: with a socket layer that never confirms the
close, removing a relay leaves it in the flow but out of the snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
2026-09-12 20:17:29 +00:00
Claude 3887b033e0 feat(zaps): fall back to a BOLT11 zap when the wallet refuses a BOLT12 offer
The wallet resolves the offer itself, so a stale or unreachable kind:10058
offer surfaces as a NIP-47 error reply, which by the spec means nothing was
paid. When that recipient also publishes a lightning address, re-send the
same share as a regular zap through the BOLT11 lane instead of toasting the
BOLT12 error; the toast stays for a recipient with no other route.

Bolt12LightningFallback keeps the decision pure and tested: every refusal
retries except the ones about our own wallet (insufficient balance, quota,
rate limit, restricted, unauthorized, unsupported encryption), which would
fail the same way over BOLT11. A paid-but-no-receipt outcome is never
retried, and neither is a wallet that never answers: sendBolt12Zap now
passes a timeout handler, so a silent wallet reports a timeout and steps
the progress instead of leaving the zap hanging.

The BOLT11 lane moves into zapOverLightning so the main zap and the
fallback share one path, and Bolt12Recipient carries the lnAddress and
relay hint the retry needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 20:17:09 +00:00
Claude 713b7d41f6 refactor: move feed DAL to commons.feeds, chess UI to nip64Chess.ui, tighten amy budget
Follow-ups to the commons/commonsUI split.

Feed data-access layer: FeedFilter, AdditiveFeedFilter, AdditiveComplexFeedFilter,
ChangesFlowFilter, FeedContentState, FeedState, InvalidatableContent,
DefaultFeedOrder, RepostRenderability and friends move from the misleading
`ui.feeds` package to the root of `commons.feeds`, next to `feeds/custom`.
The `ui.feeds` composables (NewPostsChip, RelayReachMarker, DM history cards)
stay in commonsUI and gain explicit imports. Consumer imports rewritten.

Chess: the eleven composable files in commonsUI move from the flat
`nip64Chess` package to `nip64Chess.ui`; the logic stays in
`commons/…/nip64Chess`. Consumer imports rewritten.

CLI size budget: measured after the split (1.15.2, Linux x64) the JVM
tarball is 55 MB and the jlink image tarball 80 MB, so the release gate
drops from 200 MB to 120 MB per asset. BUILDING.md, the architecture docs,
the feed-patterns skill and the split plan record the new state.

Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli/desktopApp JVM
test suites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 20:13:18 +00:00
Claude 6a4ddfa974 fix(blossom): re-check the cache after winning the read-auth in-flight slot
aFastSignerStillSharesOneSignature still failed about two runs in five: a
straggler that read the in-flight map and the cache as empty could then win
putIfAbsent because the leader had already signed, cached and retired its
entry in between, and would sign a second token.

The leader's cache put happens-before its removal of the same key, so once
a caller has claimed the slot a cached token, if any, is visible. Look once
more there: hand the cached token to this caller and to any follower that
already picked up the fresh deferred, retire the entry, and skip the
signature. Ten reruns of the class pass where two in five failed before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 19:21:27 +00:00
Claude 1f4d2bf9b5 fix: clear the mechanical Android Lint warnings
Lint reports 874 warnings / 0 errors per amethyst variant. 512 of those are
in Crowdin-managed values-*/strings.xml (mostly MissingQuantity — a
translator's plural missing a quantity) and are not ours to hand-edit; this
takes the ones that are mechanical and behaviour-preserving:

- EmptySuperCall (24 in amethyst, plus 2 in commons the amethyst run cannot
  see): ViewModel.onCleared is documented empty, so drop the super calls.
- UseKtx (2): Canvas.withTranslation for the LaTeX drawable, and
  Bitmap.toDrawable for the map pin — the KTX form CLAUDE.md asks for, and
  both compile to the same calls.
- ConstantLocale (1): CalendarEventListCard held its "MMM" formatter in a
  file-level val, which captures Locale.getDefault() once — month
  abbreviations stayed in whatever language was active at class init. The
  formatter is now cached per locale, which keeps the property the original
  comment was protecting (a formatter per recompose was 500 allocations while
  scrolling), and the locale comes from LocalLocale.current.platformLocale so
  the read is observable: Locale.getDefault() inside a composable is not, and
  Compose's own NonObservableLocale check rates that an error.
- UnusedResources (6): the Android Studio new-project wizard's leftover
  colors (purple_200, teal_200, teal_700, black, white, transparent), each
  verified unreferenced from Kotlin and XML. purple_500/700 are in use and
  stay.
- UseTomlInstead (3): the debug-only Compose/Perfetto tracing dependencies
  move into the version catalog. Same coordinates and versions; the catalog
  already carries BOM-managed versionless entries.

playDebug goes from 874 warnings to 838, still 0 errors.

Deliberately left, because each is a decision rather than a cleanup:
AppLinkWarning (autoVerify only works if the domains serve a matching
assetlinks.json), the 126 unused source strings and 10 PluralsCandidate
(both churn the translation surface), GradleDependency /
NewerVersionAvailable (dependency bumps need the license check), VectorRaster
/ VectorPath / IconDensities / IconXmlAndPng (redrawing assets), BatteryLife
(the battery-optimization helper working as designed), and InlinedApi /
ClickableViewAccessibility / DiscouragedApi / InsecureBaseConfiguration (each
needs its surrounding intent read first).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 19:17:39 +00:00
Claude 5e8fdedb83 fix(zaps): offer the Lightning rail to BOLT12-only recipients
The zap picker gated its Lightning bolt on the recipient's lud16/lud06, but
the send path has routed a recipient with a kind:10058 offer over BOLT12 for
a while (when our default NWC wallet advertises `pay`). A recipient who
published only an offer therefore had no bolt in the popup and no one-tap
zap, even though ZapPaymentHandler could pay them.

Teach RailCapabilityResolver about the BOLT12 route: hasLightning is now true
for a recipient with an offer when our wallet can pay offers. The rail keeps
its single bolt — which flavour is used stays a send-time decision. The
popup observes the recipient's offer list and the default wallet URI so the
bolt appears as those load, and the one-tap fast path uses the same check.
The sender-side test moves into AccountZapActions.canZapViaBolt12 so the
handler, the picker and the profile dialog share it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 18:49:03 +00:00
davotoula 54252b0fb9 docs(skill): teach find-missing-translations to seed source-identical values
The Crowdin workflow now passes import_eq_suggestions: true, so a value
equal to the English source is no longer skipped on upload — the repo's
locale files are the seed for Crowdin's database. The skill said the
opposite ("Don't add source-identical fallbacks"), which now leaves keys
untranslated in the UI forever.

- Background rewritten for the flag, with the confirming evidence: run
  34706537802 -> PR #4107 round-tripped 330 identical values with zero
  net key changes.
- Items 1-3: missing keys are actionable in the repo; copy English
  verbatim, except <plurals> (trips MissingQuantity) and words a locale
  would genuinely translate.
- Item 4 reconciled with item 3: seeding a key Crowdin holds nothing for
  sticks; overwriting a value it holds differently still loses.
- Records the diff-reading trap: compare key sets per file, never -/+
  lines separately, or a reorder reads as a mass strip.
2026-09-12 20:43:27 +02:00
davotoula 3fefb62f1a update cs,pt,de,sv 2026-09-12 20:24:11 +02:00
Claude 6e3af61e6a fix: clear the Gradle 10 deprecation warnings in the build scripts
Every build printed "Deprecated Gradle features were used in this build,
making it incompatible with Gradle 10". With --warning-mode all that was
five distinct Kotlin DSL delegated-property deprecations, all in our own
scripts:

- `val x by extra(...)` / `val x: T by extra` in the root script, for the
  opt-in Sonar gate that buildscript {} publishes and the body reads. Now
  extra.set("x", v) and extra["x"] as T.
- `val x by getting { }` for eight of quartz's KMP source sets. Now
  getByName("x") { }, which is what commons already used. None of those
  vals were referenced, so the local binding goes away with them.
- `val x by tasks.registering { }` and the typed
  `by tasks.registering(T::class) { }`, thirteen tasks across quartz,
  commons, cli, geode, nestsClient and desktopApp. Now
  tasks.register("x") { } and tasks.register<T>("x") { }, which return the
  same TaskProvider, so the dependsOn / finalizedBy references to them are
  unchanged.

`./gradlew --warning-mode all help` is now silent, and all nineteen
converted tasks still register and run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 17:50:14 +00:00
Claude cfa6f72760 fix(blossom): take a third cache look once the in-flight slot is held
BlossomReadAuthTokenProviderTest.aFastSignerStillSharesOneSignature still
fails about one run in three (round N: expected 1 signature, was 2), which
the pre-push hook turns into a hard block on every push.

The second cache look added in 6dc631e8 closes the leader-finished-early
window for callers that reach it after the leader retired its entry, but
not for a caller parked between that look and its own putIfAbsent: a
leader that starts after the caller's miss can sign, cache and retire in
that gap (a local key does it in microseconds), so the parked caller's
putIfAbsent then succeeds against an empty map and mints a second token.

Once the caller holds the in-flight slot, any earlier leader has already
cached, because a leader caches before it retires. So a cache hit taken
at that point is definitive: hand the cached token to ourselves and to
every follower already parked on our deferred, and retire the slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 16:43:59 +00:00
Claude 6d34d3d9f9 feat(profile): show BOLT12 offers as payment pills, drop the header wallet buttons
BOLT12 offers saved in Settings were only reachable through a small bolt
button in the profile header action row, next to a second NIP-A3 wallet
button, while every other way to pay a profile (Lightning, CLINK, on-chain,
Cashu, NIP-A3 targets) rendered as a chip in the payment rail below the bio.

Render one chip per NIP-B1 offer in that rail: tap opens the existing
copy / pay-with-wallet / pay-via-intent dialog for that offer, long-press
copies the raw lno1 string. Remove both header buttons, since the rail
already lists every NIP-A3 target with the same tap-to-pay and long-press
copy behaviour the dialog rows have. The two dialogs stay (the reaction row
still opens the NIP-A3 one), so their files are renamed after what is left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
2026-09-12 16:33:56 +00:00
Claude a87222c51f refactor: split Compose UI out of commons into a new commonsUI module
`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose
resources, markdown and desktop Compose as dependencies, dragging ~40 MB of
UI/Skiko jars into every `amy` distribution. This moves every
Compose-dependent file into a new KMP module, `:commonsUI`, that
`api`-depends on `:commons`; `:commons` keeps only the Compose runtime
(stability annotations + snapshot state) and lifecycle-viewmodel.

Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split
is a build-graph boundary and no consumer import changed. 236 files were
`git mv`'d (composables, icons, robohash, theme, Coil fetchers, the
`@Composable` relay-client entry points, `composeResources`, and the tests
that exercise them). Two headless files needed surgery instead of a move:
`GalleryParser` lost a vestigial foundation `@OptIn`, and the
`LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of
`PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds`
and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels
depend on them.

`amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell
from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`,
`geode` and `marmotBench` do not. commons' androidMain gains an explicit
androidx.core KTX dep it previously got transitively through Compose UI.

CI, crowdin, the icon-font tools and the escaping hook point at the new
composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new
commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills
document the boundary. A plan doc under commons/plans records the
classification method and follow-ups.

Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites;
both verifyKmpPurity gates; the cli runtime classpath no longer resolves
Compose UI, material3, Skiko or Coil.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
2026-09-12 16:26:59 +00:00
Claude 32f0c462bb test: run relay-backed tests against geode instead of external relays
Every test that used to open a socket to something outside the repo now
talks to geode, the relay this project ships, either in-process or as the
embedded `amy serve`.

- amethyst: the Android instrumented EventSyncTest dialed vitor.nostr1.com,
  pyramid.fiatjaf.com and the nos.lol / nostr.mom defaults and asserted
  nothing. Replaced by a JVM unit test on geode's InProcessRelays that
  preloads a source relay and asserts what lands on the outbox, inbox and
  DM relays, plus a NIP-42 variant with the source behind FullAuthPolicy
  to cover the RelayAuthenticator wiring. Wires :geode, its testFixtures
  and the JVM SQLite driver into amethyst's unit-test classpath, the same
  way quartz's jvmAndroidTest already does.
- cli/tests: the cache, dm and marmot headless harnesses cloned and
  cargo-built nostr-rs-relay on first run. They now boot `amy serve`
  (geode) from the amy binary they already build, via a shared
  start_local_relay / stop_local_relay in headless/helpers.sh. Rust is no
  longer needed for the cache and dm suites at all.
- cli/tests/marmot/marmot-interop.sh: the interactive harness defaulted
  to relay.damus.io / nos.lol / primal / bitcoiner.social / nostr.mom,
  with `--local-relays` pointing at MDK's docker stack. It now boots the
  embedded relay on 0.0.0.0 by default (the phone reaches it over the
  LAN) and keeps the public set behind an explicit `--public-relays`.
- docs: cli/tests/README.md, CONTRIBUTING.md, cli/DEVELOPMENT.md and
  cli/ROADMAP.md no longer describe a loopback nostr-rs-relay.

The quartz prodbench probes (NegentropyStallRepro, CursorTerminationProbe,
NegentropyMultiRelayLiveTest, ProductionReceiverBenchmark, ...) are left
as they are: they are opt-in diagnostics of production relay behaviour,
gated behind PROD_RELAY_BENCH / NEG_MULTI / NEG_STALL_REPRO, and would
measure nothing against a local relay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 16:21:51 +00:00
Claude d63e14bb36 fix: clear the remaining compiler warnings in amethyst and desktopApp
Second half of the warning sweep, covering :amethyst (both flavors, all
build types, unit + instrumented tests), :desktopApp and :benchmark:

- GitReplyEvent (NIP-34 kind 1622) is deprecated in favour of NIP-22
  comments, but events already on relays still arrive and still have to be
  routed, rendered and surfaced. @Suppress("DEPRECATION") with that reason
  at the five production sites and the coverage test that pins them.
- The four instrumented Compose tests move to
  androidx.compose.ui.test.junit4.v2.createComposeRule. The v2 factory
  returns the same ComposeContentTestRule, so mainClock, setContent and the
  node assertions are unchanged; only the effect dispatcher differs.
- RelayAuthPromptBusTest / RelayAuthSessionGrantsTest: @OptIn for the
  ExperimentalCoroutinesApi members (testScheduler.currentTime, runCurrent)
  they already use, matching the annotation the file's other tests carry.
- MarmotFileUploader: drop a nullable alias of a non-null cipher, left
  behind when the v2 reference stopped being conditional.
- LocalCacheSearchParityTest: hoist the Json format out of the loader.
- LivesSection: FlowRowOverflow and FlowRow's overflow parameter are
  deprecated; the non-deprecated overload already clips beyond maxLines.
- HexBenchmark: drop a bare `null` expression statement from the measured
  lambda.

Also fixes :commons:compileCommonMainKotlinMetadata, which did not compile
at all: shared code called BigDecimal.toLong(), which resolves in every
platform compilation (every actual is a Number) but not in the common
metadata one, where only the expect class's own members are visible.
`expect class BigDecimal : Number` cannot work — java.math.BigDecimal leaves
toByte()/toShort() abstract, so the JVM typealias fails the expect/actual
modality check — so the conversion is a top-level expect/actual extension
instead, with actuals next to each BigDecimal actual.

Verified warning- and error-free across the jvm, android (play/fdroid ×
debug/release/benchmark), linuxX64, and the common/jvmAndroid/native/apple/
ios metadata compilations. The apple actuals are checked by
compileAppleMainKotlinMetadata, which runs the frontend against the Apple
klibs without needing a macOS host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 16:17:00 +00:00
davotoula f6a49761f5 Update import_eq_suggestions to true to avoid tedious updating in UI of identical strings.
update cs,pt,de,sv
2026-09-12 17:57:45 +02:00
Claude a4b83b86e7 fix: clear compiler warnings in quartz, commons, marmotBench and quic-interop
Fixes the Kotlin warnings the compiler reports for these modules across the
jvm, android, linuxX64 and metadata compilations:

- PartialTokensTest / marmotBench: drop redundant casts. kotlin.test's
  assertTrue carries a `returns() implies` contract, so the `is` check
  already smart-casts; the benchmark values were never nullable-typed.
- MarmotPublish*Test, LastResortKeyPackageReuseTest: name overridden
  parameters as the supertype does (`retainedSecrets`, `snapshot`), so
  named-argument calls through the interface stay correct.
- AuthOutcomeTest: PersistentMap.put is deprecated in favour of putting(),
  which is what the rest of the codebase already uses.
- IndexableContentGoldenTest: drop an unnecessary !! on a non-null String.
- Nip46Test: the generic encode/decode round trip cannot be checked at
  runtime, so suppress UNCHECKED_CAST with a note on why it is safe.
- InternTradeoffBenchmark: hoist the liveness anchor from a local to a
  field. As a local its assignments were visible to data flow, which folded
  the trailing `check(sink != null)` into a constant.
- Http3GetClient: an empty `else -> {}` branch instead of a bare `Unit`
  expression statement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
2026-09-12 15:31:21 +00:00
Vitor PamplonaandGitHub 08a3bab605 Merge pull request #4101 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 11:15:46 -04:00
Vitor PamplonaandGitHub 4a5278ee55 Merge pull request #4105 from vitorpamplona/chore/bump-amethyst-cask-v1.15.2
chore: sync amethyst-nostr cask to v1.15.2
2026-09-12 11:15:32 -04:00
Vitor PamplonaandGitHub 7aa345ba28 Merge pull request #4104 from vitorpamplona/chore/bump-winget-manifest-v1.15.2
chore: sync winget manifests to v1.15.2
2026-09-12 11:15:22 -04:00
Vitor PamplonaandGitHub 284671aa0f Merge pull request #4103 from vitorpamplona/chore/bump-amy-formula-v1.15.2
chore: sync amy Homebrew formula to v1.15.2
2026-09-12 11:15:15 -04:00
Vitor PamplonaandGitHub a894315335 Merge pull request #4102 from vitorpamplona/chore/bump-geode-formula-v1.15.2
chore: sync geode Homebrew formula to v1.15.2
2026-09-12 11:15:08 -04:00
vitorpamplonaandgithub-actions[bot] 7995f042ab chore: sync amethyst-nostr cask to v1.15.2 2026-09-12 15:14:59 +00:00
vitorpamplonaandgithub-actions[bot] d22e2c1fc7 chore: sync winget manifests to v1.15.2 2026-09-12 15:14:54 +00:00
vitorpamplonaandgithub-actions[bot] 3b69fae6f4 chore: sync geode Homebrew formula to v1.15.2 2026-09-12 15:14:39 +00:00
vitorpamplonaandgithub-actions[bot] cb53a863ce chore: sync amy Homebrew formula to v1.15.2 2026-09-12 15:14:38 +00:00
vitorpamplonaandgithub-actions[bot] 0118c74163 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 15:12:54 +00:00
Vitor PamplonaandGitHub a5da7e49ba Merge pull request #4100 from vitorpamplona/claude/intelligent-edison-gcwtbr
Fix single-flight guarantee for fast signers in BlossomReadAuthTokenProvider
2026-09-12 11:09:57 -04:00
Claude 6dc631e85d fix(blossom): close the read-auth single-flight gap a fast signer slips through
BlossomReadAuthTokenProvider.header() reads the token cache, then signOnce()
reads the in-flight map — two separate reads. A leader caches its token before
retiring its in-flight entry, so a caller sitting between those two reads sees
an empty cache (its read came first) and an empty in-flight map (the leader
already finished), and signs a second token for the same host.

A 300ms test signer never opens that window, which is why the provider's own
concurrency test missed it. A local in-process key signs in microseconds, so
BlossomReadAuthFetcherTest.aBurstOf401sSharesOneSignature — 16 fetchers that
all 401 and all retry — hit it and intermittently saw two distinct tokens.

signOnce() now takes a second look at the cache once it finds no in-flight
entry: an absent entry proves the leader's cache write is already visible, so
the straggler reuses that token instead of starting another signature.

Covered by a new aFastSignerStillSharesOneSignature, which runs the 16-caller
burst against an instant signer over many rounds — the existing test's slow
signer cannot reach the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011APd48WJ5pZVK4Ltpj3YpL
2026-09-12 14:50:15 +00:00
Vitor PamplonaandGitHub 0629b23b1a Merge pull request #4099 from vitorpamplona/chore/release-1.15.2
chore(release): bump to 1.15.2
2026-09-12 10:34:37 -04:00
Vitor PamplonaandGitHub c0a38bc0b7 Merge pull request #4098 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:31:54 -04:00
vitorpamplonaandgithub-actions[bot] aa2e598e28 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 14:29:36 +00:00
Vitor PamplonaandClaude Opus 5 cb49caae69 chore(release): bump to 1.15.2
app 1.15.1 -> 1.15.2, appCode 459 -> 460. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven version
and geode's RelayInfo.VERSION.

Three substantive PRs since v1.15.1, plus Crowdin translations and the packaging
syncs the bump workflows opened after the last tag:

- #4092 media previews: an extension match now requires a real dot, so a player
  page whose path merely ends in the letters `_mp3` stops going to the video
  player, and `og:audio`/`og:video` are read and played with the page's
  `og:image` as poster. A declaration whose type is `text/html` -- YouTube's --
  is refused.
- #4095 nested NIP-22 replies: engagement subscriptions asked only for the
  lowercase `e`/`a` tags, so a comment two or more levels deep was invisible
  until ThreadScreen opened its own subscription. Each relay gets a second,
  root-scoped filter on `E`/`A`. Kind 1619 moves there too -- NIP-34 gives PR
  updates only an uppercase `E`, so it had been in a filter it could never match.
- #4096 Health Connect: a rationale screen Play requires, reachable from the
  composer, from Health Connect's permission screen and standalone without an
  account; reads moved off the UI thread; source names memoized; and the workout
  form is replaced rather than merged when a second suggestion is picked.

Verified on a Pixel 9 emulator before cutting, since two of the three are only
observable on device: the og:audio track plays in a thread with real transport
controls (00:30 / 05:00) where it used to buffer forever, and the Health Connect
rationale opens from all three routes -- including the one that matters for
review, where Health Connect's own permission screen launches our
ViewPermissionUsageActivity through the START_VIEW_PERMISSION_USAGE-guarded
filter.

RELEASE_NOTES_ID deliberately stays on the v1.15.0 note: RELEASE_OPS has it
repointed on x.y.0 only.

Left alone deliberately: everything under */packaging/ and translators.json's
tag, which the bump workflows and the Crowdin job write after the tag exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 10:28:52 -04:00
Vitor PamplonaandGitHub c03e7cfa5e Merge pull request #4097 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:26:57 -04:00
Vitor PamplonaandGitHub 1a812c3df5 Merge pull request #4095 from vitorpamplona/claude/note-replies-loading-mwidat
Split NIP-22 root-scope replies into separate engagement filters
2026-09-12 10:26:44 -04:00
Vitor PamplonaandGitHub 8c87745b21 Merge pull request #4096 from vitorpamplona/claude/focused-gates-w9dtcw
Health Connect: add rationale screen and improve source name caching
2026-09-12 10:24:20 -04:00
vitorpamplonaandgithub-actions[bot] 7d01d3b155 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 14:15:50 +00:00
Vitor PamplonaandGitHub 17f0ee7fbe Merge pull request #4092 from vitorpamplona/fix/og-media-playback
fix(media): require a dot before a file extension, and play a player page's og:audio/og:video
2026-09-12 10:13:01 -04:00
Claude d504f41870 fix: replace the whole form when a second workout suggestion is picked
Three defects found auditing the Health Connect workout path.

applyPrefill only assigned a field when the incoming route carried a value,
so picking a second suggestion merged into the first instead of replacing it.
Tapping a run (5 km, 380 kcal) and then a gym session left the run's distance
and calories in the form — the user publishes numbers from a workout that
never happened. Every metric is now assigned on both branches. Notes are
deliberately left alone: they are typed by the user, never carried by a route.

The `source` tag published the writing app's display label ("Samsung Health"),
or its raw package name when that app is not installed. SourceTag defines a
vocabulary — gps / manual / health_connect — and the feed badge uppercases
whatever is in the tag, so an imported workout rendered as "SAMSUNG HEALTH"
or "COM.HUAWEI.HEALTH" beside other clients' "GPS". It now publishes
SourceTag.HEALTH_CONNECT, which existed for this and had no callers.
DetectedWorkout.source keeps the friendly name for the UI.

readNewWorkouts ran entirely on Dispatchers.Main: the callers launch into a
composable's rememberCoroutineScope, and nothing in the feature switched
dispatcher. Health Connect's own calls suspend, but resolveSourceName's
PackageManager lookup is a blocking binder call made once per session, so a
week of sessions blocked the UI thread once each. The read now runs on
Dispatchers.IO and the label lookups are memoized per writer package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
2026-09-12 13:52:19 +00:00
David KasparandGitHub 212008b037 Merge pull request #4094 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 11:09:46 +02:00
davotoulaandgithub-actions[bot] 17189aba56 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-12 08:56:54 +00:00
David KasparandGitHub 3ae53d5d19 Merge pull request #4091 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:54:15 +02:00
Claude 92f3fd49b0 fix: kind 1619 was in the lowercase-e engagement filter, where it matched nothing
NIP-34's PR Update example carries only `["E", <pull-request-event-id>]` —
there is no lowercase `e` tag on a 1619 at all, and
GitPullRequestUpdateEvent.build() writes only RootEventTag to match. Listing
1619 in RepliesAndReactionsKinds2 (the `#e` filter's kind list) therefore
never pulled a single PR revision, and the comment claiming it was "rooted at
the target patch/PR/issue via a `root`-marked `e` tag" was wrong for that kind
(it is correct for the 1630-1633 statuses beside it).

The `#E` filter added in the previous commit is what actually makes a PR's
revision chain reachable from an on-screen PR row, so 1619 moves there and
comes out of the `e` list.

Nip34NotificationCoverageTest asserted the false half, which is how this
survived: it now checks each kind against the filter that can actually match
it, and pins 1619 out of the `e` list so it cannot drift back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARAmQQWbT2gGVo9tysdAgM
2026-09-12 01:44:32 +00:00
Claude 528b7a374e fix(media): six defects a code-review pass found in the og:media surface
Per CONTRIBUTING-WITH-AI.md, the branch got a review pass before the PR. All
six findings were real; none of them fire in the nostr.build case the feature
was built against, which is exactly why the tests missed them.

- Only a root-relative `og:audio`/`og:video` was resolved. A document-relative
  `media/x.mp3` -- and any value at all when the page URL fails to parse --
  reached the player verbatim as a relative string, which can only hang. Now
  every reference resolves against the page, and an unresolvable one is refused
  rather than passed on.

- Nothing checked the scheme. `og:audio = file:///...` with `audio/mpeg` passed
  the gate, letting a remote page aim the local player at a local URI. Playable
  media must now be http(s).

- The branch keyed on "is this playable", not "is this a player page", so an
  article that merely embeds a clip lost its entire card -- headline, summary,
  host, tap-through -- and got a bare player instead. `og:type` is now read and
  required to be `music.*` or `video.*`; an article, or a page that declares no
  type, keeps its card. The fixtures already carried the tag; nothing read it.

- Relaxing fetchComplete() broke the unstated "Loaded implies a non-empty
  image" invariant, so a cover-art-less track page painted a blank 180dp box in
  UrlPreviewCard (and through it UrlScreen and DisplayExternalId).
  WebBookmarksScreen guarded with `!= null` on a non-null String, which is
  vacuous. Both now test for blank.

- The new `audio/*` branch in UrlPreview produced Loaded states that the
  composer's two preview handlers did not cover, so a direct audio URL was
  handed to Coil to decode as an image and showed an empty square. Audio joins
  video there, as it already had in LoadUrlPreview.

- The video/audio branch never forwarded the fetched Content-Type to the
  player, contradicting its own new comment. An HLS playlist served as
  `audio/x-mpegurl` from an extension-less URL was then unrecognisable to
  MediaItemCache and played as progressive, i.e. not at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8uqrrzDpqUYxV9R7CJgxW
2026-09-12 01:41:19 +00:00
Claude 84c6dba81b feat: justify and document the Health Connect permissions
Google rejected the Health Connect declaration for "Insufficient Information
to Determine App Functionality": neither the store listing, the privacy
policy, nor the in-app experience explained what Amethyst does with the seven
read permissions it asks for.

All seven are used by HealthConnectManager, so none can be dropped. What was
missing was the explanation, on every surface a reviewer looks at:

- The rationale intents the manifest declares
  (ACTION_SHOW_PERMISSIONS_RATIONALE, ACTION_VIEW_PERMISSION_USAGE +
  CATEGORY_HEALTH_PERMISSIONS) pointed at MainActivity, which handles neither
  — tapping "privacy policy" in Health Connect just opened the feed. They now
  land on HealthConnectRationaleActivity, a static, account-free screen that
  lists each data type, what it fills in, and what the app never does. It is
  also reachable from a "What Amethyst reads" link on the Connect card, so
  the rationale is available before the permission request, not only after.
- PRIVACY.md gains a "Health and fitness data (Health Connect)" section: a
  per-type purpose table plus the limits (read-only, foreground-only, 7-day
  window, no route/background/history permissions, no secondary use).
- The Play listing description now covers the app's features and the
  Workouts flow, so the listing reflects what the permissions are for.
- docs/health-connect-play-declaration.md holds the paste-ready Play Console
  text: app functionality, a reviewer walkthrough, and a per-permission
  justification — including that CyclingPedalingCadence and StepsCadence come
  bundled with READ_EXERCISE and READ_STEPS and are never read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
2026-09-12 01:11:46 +00:00
Claude 2616bbfea8 fix: load nested NIP-22 replies in the feed, not just in ThreadScreen
The per-note engagement subscription only asked relays for the lowercase
`e`/`a` tags. NIP-22 puts the conversation root in the **uppercase** `E`/`A`
tags and only the direct parent in lowercase, so a kind-1111 comment two or
more levels deep never carries `e`=<rootId> (nor `a`=<address> when the root
is an article). Those comments therefore never matched the feed's REQs and
only showed up once ThreadScreen opened its own `E`/`A` subscription — which
is exactly the "half the replies appear later" behaviour on note cards.

Kind-1 threads were unaffected: NIP-10 repeats the root `e` tag on every
descendant, so the existing filter already caught them.

LocalCache was never the problem: CommentEvent.tagsWithoutCitations()
already returns root + reply ids, so a nested comment is wired into the root
note's replies as soon as it arrives — it simply never arrived.

Adds an `E` filter (kinds 1111 + 1619, which also anchors with `E`) to
filterRepliesAndReactionsToNotes and an `A` filter (kind 1111) to
filterRepliesAndReactionsToAddresses. They are separate filters because tag
names inside one filter are ANDed — folding `E` into the `e` filter would
only match comments carrying both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARAmQQWbT2gGVo9tysdAgM
2026-09-12 01:01:59 +00:00
Claude 7f19edbc5c feat(media): read og:video too, and refuse a declaration that is really an embed
nostr.build mirrors its audio player page for video, confirmed against the live
host: `e.nostr.build/v_<id>_mp4` answers `text/html` and declares

  og:video       https://v.nostr.build/<id>.mp4
  og:video:type  video/mp4

— the same shape as the `og:audio` page, and the same `_mp4`-without-a-dot trap
the previous commit stopped misrouting into ExoPlayer. So og:video gets the same
treatment: parsed, type-checked, and played with the page's og:image as poster.

Reading og:video is not the same as trusting it, and video is where that
distinction earns its keep. YouTube declares

  og:video:url   https://www.youtube.com/embed/dQw4w9WgXcQ
  og:video:type  text/html

which is markup, not a video — playing it would reproduce the exact bug this
series exists to fix. UrlInfoItem therefore accepts a declaration only when its
type holds up (classifyMedia == VIDEO, so `video/*`, `audio/*` and the HLS
playlist MIMEs all pass, `text/html` does not) or, absent a type, when the URL
carries a real media extension. YouTube falls through to its link card, as it
should. Both cases are pinned as tests from the live tags.

`playableAudioUrl` collapses into `playableMediaUrl` + `playableMediaType`, so
the renderer has one branch and the preference rule — video wins a page that
declares both, since the audio path deliberately strips the picture — lives in
one testable place. A refused og:video still falls back to a playable og:audio.

Also reads the `:url`/`:secure_url` aliases, since hosts disagree on which to
emit (nostr.build the bare key, YouTube only `:url`), and drops the parser's
early exit outright: it stopped at title+description+image, a set every page
completes *before* it reaches the media tags, so it could only ever skip them.
The scan is bounded regardless — MetaTagsParser stops at `</head>`.

Verified: the nostr.build and YouTube tags above are from curl against the live
pages. commons jvmTest (2163) and amethyst fdroid unit tests (1471) pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8uqrrzDpqUYxV9R7CJgxW
2026-09-12 00:39:15 +00:00
Claude 2e895c9311 fix(media): require a dot before a file extension, and play a page's og:audio
A note linking nostr.build's audio player page rendered a dead player:

  https://e.nostr.build/a_ETvKzX2OdOGmFEp1avRlm5_mp3?t=Aria&by=The+Fishcake

That URL is not an mp3. It answers `Content-Type: text/html` — it is the
human-facing player page, and the track it is about lives at
`a.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp3` (`audio/mpeg`), named in the page's
`og:audio`. We fed the HTML to ExoPlayer anyway, which can only buffer forever
and then show the browser-fallback overlay.

Two independent defects, fixed together:

1. Extension matching ignored the dot. `isAudioUrl`/`isVideoUrl`/`isImageUrl`/
   `isPdfUrl`/`classifyMedia`/`parseImageOrVideo` trimmed the query and then
   asked `endsWith("mp3")`, so a path ending in the *letters* of an extension
   matched — `_mp3` here, and any prose slug such as `/my-thoughts-on-mp3`.
   They now share one allocation-free matcher, `RichTextParser.hasExtensionIn`,
   which requires a real dot-introduced extension in the last path segment and
   compares case-insensitively (so `.Mp3`, which the doubled lower/UPPER lists
   never covered, resolves too). SupportedContent carried a second copy of the
   same dot-less scan — it admitted the player page into the video feed — and
   now delegates to the shared matcher instead.

   On its own this change turns the broken player into the correct OpenGraph
   link card, because an unclassifiable URL falls through to LoadUrlPreview.

2. The preview pipeline ignored og:audio. OpenGraphParser now reads `og:audio`,
   `og:audio:secure_url` and `og:audio:type` alongside title/description/image,
   and UrlInfoItem exposes `playableAudioUrl` — the resolved URL, but only when
   the page vouched for the type (an `audio/` MIME, or a genuine audio
   extension when the MIME is absent), so a junk `og:audio` still falls through
   to the card. LoadUrlPreview plays that file with the page's `og:image` as
   cover art. Note the parser's early exit had to wait for the audio pair:
   pages emit `og:audio` after `og:image`, so stopping at title+description+
   image skipped the one field that makes the page playable.

   So the note now plays, rather than merely stopping at a broken player.

Also: a URL that serves `audio/*` without an audio extension no longer throws
"unknown encoding" out of UrlPreview and lose the note's link, and fetchComplete()
counts playable audio as evidence so a track page with no cover art is not
discarded as Empty.

Verified: the Content-Type and og:audio claims above are from curl against the
live URLs. commons jvmTest (2154) and amethyst fdroid unit tests (1471) pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8uqrrzDpqUYxV9R7CJgxW
2026-09-12 00:14:28 +00:00
vitorpamplonaandgithub-actions[bot] f0aa12982a chore: sync Crowdin translations and seed translator npub placeholders 2026-09-11 23:51:32 +00:00
Vitor PamplonaandGitHub 3c4ab62729 Merge pull request #4089 from vitorpamplona/chore/bump-winget-manifest-v1.15.1
chore: sync winget manifests to v1.15.1
2026-09-11 19:48:48 -04:00
Vitor PamplonaandGitHub 892aca2b12 Merge pull request #4090 from vitorpamplona/chore/bump-amethyst-cask-v1.15.1
chore: sync amethyst-nostr cask to v1.15.1
2026-09-11 19:48:39 -04:00
Vitor PamplonaandGitHub 1924da60f9 Merge pull request #4088 from vitorpamplona/chore/bump-amy-formula-v1.15.1
chore: sync amy Homebrew formula to v1.15.1
2026-09-11 19:48:27 -04:00
Vitor PamplonaandGitHub abc1bc8a82 Merge pull request #4087 from vitorpamplona/chore/bump-geode-formula-v1.15.1
chore: sync geode Homebrew formula to v1.15.1
2026-09-11 19:48:20 -04:00
vitorpamplonaandgithub-actions[bot] f337e2da50 chore: sync amethyst-nostr cask to v1.15.1 2026-09-11 23:44:37 +00:00
vitorpamplonaandgithub-actions[bot] 303b84ec30 chore: sync winget manifests to v1.15.1 2026-09-11 23:44:29 +00:00
vitorpamplonaandgithub-actions[bot] 10cdbffcfd chore: sync amy Homebrew formula to v1.15.1 2026-09-11 23:44:18 +00:00
vitorpamplonaandgithub-actions[bot] ec71cd7084 chore: sync geode Homebrew formula to v1.15.1 2026-09-11 23:44:13 +00:00
Vitor PamplonaandGitHub 1008b130ee Merge pull request #4086 from vitorpamplona/chore/release-1.15.1
chore(release): bump to 1.15.1, unblocking the AAB on AGP 9.4.0
2026-09-11 18:52:14 -04:00
Vitor PamplonaandClaude Opus 5 a2f60eeeaf chore(release): bump to 1.15.1, unblocking the AAB on AGP 9.4.0
v1.15.0 was tagged but never shipped. Its `Create Release Assets` run failed in
`deploy-android` at the first packaging task:

    Execution failed for task ':amethyst:buildFdroidReleasePreBundle'
    > Entry name contains invalid characters:
      root/META-INF/zoomable-root:zoomable.kotlin_module

so no AAB, no APK, and none of the 47 assets were produced.

A `.kotlin_module` is named after the Gradle project path that produced it,
colons included. 14 of the 98 modules merged into the app carry one: zoomable,
Negentropy, vico, the seven coil3 artifacts, and four of ours -- Amethyst:quartz,
Amethyst:commons, Amethyst:quic and Amethyst:nestsClient -- so renaming our own
would not have been enough.

Bisected against the two toolchain bumps this cycle, since both landed after the
last good release: AGP 9.3.1 -> 9.4.0 and Kotlin 2.4.10 -> 2.4.20. With Kotlin
held at 2.4.20 and AGP reverted, both flavours' bundle tasks pass, so Kotlin is
not the trigger. The R8 output jar carries the identical 14 colon entries under
BOTH AGP versions -- 9.4.0 added the rejection rather than the names, in
JarFlinger.addJar, reached from PerModuleBundleTask.addHybridFolder.

`packaging.resources.excludes` was tried first and cannot work, at either
`META-INF/*.kotlin_module` or `**/*.kotlin_module`: with minification on, R8
emits the java resources itself and addHybridFolder hands JarFlinger its own
predicate, so those filters are never consulted. Confirmed by deleting the R8
output and re-running rather than reading a stale intermediate --
mergeJavaResource's jar holds zero kotlin_modules while R8's holds all 98.

So the entries are stripped from R8's jar in the moment before the bundle task
opens it, and the jar is put back exactly as R8 left it afterwards. Two details
carry their weight:

  - the strip is doFirst on the CONSUMER rather than doLast on R8, so a
    build-cache hit on R8 cannot skip it;
  - the restore is what keeps R8 up to date. Without it Gradle sees a modified
    output and re-runs R8 on every build -- measured here at ~2 min for an
    otherwise no-op build. With it, a second run reports
    minifyFdroidReleaseWithR8 UP-TO-DATE and finishes in 1s.

Pinning back to 9.3.1 was the alternative and is one line away; the catalog
comment records that, and says to drop the workaround when AGP fixes it.

Verified from a cleaned R8 output on both flavours:
buildFdroidReleasePreBundle and buildPlayReleasePreBundle both BUILD SUCCESSFUL,
each reporting "Stripped 14 colon-named entries from base.jar".

appCode 458 -> 459. RELEASE_NOTES_ID deliberately stays on the v1.15.0 note:
RELEASE_OPS has it repointed on x.y.0 only, and 1.15.1 ships that release's
contents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-11 18:42:22 -04:00
Vitor PamplonaandGitHub 9449bce038 Merge pull request #4085 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-11 17:52:09 -04:00
vitorpamplonaandgithub-actions[bot] 552888c401 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-11 21:50:11 +00:00
Vitor PamplonaandGitHub 8a3e2cd282 Merge pull request #4084 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-11 17:47:26 -04:00
davotoulaandgithub-actions[bot] adda653028 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-11 21:30:26 +00:00
David KasparandGitHub a7600d3445 Merge pull request #4082 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-11 23:27:38 +02:00
Vitor PamplonaandGitHub 747be2807c Merge pull request #4083 from vitorpamplona/l10n/missing-cs-de-sv-pt-2026-09-11
chore(l10n): add missing cs, de, sv, pt-BR translations
2026-09-11 17:26:26 -04:00
David KasparandGitHub 48d8693671 Merge branch 'main' into l10n/missing-cs-de-sv-pt-2026-09-11 2026-09-11 23:16:15 +02:00
vitorpamplonaandgithub-actions[bot] f3e86b9097 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-11 21:12:17 +00:00
Vitor Pamplona a5f0257394 update language on the IDE file 2026-09-11 17:07:44 -04:00
Vitor PamplonaandGitHub f94a74b2b5 Merge pull request #4081 from vitorpamplona/chore/release-1.15.0
chore(release): bump to 1.15.0
2026-09-11 16:59:08 -04:00
Vitor PamplonaandClaude Opus 5 e9e510b831 chore(release): bump to 1.15.0
app 1.14.0 -> 1.15.0, appCode 457 -> 458. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven version
and geode's RelayInfo.VERSION.

RELEASE_NOTES_ID is repointed at the v1.15.0 note
(8fce45589ea44df75e828a04c7d70bb4fabedd6ffc1946a920b2f0c7c990ff9f), which
RELEASE_OPS notes happens on x.y.0 releases and not on patches. It has to ship
in this commit rather than after it: the drawer's "Release Notes" link and the
donation card both open BuildConfig.RELEASE_NOTES_ID, so a tag cut before the
repoint ships users a link to the previous release's note. Verified present on
relay.damus.io and relay.primal.net before committing.

Adds docs/changelog/v1.15.00.md, written from the 556 commits since v1.14.0,
and its index entry. The cycle's headline is the Marmot resync: the MIP
documents were deprecated in July and MDK followed, leaving our implementation
invalid under either profile the current spec defines, so it moves onto the
adopted current profile and is now interoperable with White Noise. Marmot group
chat itself is not new -- it shipped in v1.09.0 -- and the notes say so.

Also syncs the docs that state a version rather than illustrate one, since
quartz and geode both read libs.versions.app:

- README.md, quartz-integration SKILL.md and its gradle-setup.md reference
  -> quartz 1.15.0
- geode/README.md install commands -> geode 1.15.0

The Homebrew/Winget status blocks in BUILDING.md and RELEASE_OPS.md were
re-verified rather than re-stamped, and the claim had gone stale in our favour:
both Homebrew packages are live upstream now. formulae.brew.sh answers 200 for
the amethyst-nostr cask (at 1.14.0) and for the amy formula, while geode-relay
404s and microsoft/winget-pkgs still has no VitorPamplona/Amethyst -- PR #422752
is open pending CLA. Both blocks now say that, RELEASE_OPS gains a geode-relay
row, and the section heading no longer claims Homebrew is not shipping.

Left alone deliberately: everything under */packaging/ and translators.json's
tag, which the bump workflows and the Crowdin job write after the tag exists
(bumping by hand would commit wrong hashes and a dead URL); and
cli/tests/marmot/state/mdk/Cargo.lock, where 1.14.0 is an unrelated crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-11 16:56:30 -04:00
davotoula 792eae525b chore(l10n): add missing cs, de, sv, pt-BR translations
Fill the strings and plurals missing from the cs, de-rDE, sv-rSE and
pt-rBR locale files in both resource trees:

- amethyst: git status notifications, relay login dialogs, Marmot
  disband/encrypted attachments, pay-to hand-off, napplet encryption,
  library/ratings labels, library_directory_* plurals
- commons: Marmot avatar link, disband, disappearing messages and
  system event lines, search empty states, publication/profile
  labels, publication_* and birdex_species_more plurals

Source-identical entries (format strings, placeholders, brand names,
loanwords) are left for Crowdin to decide.
2026-09-11 22:38:16 +02:00
Vitor PamplonaandGitHub bf29cd49cf Merge pull request #4080 from vitorpamplona/fix/nav-predictive-back-transitions
fix(nav): give the back gesture the slides it lost to navigation-compose 2.10
2026-09-11 15:56:57 -04:00
Vitor PamplonaandClaude Opus 5 8bccd23bfe fix(nav): give the back gesture the slides it lost to navigation-compose 2.10
A swipe-from-edge back stopped sliding and started shrinking the outgoing
screen toward its centre. A back *button* press still slid, which is the tell:
only the gesture takes the changed branch.

navigation-compose 2.10.0 split the gesture off from the button. NavHost was:

    if (composeNavigator.isPop.value || inPredictiveBack) { … popExitTransition }

and is now:

    if (inPredictiveBack) { … predictivePopExitTransition.invoke(this, swipeEdge) }
    else if (composeNavigator.isPop.value) { … popExitTransition }

with defaults of `fadeIn` opposite `scaleOut(targetScale = 0.7f)`
(DefaultNavTransitions.android.kt). We pass no predictive pair, so every
gesture back ran that 0.7 scale instead of the per-route slides. Arrived with
the 2.9.8 -> 2.10.1 bump in 86d96dda06; not in any release.

The per-destination overrides are `internal` in 2.10.1, so the only public
lever is the NavHost-level pair — and it is handed the entries but not the
builder that declared them. PopFamilies records that as the graph is built, so
predictivePopEnter/predictivePopExit can reproduce the existing rules exactly:
a modal leaves downward, a drill-in leaves toward the end, a tab entry fades.
They call popExitToEnd/popExitToBottom/popEnterFromBehind rather than respell
them, so the large-screen tier still applies and the two paths cannot drift.

navShellFadeIn/Out are now shared with the NavHost's own enter/exit instead of
being spelled twice.

Verified on emulator-5554 (Pixel 9, API 36), gesture held at the same x=540
with `input motionevent` and screencapped mid-drag:

  - pre-fix build, Profile:   screen scaled down, Home visible around all edges
  - fixed, Profile (fromEnd): screen translated right, full size, Home revealed
  - fixed, Edit Profile (fromBottom): translated down, Profile revealed above

The bottom-family case is the one that proves the lookup resolves per family
rather than answering END for everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-11 15:41:52 -04:00
Vitor PamplonaandGitHub c495c89d0f Merge pull request #4079 from vitorpamplona/fix/nwc-test-race
test(nwc): fix the flaky NwcInfoCache coalescing tests
2026-09-11 13:32:50 -04:00
Vitor PamplonaandClaude Opus 5 1adc37ba1b test(nwc): let the joining callers reach the cache before the gate opens
`concurrentGetFreshCallersShareOneFetch` failed about one run in four under
full-suite load with `expected:<1> but was:<2>`, and passed every time when
run alone -- so it read as a cache bug that was not one.

The callers joined on `Dispatchers.IO`, which only *schedules* them. The
test then opened the gate immediately, so the winner could finish first:
its `finally { inFlight.remove(key, ours) }` cleared the slot, a caller
arriving afterwards found nothing to join, and started a second fetch. The
coalescing the test is about had never been exercised on those runs.

`Dispatchers.Unconfined` runs a coroutine's body inline until its first
real suspension, so `async` now returns only once the caller has executed
`inFlight.putIfAbsent` and parked on the winner's deferred. The
interleaving the GatedFetch KDoc promises -- "caller one is provably inside
the fetch before the others arrive" -- is ordered by construction rather
than by timing.

`getFreshJoinsFetchAlreadyStartedByRefreshIfStale` carried the identical
race and is fixed the same way; it had not been observed failing. The three
remaining `Dispatchers.IO` callers in this file are left alone, because
there the caller IS the fetch winner and has nothing to join late.

Verified: the full fdroid suite (1464 tests), where the failure originally
surfaced, is green twice over, and the class passes 6/6 in isolation. The
isolated runs prove little on their own -- the unfixed test passed 5/5 that
way too -- so the load runs are the evidence that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:57:13 -04:00
Vitor PamplonaandGitHub de4f23b20c Merge pull request #4078 from vitorpamplona/claude/quirky-pascal-5kj5sz
Bump Kotlin to 2.4.20, Compose to 1.12.0, and other deps
2026-09-11 12:33:42 -04:00
Vitor PamplonaandGitHub 116d282b62 Merge pull request #4077 from vitorpamplona/claude/modest-keller-tsngxg
Fix relay list updates to preserve public/private visibility
2026-09-11 11:22:36 -04:00
Claude 86d96dda06 chore(deps): update dependencies across the version catalog
Bumps every catalog entry that has a newer release, verified with the
JVM/Android unit test suites and the desktop Compose UI tests.

Highlights:
- Kotlin 2.4.10 -> 2.4.20 (and kotlinTest, ksp 2.3.12)
- Compose Multiplatform 1.11.1 -> 1.12.0, androidx compose BOM 2026.09.00,
  runtime-annotation 1.12.1
- coil 3.5.0 -> 3.6.2, media3 1.11.1, navigation-compose 2.10.1,
  camera 1.6.2, sqlite 2.7.1, firebase BOM 34.19.0, vico 3.3.1
- benchmark 1.5.0-rc01 -> 1.5.0 (final), kotlinx-collections-immutable 0.5.2,
  slf4j 2.0.19, spotless 8.10.2, sonarqube plugin 7.5.0.8588
- CI: actions/cache v4 -> v6 in build.yml, matching create-release.yml

Two entries needed more than a version change:

- material3 1.9.0 -> 1.12.0-alpha03. coil 3.6.2 requires Compose
  Multiplatform 1.12.0, whose foundation changed the CustomStyle.applyStyle
  signature. Every material3 release built against foundation 1.11.x (the
  stable 1.9.0, and the 1.11.0-alpha07 kodein-emoji drags in) then throws
  AbstractMethodError from OutlinedTextFieldDefaults at runtime; two desktop
  UI tests caught it. 1.12.0-alpha03 is the first release built against
  foundation 1.12.x. Android still takes androidx material3 from composeBom,
  so this ref only drives desktop/iOS.

- cachemap stays at 0.2.4. 0.3.0 makes CacheMap.entries/keys/values
  DeprecationLevel.ERROR and throws at runtime; quartz's appleMain
  LargeCache/ConcurrentHashCache read those views directly and through the
  stdlib Map operators. Porting them to the new forEach/forEachKey/
  forEachValue API needs a build on an Apple target, so it is left for a
  follow-up. Rationale recorded in the catalog.

commons/build.gradle.kts: DisableCacheInKotlinVersion.2_4_10 no longer
exists in KGP 2.4.20 (the enum keeps a sliding window on purpose, to force a
re-check each release), so the iOS ui-uikit prebuilt-cache workaround moves
to 2_4_20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXfvJi9QshDv9Umzp3ZYgQ
2026-09-11 15:12:58 +00:00
davotoula d5be7c2862 refactor: extract duplicated string literals flagged by Sonar
StreamCommands read the "stream-id" flag name in four places and
MarmotBenchmarks repeated the "wss://bench.invalid" relay URL three
times. Each is now a private const in its file.
2026-09-11 16:42:26 +02:00
davotoula a4b68af778 fix(marmot): log ignored File.delete results in AndroidPushStateStore
Sonar flagged both delete() calls for discarding the Boolean result.
save() now warns when the temp file left behind by a failed rename
cannot be removed, and clear() only deletes a file that exists and
warns when the delete is refused.
2026-09-11 14:48:05 +02:00
Claude d91fa1aadf fix(nip51): stop relay list updates from wiping another client's public entries
Every NIP-51 relay list update rebuilt the event by stripping all `relay`
tags from the public tag array and writing the whole new relay set into the
NIP-44 encrypted content. A 10012 created elsewhere (Jumble, for instance)
keeps its relays in plain tags, so unfollowing a single relay in Amethyst
moved every survivor into the encrypted content and the list read as empty
in that other client.

splitRelayListUpdate() now decides where each relay goes: relays that were
public stay public, relays that were private stay private, and relays that
weren't on the list follow its convention -- public when the earlier version
was public-only, private otherwise. Applied to all nine lists that shared
the update path (relay feeds, blocked, trusted, broadcast, indexer, proxy,
search, private outbox and relay sets).

Fixes #4075

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKnV2t1sQDTRjYNptjpXAd
2026-09-11 12:23:27 +00:00
Vitor PamplonaandGitHub d669bcc745 Merge pull request #4076 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-11 08:16:20 -04:00
vitorpamplonaandgithub-actions[bot] 13f586133c chore: sync Crowdin translations and seed translator npub placeholders 2026-09-11 12:15:03 +00:00
Vitor PamplonaandGitHub 0a93f1da1f Merge pull request #4073 from vitorpamplona/claude/marmot-protocol-mdk-sync-bubfqq
feat(marmot): Marmot/MLS group messaging, interoperable with White Noise
2026-09-11 08:11:29 -04:00
Claude 0b3671f7c7 fix(relay): stop a publish retry silently dialing nothing
The transport retry assumed the relay it is retrying is still in the connection
pool. `reconnect()` walks the pool's current relays, so when it is not, the
retry is issued, logged, dials nothing, and is reported at the deadline as the
same hang-up we already knew about — the one failure mode a retry must not
have, because it is indistinguishable from having tried.

A relay leaves the pool when nothing wants it any more. `NostrClient` recomputes
that set through `combine(...).sample(300)` and `RelayPool.updatePool` retires
whatever the sampled snapshot omits, socket included. A snapshot taken before
this publish claimed the relay therefore retires a relay with an event in
flight. The outbox still holds the event and would re-send it on the next
connect, so the only thing actually missing is pool membership.

`ensureInPool` restores it before the reconnect. Defaults to a no-op on
`INostrClient` rather than reusing `getOrCreateRelay`, which throws for clients
that expose no pool, and it is a no-op in the ordinary case where the relay
never left.

Not covered by a new test, deliberately rather than by omission: every
deterministic route to "relay absent from the pool" runs through the outbox
exhausting its own retry budget, and at that point the event has been abandoned
and NOT re-sending is correct. The one route that reaches this branch is the
300ms sampling window, which cannot be forced through the public API. So this
is defence in depth on an inferred cause, and the evidence for the inference is
the interop harness's `disconnected before OK` on test 22: the event
`a153a582…` IS stored in the harness relay's database, so the relay took it and
only the OK was lost; and the relay did not hang up (no rate limits configured,
a 20-minute idle timeout, and a 1024-message slow-client queue against a
database holding 107 events total), which leaves a client-side teardown.

Existing publish suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-11 11:45:09 +00:00
Claude 3a1947e733 test(marmot): let the disband test converge instead of snapshotting
Test 29 asserted that wn reaches amy's post-disband epoch within one fixed
window. That is stricter than the protocol promises, and it failed on a
full-suite run for a case `group-lifecycle-v1.md` explicitly allows.

MDK rotates its own leaf shortly after joining, so it can commit between the
epoch-agreement gate and amy's disband — and then the two have forked. What the
spec guarantees from there is not "the disband lands first time" but that the
REQUEST survives, is regenerated against whichever branch was selected, and
lands eventually. The old assertion could only pass in the race-free case, and
a busier machine widens the race.

The loop now re-reads AMY's epoch each round, because regeneration advances it,
and drives amy's own sync, which is what carries a pass to settlement and
re-issues a disband that lost. It also adds a check the old version lacked: once
the two agree, amy must still read the group as disbanded. "Epochs agree but the
group is live" is the outcome actually worth catching, and counting epochs alone
never would have.

Evidence this is a race and not a regression from the audit fixes: the two
commits in the failing window carry different `h` tags, so they are commits in
two different groups rather than a fork, and the test passes in isolation
(`epoch 1 -> 2, wn at 2`). A full run with this change is the confirmation and
is not in yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-11 03:11:13 +00:00
Claude 025116acc8 fix(marmot): five defects from the pre-merge audit
A review of the full branch diff turned up five, all verified against the code
before changing anything.

**A departure gate nothing ever cleared.** `leaveGroup` raises a durable
`LEAVING` gate and the only `clearGate` calls were inside
`resolveDisbandRequest`, so a member who left and was invited back held a gate
against a membership that no longer existed. Harmless until this branch, where
gates began blocking outbound work AND surviving restarts — which turned it
into a group that reads fine and can never be written to again, permanently. An
authenticated re-join now clears it, which is the rule `REMOVED` already
stated.

**An SSRF hole in IPv6 avatar hosts.** `isNonRoutableIpv6` compared TEXT, so
`::1` was caught and `0:0:0:0:0:0:0:1` — the same address, expanded — was not,
and `::ffff:127.0.0.1` shares no prefix with anything it looked for. A group
avatar URL could make every member fetch from their own machine. Addresses are
now parsed to their 16 bytes and judged numerically, with IPv4-mapped and
-compatible forms delegated to the existing IPv4 rules and an unparseable
literal refused rather than waved through.

**A message on a branch the group then adopted was never rendered.** An app
payload that decrypted only on a candidate branch had its id recorded as
processed, so a later redelivery hit the `Duplicate` early-return — even though
that result is itself a witness FOR the branch, which convergence may go on to
select. It is now retryable like `UndecryptableOuterLayer`. Safe to
re-process: witnesses are a set keyed by sender, so a resent payload adds
nothing to a branch's standing.

**One dropped socket wedged a group until app restart.** An unconfirmed publish
pins `PendingPublish`, and the only caller of `retryPendingPublishObligations`
was `restoreAll`. A blocked commit now retries that group's obligations on its
way through, so the next attempt is the recovery.

`MarmotPublishBeforeApplyTest` measured "no replacement commit" by counting
sends, which the retry breaks without violating anything: the re-send carries
the SAME event id, and a fork means a second DIFFERENT commit for the held
epoch. It now counts distinct ids, which is the property it always meant.

**`forget()` left two of the gate's three copies behind.** It dropped the map
but not the snapshot non-suspending readers see, nor the record on disk, so
`restore()` resurrected a gate for a forgotten group. Latent — no production
caller yet.

11,001 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-11 02:53:37 +00:00
Claude 105d5bd217 fix(marmot,relay): end the recovery, gate the composer, extend a retry's deadline
Four follow-ups from the disband work.

**A settled pass no longer reads as `Recovering` forever.** `settle()` cleared
the pass and terminalized a disband but never restored `Stable`, so after any
fork the group reported a recovery that had already finished. The publish gate
is what actually decides whether a commit may be prepared, so nothing locked up
— it was a lie in the reporting, which is worse in its way, since the next
reader to gate on it would have found a group that looked permanently stuck.
`endRecovery` clears only `Recovering`; `Unrecoverable` is not a pass outcome
and `Disbanded` is absorbing.

**The composer is disabled when an outbound gate is up.** Sending into a
disbanding, leaving or removed group throws behind the gate, and the UI found
out by tapping. The gate is mirrored onto `MarmotGroupChatroom` and the chat
view replaces the input with the reason. The gate map is guarded by a mutex and
the refresh path is not suspending, so `MarmotPublishGate` now publishes an
immutable snapshot for non-suspending readers rather than pushing `suspend` up
through every caller for one flag.

**A retry now outlives the deadline it was issued at.** The transport retry
already existed (7e187e39) and was still losing publishes: it shared the
caller's original budget, so the retry was issued, the clock ran out, and the
publish was reported failed having done the work and thrown the answer away —
which is how a healthy loopback relay kept costing the interop harness a
message a run to `disconnected before OK`. Issuing a retry now extends the
deadline by `TRANSPORT_RETRY_GRACE_MS`. Bounded by construction: only a relay
that gave a transport failure earns it, and only as often as the retry budget
allows. The new test fails without the change with exactly the harness's error.

**The three blocked interop directions are closed, with the reason recorded.**
The obvious next idea is to bypass `wn`'s missing verbs through its daemon, and
it does not work: `wnd`'s protocol carries `Ping`, `Status`, `Shutdown`, four
`*Subscribe` variants and `Execute { cli: Box<Cli> }` — the same clap tree `wn`
parses. A verb missing from `Cli` is unreachable through the socket too, so
closing them needs a verb upstream or a driver linked against
`marmot-uniffi`/`marmot-c`. Written down in cli/tests/README.md so nobody
re-investigates.

10,998 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-11 02:19:43 +00:00
Vitor PamplonaandGitHub 31d58b0a8c Merge pull request #4074 from vitorpamplona/claude/inspiring-euler-y6wfxc
Add NIP-44 encrypt/decrypt to NIP-07 window.nostr and NIP-52 day indexing
2026-09-10 21:28:07 -04:00
Claude e577071a75 fix(napplet): grant SIGNER to the browser, and never widen a narrow decrypt grant
Two defects found auditing the NIP-44 change.

The in-app browser mints its own per-origin launch token and never consulted
HostProfile, so it still granted IDENTITY+RELAY. Those are exactly the surfaces
that set __nappletNip07, so the shim advertised window.nostr.nip44 and the
broker then denied every call -- worse than not advertising it, since apps stop
falling back. The website set now lives once, in NappletCapability, and both
mints read it.

Second, the broker recorded a consent grant under the REQUESTED op rather than
the op the grant itself carried. Nip44Decrypt is the first napplet-side request
with a narrower alternative (DecryptFrom(peer)), so a user tapping "always allow
for Alice" would have been stored as a broad "allow decrypt" -- every
conversation, forever, from one tap. Recording now goes through
NostrSignerPermissionLedger.record, which uses the grant's own op, and a
standing narrow grant is honoured on later requests instead of re-prompting.
This mirrors the NIP-46 authorizer, which already got both right.

With the recording fixed, the consent dialog can safely name the counterparty:
Nip44Decrypt now supplies it, so the prompt reads "read your private messages
with Alice" and offers the scoped grant beside the broad one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg
2026-09-11 01:26:04 +00:00
Claude 3c65e601df fix(marmot): wire the disband fix through to Android
The convergence-based disband landed in `commons` and `quartz` but three things
it depends on only exist per front end, and the app had none of them.

**Nothing would have carried the pass to settlement.** Terminalization now
happens when the convergence pass SETTLES, and the settler is started by
inbound traffic that detected a fork — but a disband opens its pass from a
local outbound Commit with no fork, so no carrier ever started. On Android the
group would have sat in `Recovering` behind its own `Disbanding` gate forever:
nothing sendable, never ending. The CLI never showed it because the harness
drives settlement explicitly. `disbandGroup` and the regeneration path now
start the carrier themselves.

**The gate was not durable anywhere real.** Gate storage is defaulted on
`MarmotPublishObligationStore` so existing stores keep compiling, and neither
`AndroidPublishObligationStore` nor `FilePublishObligationStore` overrode it —
so the previous commit's durability claim held only for the in-memory store the
test used. Both now persist gates: one file per group beside the obligations.
Android writes them unencrypted, unlike an obligation, because the value is one
enum name and the filename is a group id the device already stores in the clear
— no key material, no message content. `FileStoresGateTest` pins the round trip
on the real file store, including that a gate write is not mistaken for an
obligation on reload.

**The UI announced an ending that may not have happened.** The toast said
"Group disbanded" as soon as the call returned, which used to be true because
an unacknowledged publish threw. It no longer throws — the request stays
durable and pending — so `disbandMarmotGroup` now returns whether the group is
terminal, and the screen says "Ending the group" when it is not. Leaving the
screen is right either way: the group takes no further outbound work.

Verified: quartz 4836, commons 1886, cli 53 green, and the full MDK interop
harness is 29/29 at the new 0.9.21 pin with all of this built in — including
test 05, whose earlier failure was the loopback relay dropping a websocket
before OK rather than anything in 0.9.21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-11 01:17:23 +00:00
Claude 809983219d fix(marmot): terminalize a disband on selection, and bump the MDK pin to 0.9.21
**The disband was terminalizing on application.** `group-lifecycle-v1.md`
("Convergence and realization") says a valid disband Commit is never
terminalized through ordinary linear advancement: admitting one moves the
lifecycle to `Recovering` EVEN WITH NO DIVERGENT EDGE, and only a SELECTED
disband Commit moves it to `Disbanded`. We went straight to `Disbanded` in
`recordApplied`, so a disband that lost a branch race had already destroyed the
group locally — and `Disbanded` is absorbing, so that client stops processing
group traffic and can never learn the branch it lost was the one everyone else
kept.

Most of the machinery for this was already written and never wired:
`ConvergencePass.markDisbandCandidateAdmitted`, `isRecovery`, and
`LocalOutboundGate.DISBANDING` all existed with no callers outside one unit
test.

- `recordApplied` now admits the disband for selection instead of
  terminalizing; `settle` stays the only path to `Disbanded`.
- The pass opens WITHOUT `markForkDetected` — the spec is explicit that the
  forced transition does not assert a fork.
- `settleUncontested` resolves a pass with no divergent material. Every pass
  used to be opened BY a divergent commit, so `freezeInputs` could assume one
  existed; with none it returns null and `settle` bailed WITHOUT clearing the
  pass, leaving it open forever and spinning every caller polling for
  settlement. A no-fork disband is exactly that shape.

**The request is now durable.** The `Disbanding` gate goes up first and is
persisted (gate storage added to the obligation store as default methods, so
existing stores keep compiling), because it has to outlive a publish no relay
acknowledged, a crash, a restart and a losing branch. An unacknowledged publish
no longer throws the intent away, and `requireOutboundAllowed` honours the gate,
so a group with a pending disband refuses new messages instead of carrying on as
if nothing had been asked.

**Regeneration is bounded to one attempt per epoch.** `resolveDisbandRequest`
runs at settlement and regenerates against the selected state when an active
branch won — but regenerating opens a fresh pass, and settling that pass calls
back in, so without the bound the two spin against each other forever. (MDK
bounds the same loop with `DisbandRequest.last_prepared_epoch`.) Waiting for a
new epoch is also right on the merits: a commit authenticating against the same
parent that just lost would lose again.

**MDK pin → 0.9.21 (`fdd398a8`).** The two shipping apps have diverged —
android is on 0.9.21, ios still on 0.9.20 — so the comment claiming they agree
was false. The rule is now written down: take the newer, because that is where
new validation lands and a client satisfying it satisfies the older one.

Also renames three shared-source test functions that contained a comma.
Kotlin/Native rejects those outright, which is why `test-quartz-linux-native`
and `test-quartz-ios` failed while every JVM run passed — the pre-push hook runs
JVM tasks only, with the native ones disabled on this host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-11 01:02:37 +00:00
Claude 94a51efa69 fix(strings): drop the Android-only apostrophe escape from a Compose string
`lint` fails on the branch: `compose_escaping_check` flags one `\'` in the
Compose resource catalog. Android's parser resolves that escape; Compose's does
not, so the string rendered with a literal backslash in it.

Repaired with the repo's own tool, as the check instructs:

    python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \
        commons/src/commonMain/composeResources

`marmot_retention_footer` was the only entry affected. Both lint hooks pass now,
which unblocks the five jobs the workflow gates behind them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 23:41:13 +00:00
Claude c91b2afd09 feat(marmot): interop coverage for deletions, retention and disband inbound
Adds the three harness directions the reference CLI can actually drive, and
the implementation each one needed.

**Deletions wn->amy (test 27).** We sent kind:5 and MDK applied it (test 23),
but nothing on our side applied an inbound one — a message its sender believed
was gone stayed on screen. `MarmotManager.deletedIds` mirrors `editOverlays`
with the same account-identity rule MDK uses for a self-retraction; `amy marmot
message list` reports `deleted` and blanks the body. Android already applied
these through LocalCache's NIP-09 path, which enforces the same author check.

MDK also honours an admin *moderation* delete carrying an authenticated grant
frozen at ingest. We issue no such grant, so a cross-author delete is ignored
rather than guessed at.

**Retention wn->amy (test 28).** Test 26 proved MDK accepts a group requiring
0x8005 with our bytes; the read side was untested, and that is where the
epoch-pinning rule lives — a message keeps the retention of the epoch that
DELIVERED it. Adds `MarmotManager.setMessageRetention` (the component's
explicitly-allowed mid-life update, admin-only), `amy marmot group
set-retention`, and a pinned `expires_at` on every message row. The test has
wn send under one policy, amy re-time the group, wn send again, and asserts the
two messages carry different pinned expiries.

**Disband amy->wn (test 29).** Our disband staged a bare lifecycle update.
`group-lifecycle-v1.md` fixes the whole Commit — the lifecycle update, an
admin-policy replacement naming only the committer, and a Remove for every
other leaf, all inline — and MDK rejects anything else as an unsupported
lifecycle transition, which would have left the group live for every member
while reading as ended here. `MlsGroupManager.stageDisband` now builds that
shape, `stageEnableDisbanding` covers a group predating the component, and
`amy marmot group disband --yes` drives it.

Also labels `MarmotIngestResult.Ignored` with the branch that produced it.
Four very different situations collapsed into one unlabelled result, which
made a client stuck in convergence indistinguishable from a quiet one — that
ambiguity cost most of the time spent diagnosing test 29.

Three directions stay uncovered because the reference CLI cannot originate
them: edits wn->amy (no `wn messages edit`, and kind 1009 is reserved against
`send-event`), setting retention from wn, and disband wn->amy. MDK's runtime
and uniffi surfaces expose all three; only its CLI does not. Documented in
cli/tests/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 23:37:39 +00:00
Claude 5a5c6cffa8 feat(nip52): emit the uppercase D day-index tags on kind 31923
NIP-52 requires a `D` tag on time-based calendar events -- the day-granularity
unix timestamp floor(unix_seconds / 86400), one per UTC day the start..end
range spans. Amethyst emitted none, so events it authored were invisible to
clients that discover calendar events by date (#D queries) rather than by
scanning every 31923 in existence.

CalendarTimeSlotEvent.build now emits the full set, which covers editing too:
the create/edit screen rebuilds the whole event through build(), and the
builder extension removes before it adds, so shortening an event drops the
days it no longer covers instead of leaving them claiming it forever.

end is exclusive per the spec, so an event finishing exactly at midnight does
not tag the following day. The range is capped so a mistyped end date cannot
produce an event too large to publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg
2026-09-10 23:30:52 +00:00
Claude 44b7ae165f feat(napplet): expose NIP-44 encrypt/decrypt on the injected window.nostr
The injected NIP-07 provider offered only getPublicKey/getRelays/signEvent, so
a page hosted in the nSite viewer or the in-app browser could sign but never
seal: a NIP-59 kind:13 seal is NIP-44 ciphertext authored by the real key, and
signEvent alone cannot produce one. That put NIP-17 DMs and every gift-wrapped
app protocol out of reach of any web app logging in with Amethyst.

Adds nostr.nip44Encrypt/nip44Decrypt to the broker, behind a new SIGNER
capability. SIGNER is website-only by construction: no NIP-5D domain maps to
it, so resolveRequiredCapabilities can never hand it to a locked napplet
however its manifest is written. Individual calls still pass the per-operation
signer ledger, reusing the vocabulary NIP-46 already uses for the same ops --
encrypt auto-allows under REASONABLE, decrypt always asks.

nip04 stays deliberately absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg
2026-09-10 23:30:44 +00:00
Vitor PamplonaandClaude Opus 5 76bbaf569d fix(marmot): write attachments in the adopted encrypted-media-v2 shape
Every image Amethyst sent into a Marmot group was invisible to every other
implementation. The upload was encrypted with the MIP-era scheme and
described with a MIP-era `imeta` -- `url`, `x`, `n`, `v mip04-v2` -- and
MDK 0.9.21, which White Noise embeds, knows only `encrypted-media-v1|v2`:
`locator`, `ciphertext_sha256`, `plaintext_sha256`, `nonce`. Its typed
parser rejects anything else and the caller drops the tag without a word,
so the message arrived carrying no attachment at all.

The encoder for the adopted shape was already here and already correct.
What sent the old one was the gate: v2 was used only when the group
carried the `encrypted-media-v2` component, and groups are created
WITHOUT it on purpose, so epoch 0 matches the reference implementation
byte for byte. The default path was therefore always the dead dialect.

Receivers do not require that component -- MDK pins the opposite, that an
out-of-policy locator is "kept, not dropped on ingest", because media is
authenticated by its hashes and AEAD rather than by where it sits. Only a
SENDER's own outbound validation is constrained by policy. So the cipher
is now chosen unconditionally, and a media type too malformed to
canonicalize becomes `application/octet-stream` rather than falling back:
an attachment labelled imprecisely still renders, one in a dialect nobody
reads does not.

MIP-04 stays on the READ side for messages older builds already sent.

The test pins the tag we write against MDK's own fixture
(`crates/marmot-app/src/media/tests.rs`, `valid_v2_imeta_tag`) instead of
round-tripping through our own parser, which is what let this drift
through a suite that already claimed media interop: both ends drifted
together and agreed with each other.

Verified on device, Amethyst -> White Noise Android: the chat list shows
`Photo` (their `classify_chat_list_attachments` only says that once the
imeta parses) and the conversation renders the image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:01:19 -04:00
Vitor PamplonaandClaude Opus 5 425e1a6470 test(marmot): let the headless harness run off Linux
Three assumptions the harness makes are Linux-only, and each stops it
before a single test runs:

- it binds the relay to the URL host, and macOS has no 127.0.0.2 alias
  ("Can't assign requested address"). `RELAY_BIND` now separates the bind
  address from the advertised host, so the relay can listen on 127.0.0.1
  while the URL names something else.
- `wnd` refuses a socket path it considers too long, and rejects any path
  containing a symlink or a directory it does not consider trusted-owned.
  `B_SOCKET`/`C_SOCKET` are overridable now, so the sockets can live in a
  short real directory while state stays in the repo.

Note for whoever runs this next: the `127.0.0.2` trick the comments
describe no longer buys anything. `RelayUrlNormalizer.isLocalHost` parses
the address now instead of matching literals, so the whole 127.0.0.0/8 is
stripped from the DM and KeyPackage relay lists -- `amy relay add
ws://127.0.0.2:8080` lands in nip65 only, and KeyPackage publishing falls
back to the public defaults. A host that resolves to loopback without
looking like it (lvh.me) survives our strip, but `wnd` rejects plain `ws://`
for a non-local host, so the two stacks currently admit no shared cleartext
relay url. That needs solving before this suite can pass end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:36 -04:00
Vitor PamplonaandClaude Opus 5 80aa4b31e2 fix(marmot): await the KeyPackage relay list before creating the group
"Use outbox relays" launched `saveKeyPackageRelayListFromOutbox()` into its
own coroutine and called `proceedWithCreate()` beside it, so the write the
creation depends on raced the creation itself. The loser was silent:
`isCreating` had already latched true, and the top bar gates on
`isActive = { !isCreating }`, so Create went inert for the rest of the
screen's life -- no group, no error, and only Cancel could leave.

`proceedWithCreate` now takes an optional `prepare` step that runs inside
the same coroutine and is awaited, which also puts a failure to save on the
same Toast path as a failure to create instead of dropping it in a
coroutine nobody reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:36 -04:00
Vitor PamplonaandClaude Opus 5 cd1e2776ee fix(marmot): sign the account identity proof with the bare signer
No Amethyst account could mint a KeyPackage. Every attempt died in
`AccountIdentityProofV2.create` with "signer altered the account identity
proof event tags", so nothing was published to relays, no group could be
created, and the failure was invisible -- the exception surfaced no toast
and, with `VERBOSE_LOGS` off, no log line either.

The account signer is a `NostrSignerWithClientTag`, which appends the
NIP-89 client tag to everything it signs; the setting defaults to on, so
this was the ordinary path on device rather than an exotic one. The check
it tripped exists so a substituting external signer cannot authorize a key
the caller never asked to authorize, and it cannot tell a decorator's
addition apart from a hostile one -- correctly, since both rewrite the
bytes about to be hashed into an id.

So the proof is signed by the signer with that decorator peeled off, which
is what `withoutClientTag()` is for and what every other "these exact bytes
were requested" caller already does. A proof is a fixed statement about a
key rather than a post: it carries no client attribution, and a verifier
would have to strip the tag again anyway.

Verified on device: the app now publishes a framed kind:30443 (466 bytes,
`0001 0005 0001 0001`), White Noise Android finds it, and a group created
there reaches the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:36 -04:00
Claude a0f66eca6e feat(marmot): let an admin switch a group to encrypted attachments
encrypted-media-v2 was reachable in theory and unreachable in practice on
Android. `marmotUsesEncryptedMediaV2` keys off the group's `0x800b` policy, and
nothing in the app ever set one: createMarmotGroup does not pass it, and
setEncryptedMediaPolicy had no caller. Every group this app created therefore
fell back to MIP-04 forever, and the v2 code could only ever run in groups
created by amy or the reference client.

Leaving it off at creation is deliberate and stays that way —
CurrentProfileGroupFactory explains that carrying it at epoch 0 would make our
GroupContext differ from the reference's for identical inputs and would force
every joiner to advertise `0x800b` before it could be added. The spec's answer
is that "a group that wants a media policy commits one". This adds the thing
that commits one.

Admin-only, current-profile-only, and offered only while the group lacks the
component. Enable-only: changing the policy later is the same commit, but
REMOVING it is a question the component does not answer, and inventing a
removal that strands members mid-upload is not something to guess at. The
explainer says plainly that every member sees the change and it cannot be
undone.

The endpoints come from the account's own Blossom server list rather than a
constant, because a policy naming servers the uploader does not use would
describe a group nobody can actually post media to; with none configured it
refuses and says where to add one.

MarmotGroupChatroom gains hasEncryptedMediaPolicy so the action disappears once
it has been taken, populated in syncMetadataTo alongside isCurrentProfile.

Verified against the reference implementation, not just our own tests: the
headless MDK interop harness passes 26/26, including media-v2 in both
directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 20:58:57 +00:00
Vitor PamplonaandGitHub 3c6eb8ee55 Merge pull request #4070 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-10 15:57:57 -04:00
Vitor PamplonaandGitHub 9984cd3662 Merge pull request #4071 from vitorpamplona/claude/search-filter-pills-jer24y
Unify search implementation across Android and Desktop
2026-09-10 15:57:48 -04:00
Vitor PamplonaandClaude Opus 5 21dee6eb2e fix(search): take away All and People while the box holds a filter
The people search is one NIP-50 string against kind 0, so every other
token in the box is dropped without a word the moment the scope includes
People: `#bitcoin since:2025-01-01` came back with people named "bitcoin"
and no hint that the date had been thrown away.

Only `kind:` admitted this, which left the other fourteen fields keeping
the toggle enabled while doing nothing. `isEventOnly` becomes
`pinsToNotes`, and the rule is now the widest one that cannot lie: free
text and its operators (`OR`, `-exclude`, quoted phrases) leave the
toggle alone, and anything else -- authors, `from:`, kinds, pseudo-kinds,
`since:`/`until:`, hashtags, `lang:`, `domain:`, `to:`, `label:`, NIP-73
scopes, `group:` -- greys All and People.

A few of those (`domain:` against a nip05, an npub `from:`) could be
answered by a people search built to; none are today, and offering a
scope that ignores half the box is worse than not offering it.

The screen needed no wiring: `enabled = !pinnedToNotes || s == NOTES`
already covered both halves. The scope stays derived rather than written
back over the reader's pick, so deleting the chip gives back the scope
they chose -- verified on device along with each case in the table.

Tests take one case per pinning field, so a field added to SearchQuery
and forgotten there fails rather than silently doing nothing again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:43:44 -04:00
Claude 09a397f3f1 Merge remote-tracking branch 'origin/claude/search-filter-pills-jer24y' into claude/search-filter-pills-jer24y 2026-09-10 17:37:21 +00:00
Claude d508a171ad Merge main: the golden's kind list stays machine-checked
Twenty commits, one conflict, in the file both sides had reason to touch.

Main pinned kind 11998 — the DVM heartbeat — into the golden test's KINDS
list as a `<not searchable>` row, so that a heartbeat quietly gaining
indexable content would show as a diff. This branch had already replaced
that hand-kept list with `SearchableKinds.ALL`, which holds only kinds
that are searchable, so the pinned row has nowhere to live and is
dropped.

Nothing is lost by that, which is the whole reason to resolve it this
way: `SearchableKindsTest` builds every kind from 0 to 65535 through
`EventFactory` and asserts the recorded set is exactly what came back
searchable. A heartbeat that started indexing text fails there, by
number, without anyone having remembered to list it — the same guarantee
the pinned row gave for one kind, for all of them. The KDoc says so, so
the next person doesn't re-add a row the list cannot hold.

Main's other change to that file is kept as-is: the golden comparison
normalizes CRLF, and `.gitattributes` forces LF on `*.golden`, so a
Windows checkout can't fail the suite on invisible line endings.

Everything else merged clean. The heartbeat kind is not searchable, so
`SearchableKinds`, `RenderableKinds` and the `kind:` vocabulary are
unaffected — verified by running their drift tests against merged main
rather than by reading the diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 17:36:39 +00:00
Claude c0005fb48a fix(marmot): four defects and a wasted write found auditing the branch
SSRF guard walked past by IPv4 shorthand. isSafeToContact only recognised a
literal when the host split into exactly four integer parts, so `127.1`,
`2130706433`, `0x7f.0.0.1` and `0177.0.0.1` all fell through to "safe" — every
one of them loopback to a resolver, which uses inet_aton and does not need four
parts. It gates the real avatar fetch in MarmotGroupIconDisplay, so a group
admin could make every member's device probe its own network: exactly what the
function's own comment says it prevents. Now any notation is packed to its
32-bit value and judged once, and a numeric-looking host that cannot be
evaluated is refused rather than allowed — "we could not tell" must not mean
"go ahead".

encrypted-media-v2 was write-only. The send path existed; nothing rendered it.
The v2 imeta carries `locator <kind> <value>` pairs and no `url`, because the
same ciphertext may live in several places and none is privileged — but
IMetaTag.parse anchors on `url` and returns null without one, so hasMip04Media
was false and a v2 attachment drew as its caption with the image missing and no
error. Adds a v2 branch that parses the tag directly, plus the receiving
constructor EncryptedMediaV2Cipher lacked: the existing one populates the nonce
and plaintext hash from encrypt(), so it could only ever decrypt what the same
instance had just encrypted — the sender's case and nobody else's.

A confirmed publish RETRY forked the group against itself. commitAndPublish
marks the message processed, records the local commit, pins retention and syncs
system rows; retryPendingPublishObligations installed the state and did none of
it. The relay's echo of our own republished commit was therefore admitted as an
unknown kind:445 at an epoch already merged, opening a convergence pass against
ourselves — a restart could put a healthy group into Recovering by succeeding.
The framed commit is recovered by reopening the stored event with the
pre-commit exporter secret that priorState derives, so no persisted record
had to change shape to carry bytes it already implies.

The convergence settler could drop its carrier. Between the loop finding
nothing left and the finally clearing the flag, a new pass's
startConvergenceSettler() lost the compareAndSet and returned, leaving an open
pass with nobody polling it until unrelated traffic arrived. The carrier now
re-checks after releasing the flag and reclaims it if work appeared in the gap.

And a wasted write: persistGroup re-encoded and rewrote the whole retained-epoch
window on every call, including every application message — one TlsWriter and
one array per retained epoch plus a store write, for bytes identical to the ones
already there. The window only moves when an epoch advances, so it is now gated
on a revision counter. The group state itself still persists on every send; that
is what keeps the sender's ratchet generation durable, and nothing here changes
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 17:16:11 +00:00
Vitor PamplonaandClaude Opus 5 73a9d37f2d feat(search): show which relays the search is still waiting on
The spinner could only ever say "still going", and it said it off a stopwatch:
`settled` means "1200ms since you last typed", not "the relays answered". So a
search that hung looked the same as one that had finished, and neither said why.

The sub-assemblers knew all along. They build the per-relay filters and they are
handed every EOSE — the information was reaching `EOSEByKey` for `since`
bookkeeping and going no further. `SearchQueryState` now records both halves:

- `asked`, written as the filters are built
- `answered`, written from `newEose`

Additive and keyed by the query, because the posts and the people assemblers run
off the same state; a call that replaced the set would leave whichever ran second
looking like the only one that asked anything. `newEose` had to become `open` for
a subclass to see it.

Tapping the spinner now lists the relays outstanding, with the ones already in
below them dimmed, under "Waiting on 2 of 5 relays".

The spinner itself changed with it. The timer is now only the floor — something
has to show in the moment before any relay can reply — and after that the real
signal takes over: it turns while a relay still owes an EOSE. Otherwise it would
vanish 1.2s in and the popup would be untappable, which is how I found this
worth doing.

A relay that never sends EOSE would spin forever, so there is a 12s ceiling on
how long the spinner will admit to waiting. Past it the spinner stops and the
list still names who never answered.

Verified on device: "Waiting on 2 of 5 relays", antiprimal.net and
relay.ditto.pub outstanding, nostr.wine / relay.noswhere.com / search.nos.today
dimmed as answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:14:07 -04:00
Claude 3328447e87 Merge: keep the on-device ordering fix and one spinner mirror
Two commits landed on the branch while this audit was running, and one of
them is a crash the audit missed: `searchDataSourceState` had ended up
below the eagerly-shared collectors that call `updateDataSource`, so
opening search from anywhere died on a null field. I found the same
hazard on `listState` and fixed that one, then failed to check what else
that function touches. Their version keeps the field order and states the
rule as a rule; taken as-is.

The spinner was found and fixed twice, independently. Theirs is kept: the
mirror is driven from an `init` block rather than an eagerly-shared
collector, which is one less field whose declaration order matters in a
class that has now been bitten by that twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 16:47:24 +00:00
Claude 0e0cfd9429 fix: what a read of the whole branch turned up
Four bugs and four costs, none of which any test was watching for.

**The spinner never stopped.** `isRefreshing` was
`derivedStateOf { searchValue.isNotBlank() && !state.settled.value }`.
`derivedStateOf` invalidates on snapshot reads and a `StateFlow`'s
`value` is a plain field, so it computed the right answer once and never
recomputed until the text changed — the spinner appeared on the first
keystroke and stayed up for as long as there was anything in the box.
`settled` is mirrored into Compose state now, declared above the
collector that writes it for the same reason `listState` is.

**A search remembered before disk answered was thrown away.** The
restore assigned over whatever had been remembered in the window between
the screen opening and the file being parsed — in memory and then on
disk, because the next write persists what survived. It merges now, and
writes back when the merge changed anything.

**Two saved searches could share an id.** The id was the timestamp plus
the list's size, so two saves in the same second — or one after a delete
— collided, and an id is what `forget` deletes by and what the restore
de-duplicates by. Found by the restore test, which is how it came to have
one of its own.

**A stored history with duplicate rows crashed the list.** Two entries
that serialize alike are two rows with the same key. De-duplicated on
read, not just on write.

Costs, in the order they matter:

- The cache-driven rescan walked the whole cache for traffic that could
  never appear in search. A running app takes in chat, DMs, reactions and
  zap receipts continuously; each bundle cost up to four full scans. They
  are dropped before the sample now, on the kinds the result lists are
  actually built from.
- Three parses per keystroke: the two debounced views re-parsed the text
  the immediate one had already parsed. They are debounces of the parse
  now, so one keystroke is one parse and the same `SearchInput` reaches
  all three.
- `KindRegistry.tokenize` re-sorted the alias map on every call, and it
  is called for every chip on every recomposition and for every
  serialization of a query. Sorted once.
- Desktop re-ranked its whole result list on every keystroke, against a
  query no relay had been asked yet. It ranks against the debounced query
  — the one the results were fetched for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 16:45:05 +00:00
Vitor PamplonaandClaude Opus 5 9b7512a22a fix(search): the spinner never stopped, because it could not see it had
`isRefreshing` read `state.settled.value` inside `derivedStateOf`. A StateFlow
read is invisible to the snapshot system, so the derivation re-ran only when
`searchValue` changed -- the one snapshot input it had. The spinner therefore lit
on the first keystroke and stayed lit for as long as the box held text, long
after the results were on screen.

Observed: type a query, wait forty seconds with results fully rendered, and the
arc is still turning.

The same flow already has a correct reader two hundred lines down, where the
empty state collects it with `collectAsStateWithLifecycle`. That is why "nothing
found" timed out properly while the spinner did not -- one consumer observed the
flow, the other sampled it once and never looked again.

Mirrored into snapshot state in the view model so both of `isRefreshing`'s
inputs invalidate it. Verified on device: spinner present a few seconds in, gone
by twenty-eight, where before it ran past forty.

Worth noting what this does not fix: `settled` is a timer, not knowledge. It says
"long enough since the query changed", not "the relays answered" -- there is no
EOSE on this path. The spinner is now honest about the heuristic it has, not
about the search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:29:59 -04:00
Vitor PamplonaandClaude Opus 5 8241b44a49 fix(search): the refactor moved another field below its eager collector
Opening search crashed again, one field along from last time:

    NullPointerException: SearchQueryState.getSearchQuery()
    on a null object reference
      at SearchBarViewModel.updateDataSource(SearchBarViewModel.kt:504)
      at SearchBarViewModel$searchTerm$2.invoke(SearchBarViewModel.kt:162)

Same mechanism as the `listState` crash: `searchTerm` and `sourceWatcher` are
shared `Eagerly`, so they run `updateDataSource` inside the constructor, and the
pipeline refactor left `searchDataSourceState` declared below them. Kotlin
initialises in declaration order, so it was still null.

Worse than the first one, though. `listState` is only touched on the non-blank
branch, so an empty box survived; `searchDataSourceState` is touched on *both*
branches, so this crashed opening search from anywhere -- seeded or not, Home
included.

Moved above the collectors, next to `listState`, and the comment there is now a
rule rather than a note about one field: everything `updateDataSource` touches
is declared above that line. Two occurrences in two passes over this class say
the ordering is not obvious from reading it, so it is worth stating plainly for
whoever tidies these fields next.

Verified on device both ways: search from Home (unseeded) and from Reads
(seeded, `kind:article`) — no crash on either, and the seed still chips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:10:42 -04:00
Claude cb5e9c9ce1 fix: results that follow the cache, a spinner that means it, and a
vocabulary that can answer

Three smells from the audit, and the first one was a bug.

**Results stopped at the first scan.** Every result flow combined the
debounced query with an invalidation counter that only the lifecycle
touched. So a search ran once, ~100ms after the last keystroke, against
whatever happened to be cached — and the events its own REQ brought back
never appeared, because nothing re-ran the scan. Typing another character
or leaving and returning to the screen was the only way to see what the
relays had sent. The cache has published what it takes in all along
(`ICacheEventStream`); search just never listened. Now it does.

Sampled rather than debounced, and the distinction is the whole of it: a
debounce waits for quiet, and a cache taking in a search's own results
plus everything else the app subscribes to may not go quiet for seconds
— the list would have stalled exactly when it had the most to show.
Sampling caps the cost at one rescan per 400ms and guarantees progress
while events are still arriving. Lifecycle refreshes stay immediate.

**`isRefreshing` meant "the box has text".** Every other implementor of
`InvalidatableContent` uses it for "a refresh is running", and three call
sites here read a name that said one thing and meant another. Both
meanings now have their own names, and the honest one earns its keep:
the field shows a spinner beside the clear button while a search is still
under way. Android had no way of saying results were still coming, so a
half-filled list looked like the answer — desktop has had per-relay
progress since its bar was written.

**Two `kind:` names could never return anything.** `kind:repost` and
`kind:profile` both drew a chip and narrowed the query to a kind the
result scan drops. `kind:profile` was the worse of the two: it also
pinned the scope to Notes, taking away the People scope, which is the
only place a profile is ever found.

The chain of twelve `is` checks that dropped them was in `CacheSearch`,
where the vocabulary could not see it. It is now
`RenderableKinds.NEVER_IN_RESULTS` in commons, `CacheSearch` reads it,
and a test fails if any alias names one. Three more tests pin the rest of
the table: every alias writes itself back as its own name, every alias
names kinds `EventFactory` can build, and the fifteen renderable kinds
with no name of their own are listed so adding a kind is a decision about
whether it earns a word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 16:08:46 +00:00
Claude 3c3824511c fix(marmot): stop offering legacy groups actions they cannot perform
Nothing in the Android Marmot UI knew what a legacy MIP-01 group was —
grepping isCurrentProfile across the whole surface returned nothing. So an
admin of a group created before the current profile saw the Disband button and
the avatar-URL field, used them, and got a toast: those components have no
carrier in a legacy group, and MarmotManager refuses before building the
commit.

Surface it as state rather than as an error. MarmotGroupChatroom gains
isCurrentProfile, populated in syncMetadataTo from the group view, and the two
screens read it:

- Disband is hidden unless the viewer is an admin AND the group can express a
  lifecycle at all. An admin of a legacy group gets a line saying why, because
  a silently missing action is the more confusing outcome — the surprising part
  is that a NEW group would have it.
- The avatar-URL field is replaced by its reason instead of shown and then
  rejected on save. The uploaded Blossom image still works there, which makes
  this a missing option rather than a missing feature. The save call is guarded
  too, so a later edit to the form cannot turn a hidden field back into a
  refused commit.

Both strings say the group cannot be upgraded and a new one is the route. That
is the part a user cannot infer: the identity proof lives in each member's own
LeafNode and covers that leaf's signature key, so it cannot be added to leaves
that already exist.

isCurrentProfile defaults to TRUE on purpose. Creating a group does not run a
metadata sync, and every group created now is current-profile, so a
just-created room must show its full feature set immediately; a restored legacy
room is corrected by the startup sync, which runs for every group before any
screen reads it.

setEncryptedMediaPolicy has the same legacy refusal but no Android UI, so
disband and the avatar URL were the only two exposed paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 16:04:03 +00:00
Claude 6501e330d2 test(marmot): a v3 state blob from the shipped build still loads
The staged-proposal pool moved STATE_VERSION from 3 to 4, and nothing covered
the case that decides what happens to every group already on disk: a blob the
PREVIOUS build wrote being read by this one. There was a v1 test, from an older
bump, and then nothing.

v4 only appends, so for a group with nothing staged the new section is exactly
one uint32 zero. That makes a genuine v3 blob obtainable by stripping those four
bytes and moving the version word back — byte-for-byte what the previous build
would have written, rather than a re-implementation of the old encoder that
could drift from it. The test asserts the tail really is a zero count before
relying on that.

Asserts the blob parses, invents no staged proposals, keeps its epoch, and is a
WORKING group afterwards: it encrypts and decrypts, and its exporter secret is
unchanged. A parse that succeeds but derives different secrets would be the
worse failure, and would look like a passing test without that last check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 15:47:33 +00:00
Claude 3db15da8ea test(marmot): pin what a legacy MIP-01 group can still do
Groups created before the current profile existed are still on disk, and they
can never become current-profile groups: the account identity proof lives in a
member's own LeafNode and covers that leaf's signature key, so it cannot be
added to leaves that already exist. AccountIdentityProofV2 states it outright —
"There is no fallback and no in-place migration". The only route from a legacy
room to a current-profile one is a new group and a re-invite.

That makes the legacy contract worth pinning rather than rediscovering by hand
each time someone tests an upgraded install. Messaging still works; disband
(0x800c) and the URL avatar (0x8007) are GroupContext components a legacy group
has nowhere to put, and both refuse up front with a message naming the reason
rather than failing somewhere inside the commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 15:41:24 +00:00
Claude 1a97982668 test: put the two front ends' REQs side by side
The claim this whole pass rests on is that the same query reaches a relay
as the same REQ from either front end. Nothing checked it — every bug it
started from was a violation of it, and each one was invisible because
the two answers were never compared.

`desktopApp` is the only module that can see both paths, so the test
lives there: 16 query shapes, each asked through Android's
`searchPostsByText` and Desktop's `SearchFilterFactory`, compared arm by
arm on kinds, authors, tags, search, since and until.

One assumption of mine was wrong and the test said so: a bare `from:`
does build filters on both sides. It is bounded by its author, so unlike
a bare `kind:` it is a real search rather than an unbounded feed.

The plan is updated to what shipped, including the two findings left for
someone else: twelve event classes missing from `EventFactory`, and the
searchable-kinds reference table now nine kinds behind the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 15:41:16 +00:00
Claude 3db6cdc4a5 docs(marmot): match send_app_message against MDK's zero-invitee fixture
MDK's prepare_app_send builds its group with create_request(vec![]), so its
sender is alone in the group. Now that our send row is parameterised by member
count, the row that lines up is send_app_message/0 — naming it explicitly keeps
the comparison matched instead of leaving a reader to assume the unparameterised
number still applies. Ratio goes 6.5x to 7.1x faster on the matched pair.

Also records why ingest_app_message has no MDK column: their bench binary panics
in bench_deferred_outbound_preflight_matrix before reaching
bench_app_message_ingest, so there is no reference number — not one we declined
to use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 15:34:54 +00:00
Claude 707cc60261 feat: Android remembers what you searched for
Desktop has had a search history since the advanced bar was written.
Android had none, for no reason other than where the code sat — and an
empty search box on Android rendered nothing at all, which is the one
place a reader's last searches are worth offering.

The behaviour came over with `SearchHistory`; what is added here is
Android's half. `DataStoreSearchHistoryStorage` is the file the two
strings live in — device-global like the drawer's collapse state, since
what you searched for is a property of this phone and is never published
to a relay. The empty box now lists the recent searches, each one tap
from being re-run: an entry is stored as the same text the box holds, so
putting it back in the field is the whole of loading it.

Recorded on the Enter key rather than on every pause in typing. The field
already had an `onSubmit` slot that nothing on Android was passing;
recording every settled query would fill the list with the prefixes of
the word being typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 15:34:24 +00:00
Claude 6f9338e4cd perf(marmot): benchmark commit ingest, and the message path by group size
Two gaps, both of which turned out to be hiding something.

**The message path ran on groups of one and two.** Latency is genuinely flat
across group sizes — an application message is sealed under the sender's own
ratchet and never touches the tree — so the head-to-head claim against MDK
survives being parameterised. Allocation is not flat on the send side: 68.7 KB
at zero members to 253.1 KB at 32, while the receive side moves 61.5 to
67.4 KB. The asymmetry is exact rather than mysterious. MlsGroupManager.encrypt
calls persistGroup unconditionally, serialising the whole group state on every
message sent; decrypt calls it only when the message was a Commit that advanced
the epoch, so decrypting an application message persists nothing. Sending in a
32-member group therefore spends a full state serialisation to record what
amounts to a generation-counter bump. The write is necessary — a sender
generation reused after a crash is a nonce-reuse-class problem — but writing
all of the state for it is heavier than the invariant needs. Recorded as a
finding, not changed: send-path persistence is security-sensitive.

**Commit ingest was measured by nobody**, here or in MDK, despite being the
operation every member performs on every membership or settings change and the
only one whose cost is meant to scale with the group. It grows 1 950 -> 2 367
-> 3 009 us from 1 to 32 members: 1.5x for a 32x bigger group, which is the
log2 shape MLS predicts, since the UpdatePath carries one node per LEVEL of the
tree. Allocation grows 5.7x to 1.4 MB, making it the largest single allocator
in the suite and the row to watch on a phone.

Two measurement lessons are written into the README rather than left implicit.
An isolated --only=ingest_commit run reported no trend at all and put the
one-member case slowest; that was JIT warm-up on the shared group builder,
and the full-suite numbers are the trustworthy ones. And create_group/32 is
now reported as a range (77.5 - 195.7 ms) instead of a figure: five runs
produced four within 11% and one more than twice the rest, which is what the
fewest-iterations row on a shared vCPU looks like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 15:32:18 +00:00
Claude a4ccaba4d3 refactor: search history moves to commons
The cap, the most-recent-first ordering, the de-duplication and the
encoding were all written into a desktop `object` bolted to
`java.util.prefs`. None of it is desktop-specific — it is a capped list
and a labelled list — and that placement is the entire reason Android has
never had a search history and the entire reason none of this was tested.

`SearchHistory` in commons owns the behaviour; `SearchHistoryStorage` is
the two-method seam for where the bytes land. `SearchHistoryStore` keeps
its API and becomes the `Preferences` half, so Desktop is unchanged and
an existing stored history keeps loading.

Two bugs the tests found on arrival:

A saved search whose label contained a tab lost the query it named — the
label is the one free-text field and the tab is the field separator, so
everything after it shifted by one. Labels are now escaped, and only for
the two separators and the backslash, so an already-stored label that
contains none of them encodes to exactly itself.

Re-running a query typed in a different token order made a second history
entry. The comparison is on the serialized form now, so two queries that
mean the same thing are one entry however they were typed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 15:19:55 +00:00
Claude c0aeda8cd3 refactor: desktop's search bar becomes a shell too
`AdvancedSearchBarState` kept its own text, parse, debounce and sort
orders. It now delegates all of that to `SearchState` and keeps what is
genuinely Desktop's: relay callbacks, raw `Event` results, per-relay sync
status, and the expanded form panel.

Two things the shared holder fixes on the way in:

Relevance ranked against the whole box. `sortEvents(notes, order,
rawText)` scored the literal text of the query's own tokens, so a search
with a `from:` or a `kind:` in it ranked on noise — Android had been
fixed to score the leftover terms, Desktop had not. Going through
`SearchPipeline.rank` fixes it and gives Desktop the stable tiebreak too.

`ChangeSource` is gone. It existed to decide whether the field should
show what was typed or the serialized query, because a form edit wrote to
the query while typing wrote to the text and the two could disagree.
`SearchState.edit` writes the token into the box instead, so a button
press and a typed token produce the same state — which is the rule the
whole chip language rests on.

`SearchResultSorter.sortEvents` had no callers left after that, so it is
deleted and its tests now exercise `SearchPipeline.rank`. Two orderings
of the same list, one of them without a tiebreak, was the duplication
this whole pass exists to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 15:08:45 +00:00
Claude 2d354f1ced perf(marmot): re-measure with the founding commit no longer published
marmotBench did not compile after the founding-add change — it was outside the
modules I rebuilt when the commit event became nullable, so two call sites in
it still dereferenced. Both are the same shape as the ones already fixed
elsewhere: the ingest_app_message setup re-ingested its own commit as an echo,
and the epoch probe printed the commit's kind. Neither has a commit to speak of
now, and the probe says so:

    after createCurrentProfileGroup: epoch=0
    after addMember:                 epoch=1
    commits published by addMember:  0 (founding add, merged locally)
    bob after joining:               epoch=1

The measured effect is smaller than "we removed a whole published event"
suggests, and the README now says so precisely. Allocation per operation:
create_group/0 unchanged at 81.3 KB — with no invitees there is no founding Add
to skip — then -11% at one invitee (543.6 -> 482.5 KB), -6% at eight, and -1.4%
at 32. The absolute saving grows with the invitee count because the commit that
is no longer built carries N Adds; the fraction shrinks because the rest of the
operation grows faster.

Latency moved within run-to-run noise, so no latency claim is made for it. The
reason to want the change is that creating a group with initial members no
longer depends on a relay acknowledgement the spec never asked for.

Full table refreshed from two runs: against MDK, create_group/1 is 1.5-1.6x
slower, create_group/8 1.7x, create_group/32 2.6x, while join_welcome is 2.5x
and send_app_message 6.5x FASTER.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 15:08:09 +00:00
Claude c6e1bc4e5a fix(marmot): merge the founding Add locally instead of publishing it
`protocol-core/publish-lifecycle.md`: "When founding creation includes initial
invitees, the creator next prepares and locally merges one founding Add Commit
from epoch 0 to epoch 1. That Commit also has an empty group-message
publication obligation: the creator is the only pre-existing member, so no peer
can be forked by failure to publish it." And: "The empty-obligation exception
is limited to the epoch-0 creation and, when applicable, its immediately
following founding Add Commit."

We implemented the first half and missed the second. createGroup already
satisfied an empty obligation for epoch 0, but the Add that follows went down
the ordinary commitAndPublish path, which applies a commit only once a relay
acknowledges it. So creating a group with initial members against an
unreachable relay silently produced an empty epoch-0 group — the members were
never added and the Welcomes were never sent — where the spec makes the Add
canonical immediately and each Welcome an independent retryable delivery that
"does not affect canonical group state".

addMemberInvites now detects the founding case (epoch 0, creator the sole
member), merges the Add locally, and returns a null commit event with the
Welcomes. Nothing is published, which also stops spending a signature and an
outer encryption on bytes with no audience, and stops leaving a kind:445 on
relays that a joiner can receive before its Welcome — the reference calls that
a "welcome-before-commit AlreadyAtEpoch bounce" and drops the commit for the
same reason.

The commit event is nullable rather than absent so every call site had to be
looked at: the CLI reports founding_local_merge and publishes nothing, and the
Android action logs the case instead of dereferencing.

Test fallout was all one shape — suites that used create + addMember as SETUP
were testing the exception rather than the rule. MarmotPublishBeforeApplyTest
and MarmotPublishDurabilityTest now get past the founding add first, and gain
direct coverage that a founding add merges even when the publisher rejects
everything, and that the very next commit is ordinary.

publish-fail/v1 is refused rather than passed. It fails the FOUNDING creation's
outbound and expects epoch 0 with one member, which is the legacy lifecycle:
MDK resolves the profile from application_profile and `None | Some("legacy")`
means legacy, where create_group returns GroupCreated { pending }. Our old
behaviour happened to match that. Weakening the fix to keep the vector green
would reinstate the bug, so the runner refuses it by name via
LegacyOnlyScenario and the test asserts the refusal. invite-publish-fail/v1
still runs in full: a rejected later invite is an ordinary commit either way.

Also corrects this file's own README claim that create_group/N costs "one more
commit" than MDK. Both do exactly one; only the publishing differed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 15:03:38 +00:00
Claude 4f26534336 refactor: one holder for what a search is
Both front ends had grown their own copy of the same state — the text,
the parse, the debounce, the scope, the sort orders, "is it fair to say
nothing was found yet" — and the copies disagreed on every one of them.
Android parsed the same string about nine times per keystroke across four
debounce windows; Desktop parsed once and had no scope at all. Neither
difference was a decision.

`SearchState` in commons owns all of it. `SearchBarViewModel` keeps only
what a shared holder cannot: a LazyListState, a FocusRequester, invite
routing, NIP-05 resolution, and the seven result flows — the acquisition
of results, which is a cache scan here and a relay callback on Desktop.
615 lines down to 508.

Three things fall out of it:

`SearchInput` carries the text and its parse as one value. Most callers
want the query; a couple genuinely want the characters (a relay finder
matching wss://, an id lookup deciding whether the box holds a pointer or
a phrase), and reading those from a separately debounced flow lets a
collector pair one keystroke's text with another's parse. It also carries
`nameTerms`, which was the most-repeated parse of all — every people and
channel finder re-parsed the whole box to ask for it.

Two debounce windows instead of ten, each named for what it protects:
100ms before a cache scan, 300ms before a REQ, because a REQ opens a
subscription on every search relay and withdrawing it a keystroke later
is traffic nobody wanted. Eight collectors had been declaring the 100
separately, which is not eight policies but one policy that could drift.

`edit {}` writes a filter into the box as text rather than holding it
beside the text. Desktop carried a `ChangeSource` flag to decide whether
to show what was typed or the serialized query; with the box
authoritative there is nothing to decide, and a chip stays something the
reader can delete by editing the words.

One fragility fixed on the way: `listState` is now declared above every
eagerly-shared collector that scrolls it. It was above the only one that
did, and `debouncedForRelays` — a StateFlow with a seeded value, where
the old flow waited out 300ms first — added another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 14:59:21 +00:00
vitorpamplonaandgithub-actions[bot] 4c57207239 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-10 14:57:17 +00:00
Vitor PamplonaandGitHub a8e8778265 Merge pull request #4069 from believethehype/main
Introducing DVM heartbeats
2026-09-10 10:53:46 -04:00
Dr. Tobias Baur e19fce0ed2 fix: pin kind 11998 in the indexable-content golden and make it CRLF-proof
The golden test's KINDS list was missing the new heartbeat kind, and the
comparison read the golden raw — on Windows CI (autocrlf checkout) every
internal CRLF counted as a diff, failing the suite with a full-table
ComparisonFailure. Adds 11998 as a pinned <not searchable> row (a heartbeat
is a machine signal: no indexable content, per the NIP-50 eligibility
policy), normalizes CRLF in the comparison, and forces LF for *.golden in
.gitattributes so checkouts can never break it. No reindex needed: nothing
is indexed for this kind.
2026-09-10 16:40:06 +02:00
Claude d9a0bb17f2 test(marmot): probe the commit shape of founding group creation
The README claimed create_group/N "includes one more commit on our side"
than MDK's founding creation. Measured, that is wrong: create leaves epoch 0
having published nothing, addMember produces exactly one kind:445, and the
invitee joins at epoch 1. MLS allows nothing else — RFC 9420 section 11
requires a group to be created with a single member, so MDK's founding
creation commits Adds internally too.

The difference is that we publish that founding Add Commit and MDK
deliberately does not. Its own comment: "we intentionally do NOT emit the
commit ... every other member lands in the group via welcomes, which carry
the post-commit state directly."

--epoch-probe prints the shape so the claim stays checkable rather than
remembered. The README correction and the behaviour change belong in their
own commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 14:39:19 +00:00
Claude 8a5002fc52 refactor: one scope table instead of seven guards
The All/People/Notes toggle was applied as seven separate
`if (scope == …) return emptyList()` lines, one written into each result
flow. Seven copies of a three-row table is how `ALL` came to mean
"everything" in six of them and something slightly different in the
seventh: the note flow let hashtags through under Notes, the channel
flows did not, and nothing said which was intended.

`SearchScope.shows(SearchResultKind)` is that table, once. A new result
kind now answers the question by appearing in the `when` rather than by
someone remembering to guard it, and the pinned test is written from the
old guards rather than from the new enum, so it records what the toggle
actually did on the day it was collapsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 14:34:45 +00:00
Claude 3977c38fed perf(marmot): field arithmetic on 10 limbs of radix 2^25.5
One X25519 scalar multiplication cost 541us, and create_group/1 is about two
dozen of them, so the curve primitive was not part of the gap against MDK — it
was the gap.

The cause was the representation rather than the language. Curve25519Field
used TweetNaCl's 16 limbs of radix 2^16, so a schoolbook multiply spent 256
limb products. SunEC's X25519 — also pure Java, same JIT, same machine — ran
the same operation in 160us on ~26-bit limbs in 10 words, which is 100
products. The ratio of products matched the ratio of times, which rules out
"managed language" as the explanation and names the fix.

Rewrite the field to 10 limbs of radix 2^25.5, the layout ref10,
curve25519-donna and SunEC all use. A multiply is 100 products, a square 55
(each off-diagonal pair once, doubled), and the ladder's a24 constant gets a
dedicated scalar multiply instead of a general one against nine zero limbs.
Limbs stay signed and denormalised between operations; only pack25519
produces a canonical value. Straight-line locals mean mulInto and sqrInto need
no scratch accumulator at all, so that parameter is gone from every caller.

    x25519_dh       541us -> 121us      ed25519_sign     1018us -> 259us
    x25519_base     535us -> 121us      ed25519_verify   2113us -> 536us

At 121us the scalar multiplication is faster than SunEC's 160us, which is the
sanity check on the result: it lands where a good managed implementation
should rather than somewhere suspiciously better. Against MDK, create_group/1
goes from 4.7x slower to 1.5x, join_welcome from 1.3x slower to 2.5x FASTER,
and send_app_message from 2.5x to 6.5x faster.

Re-encoding curve constants is where one mistyped limb yields code that runs
and is silently wrong, so none were transcribed by hand. Each was re-derived
from its existing encoding and checked against its mathematical definition:
d == -121665/121666, d2 == 2d, By == 4/5, I^2 == -1. The multiply and square
formulas were generated from the representation's weight bookkeeping and
diffed against an independent reference over 20000 random limb vectors before
any Kotlin was written; the carry chain and the canonical encoder were
validated the same way, including at p, p-1 and on non-canonical inputs.

RFC 7748, RFC 8032, HPKE, the MDK crypto-interop vectors and the full quartz
and commons suites all pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 14:32:55 +00:00
Claude 14c4e20198 refactor: put search's cache questions on the port
`SearchBarViewModel` asks `LocalCache.search` five questions, and
`LocalCache` is an `amethyst` singleton — which is the whole reason
desktop wrote a second search rather than reusing this one, and so the
whole reason the two drifted. The five now sit on `ICacheProvider`, the
port the commons migration sweep already routes cache access through, so
a state holder in commons can ask them without naming the module.

They default to returning nothing rather than being abstract. Desktop's
cache holds notes and live channels but has no public-chat or ephemeral
store, and a port that forced it to implement those would be asking it to
lie; a front end renders what its cache can answer and gains the rest
when its cache does.

`CacheSearch` now takes the mute list as `LiveHiddenUsers` rather than the
`HiddenUsersState` holder it lives in. The holder cannot be named from
commons, and the value is what was wanted anyway: `findNotesStartingWith`
was re-reading `.flow.value` five times down one scan, so a mute arriving
mid-scan could hide a note from one branch and not the next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 14:27:53 +00:00
Claude e22b200e63 perf(marmot): square by symmetry, and stop trusting the CPU profile
A JFR profile of create_group put 75% of all execution samples in
car25519. Rewriting car25519 to drop its modulo and its branch then changed
nothing measurable — which is the profile telling on itself. JFR's execution
sampler is safepoint-biased and the counted loops in the field arithmetic
carry no safepoint polls, so samples land on whichever method follows the
poll rather than the one burning the time.

Time the primitives end to end instead. That needs no profiler to be
believed, and multiplying by how many of them an operation performs says how
much of it is curve work:

    x25519_dh        579us      ed25519_sign     1004us
    x25519_base      582us      ed25519_verify   2102us

create_group/0 is ~4.0ms, about seven scalar multiplications; create_group/1
is ~14ms, about twenty-four. The curve primitive is essentially the whole
cost, so that is the only place a create_group speedup can come from.

Square by symmetry: in a*a every off-diagonal pair is computed twice, so
taking each once and doubling turns 256 multiplications into 136. Worth a
measured 6.6% on the scalar multiplication (579us to 541us). Ed25519 is
unchanged, as extended-coordinate point addition contains no squarings.

The car25519 simplification is kept but explicitly claims no speedup: C2 was
already strength-reducing what it removes, and ART is the target that might
not. Its KDoc now records that honestly, and warns the next reader off
profiling this file.

Adds the primitive benchmarks and a --only= filter to marmotBench, which is
what made the profiler's story falsifiable in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 14:17:01 +00:00
Claude d76c3c3ef3 refactor: one kind window for search, checked against quartz
"Which kinds can a search return?" was answered in three places that
disagreed. Android's relay subscription listed 34 kinds; desktop's filter
factory listed its own copy, which had lost the calendar slots and code
snippets; the local cache scan had no kind window at all, so it matched
kinds no relay was ever asked for. Same query, three answers.

Quartz already knew the protocol half of it — SearchableEvent, 142
reachable kinds, referenced zero times by the client — but only as an
interface nothing could enumerate. So it gains SearchableKinds.ALL, and a
test that sweeps the whole 16-bit kind space through EventFactory and
asserts the recorded list is exactly what came back searchable. A kind
added to quartz now lands as a failing test naming the number.

Commons gains RenderableKinds: the 46 of those Amethyst has a card for,
plus three (pin lists, poll responses, NNS records) that match on content
alone and are named as such. Both front ends and the local scan read it.

The audit that produced it found fifteen searchable, renderable kinds the
search never asked for: pictures, all four video kinds, workouts, git
repos, sites, napplets, meeting spaces and rooms, calendar events and
software applications. Searching for your own pictures returned nothing.

Two findings on the way, from the sweep rather than from reading:

- quartz's indexable-content golden test pinned 126 kinds when 142 were
  searchable, so seventeen — every video kind among them — had their
  indexed text unpinned. It now reads SearchableKinds.ALL; the regenerated
  golden adds those seventeen and drops 31890, which is not buildable.
- twelve event classes are absent from EventFactory, so their events parse
  as a plain Event and nothing ever calls indexableContent(). One of them,
  FeedDefinitionEvent, implements SearchableEvent. Left alone and written
  down in the plan: a factory bug is not search's to fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 13:57:13 +00:00
Dr. Tobias Baur f8040e5962 fix: record DVM heartbeats in a strong registry — beat notes are WeakReference-held
Beat notes live in LocalCache.addressables (WeakReference values) with no
strong holder on the Discover screen: every GC sweep cleared them all at
once, the freshness gate failed for every DVM simultaneously, and the list
collapsed and rebuilt one beat at a time (worst on refresh — an allocation
spike). The detail screen survived only because its own composable held the
note.

DvmHeartbeatRegistry (Address -> latest createdAt, strong, one entry per
DVM) is now the freshness source: recorded on every beat consumption, read
by the gate and the liveness composables. Stale entries are inert; the map
is bounded by distinct DVM addresses.
2026-09-10 15:49:55 +02:00
Claude 58e684273e perf(marmot): run Curve25519 scalar multiplication without allocating
An allocation profile of the Marmot benchmarks put 93% of every sampled
allocation in Curve25519Field.mul/add/sub. The pure-Kotlin field arithmetic
returned a fresh LongArray(16) from every operation, and a Montgomery ladder
runs ~18 of them per scalar bit across 255 bits, so one X25519 scalar
multiplication produced over a megabyte of garbage. Ed25519 was worse: its
extended-coordinate point addition needs ten temporaries and a scalar
multiplication calls it 512 times.

Give each field operation an *Into twin that writes into a caller-owned
output and shares one 31-limb accumulator, then rewrite both hot paths around
them. The X25519 ladder allocates its eleven-array working set once before the
loop and overwrites a/b/c/d in place after their last read; Ed25519 creates a
single PointAddScratch per scalar multiplication and reuses it for all 512
additions, including the aliasing doubling step. Every *Into is safe when the
output aliases an input, because mulInto fully accumulates into the scratch
before it touches the output.

The allocating functions stay. They are still used off the hot path, where
clarity is worth more than the bytes, and keeping them means the in-place
versions can be differentially tested against them.

Allocation per operation drops 10x to 80x depending on the benchmark
(create_group/0 6958.7 KB to 88.3 KB, ingest_app_message 5640.3 KB to
70.9 KB), reproducing to four significant figures across runs, and p50
latency improves on every row that is not dominated by measurement noise.
No behaviour changes: the RFC 7748 and RFC 8032 vector suites, the HPKE
tests and the full quartz suite pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 13:47:11 +00:00
Claude 433371680a test(marmot): the state blob is v4 now that it carries staged proposals
Persisting the staged-proposal pool moved STATE_VERSION from 3 to 4, but
this assertion still pinned 3. It is the only test that reads the version
word directly, so nothing else caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 13:46:54 +00:00
Dr. Tobias Baur 9db245f609 fix: make Discover's pull-to-refresh re-issue the discovery REQs
Pull-to-refresh only re-read the cache (invalidateData), so when cached
beats were stale it showed the loss instead of fixing it. The DVM tab's
refresh now also invalidates the discovery assembler, re-issuing the 31990
REQ and both heartbeat streams (global + outbox batches) with fresh rolling
windows — beats repopulate in seconds instead of whenever the open REQs
happen to deliver.
2026-09-10 15:39:55 +02:00
Dr. Tobias Baur 5a4002e256 fix: widen the DVM heartbeat drop window to 900s (hysteresis)
Beats arrive every 300s, so the old 420s window tolerated barely one
delivery hiccup — live DVMs dropped in oscillations and flickered back on
the next beat. The gate now keeps a DVM visible through several missed
deliveries (relay reconnects, REQ churn) before dropping it; a dead DVM
still disappears after 15 minutes. Single constant, all surfaces consistent.
2026-09-10 15:28:25 +02:00
Claude be87d1f625 refactor: one search pipeline, so a front end cannot skip a step
Every piece of a search already existed in commons and was already tested. What
did not exist was the *sequence* — each front end assembled parse → filters →
keep → rank by hand, and each forgot a different part.

That is not a coincidence to be fixed four times. Android never called the
post-filters, so `-term` and the pseudo-kinds did nothing; it never used the
relevance scorer, so Relevance sorted by date; it dropped the query's kinds on
the way to the filters, so a `kind:` chip narrowed nothing. None of those were
visible as a missing call, because there was nothing they were missing *from*.

SearchPipeline is that something. Both filter paths and the Android result path
now go through it, so the fixes are inherited by construction rather than by
remembering.

Generic over the item via two accessors — what event does this carry, what is
it worth — because Android holds `Note` and desktop holds `Event`, and that is
the entire difference between them. The zap accessor is a Double rather than
the BigDecimal a Note carries: quartz declares that type `expect` with no
Comparable and its JVM actual is java.math.BigDecimal, so a comparator over it
cannot be written in common code. A double is exact past any zap that will
exist.

Sort keys are snapshotted per item, which the Android feed order already did
and desktop did not need: a Note is a mutable box, a newer addressable event
arriving mid-sort moves createdAt under the comparator, and TimSort answers
that with "Comparison method violates its general contract!".

15 tests, one per bug that shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 13:22:44 +00:00
Claude d03cc445f1 perf(marmot): head-to-head benchmark against MDK, and where our allocation goes
Adds `marmotBench` — the quartz half of a head-to-head against MDK's
`cgka-engine --bench group_lifecycle`, case for case: create_group/N,
join_welcome, send_app_message, ingest_app_message. Both sides exclude
transport crypto, run over in-memory storage, and keep setup outside the
measured window, so what is compared is the engine's own CPU cost.

Every row also reports BYTES ALLOCATED PER OPERATION, from the JDK's own
`ThreadMXBean` — no dependency added. Latency alone cannot answer "are we
avoiding GC": a JVM can win a microbenchmark and still hand the user a
dropped frame later. The counter is per-thread, so benchmark bodies run
inline via `runBlocking` rather than on a dispatcher, where the allocation
would go uncounted.

First results, same host, nothing else running:

    operation              MDK        quartz      ratio
    create_group/1      3.61 ms     16.93 ms     4.7x slower   27 MB/op
    create_group/8      9.93 ms     51.47 ms     5.2x slower   95 MB/op
    create_group/32    31.64 ms    190.08 ms     6.0x slower  333 MB/op
    join_welcome        4.77 ms      6.22 ms     1.3x slower   10 MB/op
    send_app_message    4.28 ms      1.72 ms     2.5x FASTER  2.8 MB/op

So the steady-state path a user actually exercises — sending a message — is
already faster than the reference. The gap is concentrated in key-agreement
work, and a JFR allocation profile says exactly where: 93% of all allocation
samples are `long[]` from `Curve25519Field.mul/add/sub`, which return a
freshly allocated field element on every single field operation inside
255-iteration scalar-multiplication loops. That one shape explains both the
5x latency gap and the MB-per-op allocation.

The fix (in-place field ops over caller-supplied scratch) is left as its own
change so it can be verified against the RFC vectors on its own merits.

Note: MDK's `ingest_app_message` has no number here. Their bench binary
panics in `bench_deferred_outbound_preflight_matrix` (an assertion on peeler
attempts) before reaching it, and criterion's filter does not skip that
bench's fixture construction. Reporting the gap rather than inventing a
comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 13:22:43 +00:00
Claude 5fc9aa9f58 fix(marmot): every member applies the commit that evicts a leaver, and it survives a restart
Two defects, both found by `leaver-removal-secrecy` and both isolated with a
failing test first. Between them a departing member stayed in the tree —
holding the group's keys and reading everything sent after they left — while
the group believed the departure had been processed.

A peer's proposal must be REFERENCED, not inlined
--------------------------------------------------
`commit()` inlined every staged proposal, including ones another member
authored. An inline proposal carries no sender, so a receiver attributes it to
the committer. For a `SelfRemove` that is not cosmetic: the proposal means
"remove my leaf", so inlining someone else's says "remove the COMMITTER's
leaf". Now only our own proposals go inline; a peer's goes in by
`ProposalOrRef.Reference`, which resolves against the receiver's own pool where
their copy of the same standalone proposal already sits with the original
proposer's leaf index. `PendingProposal.authenticatedContentBytes` documented
this contract all along; the code did not implement it.

A path-less commit must contribute a ZERO commit secret
--------------------------------------------------------
`commit()` derives path secrets unconditionally — it needs them to build the
UpdatePath when there is one — and then keyed the commit secret on whether
those secrets existed rather than on whether the path was actually SENT. A
SelfRemove-only commit omits the path (RFC 9420 §12.4.1), so every receiver
used the zero vector while the committer used a derived one: different epoch
secrets, and every witness rejected the commit with a confirmation-tag
mismatch and fell an epoch behind. The same branch also overwrote
`pathPrivateKeys` with keys that were never published, discarding the ones that
could still decrypt commits addressed to our ancestors.

The pool is an obligation, so it is durable
--------------------------------------------
`MlsGroupState` gains `pendingProposals` (STATE_VERSION 4; older blobs decode
with an empty pool), and staging a peer's standalone proposal now persists the
group the way an epoch change does — it only mutated memory before. Losing the
pool to a restart does not lose a message, it loses the obligation: nobody is
left holding the proposal that evicts the leaver.

That also makes `stageCommit`'s explicit hand-off of the pool redundant, so it
goes back to the shared `stage` helper and `adoptPendingProposals` is removed.

`leaver-removal-secrecy` now replays instead of asserting its own divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 13:22:23 +00:00
Dr. Tobias Baur cd67c56909 fix: source the heartbeat outbox fetcher from the ungated cache scan
The outbox fetcher read its announcement set from the gated DVM feed list —
a death spiral: a DVM dropped for a stale beat left the fetch batch, its
beats were never fetched again, and the drop became permanent. The fetcher
could only ever help DVMs that were already visible.

The assembler now takes cache-backed sources from the front end:
LocalCache.cachedDvmAnnouncements (every cached k=5300 announcement,
newest-first, capped at 100 — the gate's other eligibility checks, WITHOUT
the freshness gate), a per-author relay lookup unioning the NIP-65 outbox
with the cached relay hints (same mix as the event finder), and 31990/NIP-65
observation flows as re-issue drivers.
2026-09-10 15:09:57 +02:00
Claude 6e6727623e docs: scope the search state unification
A proposal, not a change. Four bugs of one shape turned up during the search
feature work — a control live on one platform and dead on the other — and
fixing them individually has no end while the structure keeps producing them.

The finding that matters for scheduling is that this is not a laziness
problem. LocalCache, CacheSearch and Account live in `amethyst/`; Note, User
and the whole query layer live in `commons/`. Desktop could not reuse
Android's search, so it wrote a second one over relay callbacks alone. Every
divergence follows from that single module boundary.

Which means the commons migration sweep already plans the move that dissolves
it — LocalCache is its step 3, and deleting DesktopLocalCache is called the
largest duplication in the repo. So the plan explicitly refuses to invent a
search-side cache port: that would be scaffolding the sweep deletes. It either
waits for step 3 or extends the sweep's own ICacheProvider, and the sequencing
call is the maintainer's.

Phase 1 is independent of both and does the part that actually caused the
bugs: one pipeline object owning parse → build → post-filter → rank, so a
caller cannot skip a step, plus one kind-set source derived from quartz's
SearchableEvent instead of three hand-kept lists that have already drifted.

Non-goals, the pieces that are genuinely not shareable, and the parity test
that would have caught all four bugs are written down too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 12:59:58 +00:00
Dr. Tobias Baur 6a02d787b6 fix: fetch DVM heartbeats from the DVMs' outbox relays
Alive DVMs whose heartbeats never reach the user's discovery relays stayed
hidden from the Discover list: the freshness gate only saw beats delivered
by the global heartbeat REQ on the selection's relays, while the beats
existed on each DVM's own outbox (their detail screens proved it).

DiscoveryDvmHeartbeatSubAssembler joins the discovery assembler group and
batches the DVM list's announcement authors per DVM outbox relay
(coverage-ranked, capped at 12 relays; unknown outboxes fall back to the
global REQ). Re-issues on DVM-list membership changes via the inner feed
flow, since FeedState.Loaded reuses its wrapper.
2026-09-10 14:28:16 +02:00
Dr. Tobias Baur 6d5e847f73 fix: keep the DVM heartbeat REQ alive on every Discover selection
Selecting a Relay (or favorite-algo-feed) chip in the Discover menu, or a
relay whose per-relay slice was momentarily empty, dispatched no kind-31990
filters — and the heartbeat REQ rode along, so beats stopped arriving while
the Content tab kept rendering cached announcements. The 60s staleness timer
then dropped every DVM as its cached beat aged past 420s.

The heartbeat filter now rides the selection's own relays (new
IFeedTopNavPerRelayFilterSet.relays()) instead of the 31990 dispatch result.
2026-09-10 14:08:10 +02:00
Claude 5f36618767 fix(marmot): a leaver's SelfRemove was committed but never applied
`MlsGroup.saveState()` does not serialize the staged-proposal pool. `stageCommit`
prepares its commit on a CLONE restored from that state, so the clone always
started with an empty pool: `commit()` produced an empty proposal list, the
epoch advanced, and every proposal the commit was called to apply was silently
dropped.

The case that bites is a departing member. MIP-03 makes a departure a
standalone `SelfRemove` PROPOSAL — the leaver cannot evict themselves — so it
sits in the pool until an authorized member commits it. That commit ran, looked
successful, and left the leaver IN THE TREE, still holding the group's keys and
still able to decrypt everything sent after they left.

`stageCommit` now hands the clone the live group's pool. The other `stage*`
entry points are untouched: each of those has a proposal of its own to make,
and folding a peer's pending SelfRemove into an unrelated commit would also
trip MIP-03's no-mixing rule for a non-admin committer.

Also here:

- `MarmotManager.commitPendingProposals` — the commons-level entry point for
  "commit what a peer proposed", returning null when nothing is staged so a
  caller can drive it unconditionally after ingest.
- `MlsGroup.hasPendingProposals` / `MlsGroupManager.hasPendingProposals` — a
  public way to ask whether there is work to commit, where the proposals
  themselves stay module-internal.
- Scenario runner: `restart_client` (rebuilds the manager over the same stores
  and calls `restoreAll`, so nothing may depend on state that only lived in
  memory) and `leave`. `restart-delivery-faults` replays.
- `removed_members` observations are now checked, including the evictor's own
  commit — the actor was the one participant who did not remember doing it.

Two gaps stay open and asserted rather than deleted, so they fail loudly when
fixed: a peer that has the same proposal staged does not apply the commit
carrying it inline (`leaver-removal-secrecy`), and the proposal pool still does
not survive a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 12:06:41 +00:00
Claude 14b02a1ebb feat(marmot): disband a group, edit its avatar link, and replay seven more vectors
Disband (`marmot.group.lifecycle.v1`, 0x800c)
--------------------------------------------
We enforced the Disbanded state but nothing on this side could enter it, so
the enforcement was only reachable from a peer's commit. `disbandGroup` writes
the component in a Commit every member replays. It refuses what is
structurally impossible — a non-admin, a legacy MIP-01 group with no carrier
for a lifecycle state, a second disband — and, unlike every other metadata
setter, refuses to report success when no relay acknowledged the commit: the
caller is about to tell a human the conversation is over, and a group that is
still live for everyone else must not be announced as ended. The obligation
stays queued either way.

On Android it is an admin-only action in the group header behind its own
confirmation, worded for what it is: for everyone, and not reopenable.

Avatar link (`marmot.group.avatar-url.v1`, 0x8007)
--------------------------------------------------
`setGroupAvatarUrl` existed but only `amy` could reach it — the renderer
already preferred a URL avatar over the Blossom image, and no Android screen
could set one. Edit Group Info now carries the field; it commits separately
from the profile so saving a rename does not rewrite the avatar state, and
clearing it falls the group back to the uploaded image.

Seven more scenario vectors
---------------------------
New steps: `update_group_data`, `remove_members`, and the delivery-fault
family — `omit_message`, `duplicate_message`, `reorder_messages`,
`withhold_message`, `release_withheld`. Selectors are matched key by key and
an unknown key is refused, because a silently widened selector injects a
different fault from the scripted one while still reporting under the
vector's name. `group_profile` outcomes are checked too.

Replaying now: group-data-update, deferred-tick-catchup, incremental-growth,
drop-queued, queue-faults, delayed-past-epoch-app-message,
readd-after-eviction. 18 vector tests, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 11:50:18 +00:00
Claude 92f1fe3422 feat(marmot): add several members in one commit, and stop passing vectors vacuously
Batched Adds
------------
MDK — and therefore both White Noise clients — turns a `create_group` with N
invitees into ONE Commit: N Add proposals and one Welcome carrying N
`EncryptedGroupSecrets`, keyed by KeyPackage reference (RFC 9420 §12.4.3.1).
We staged one Add per commit, so the same group landed at epoch N instead of
epoch 1 and cost a round trip per invitee.

`MlsGroup.addMembers` / `MlsGroupManager.stageAddMembers` propose-N then commit
once; `MarmotManager.addMembers` publishes that single commit and fans the same
Welcome bytes out to each invitee. The singular entry points delegate, so no
caller changes.

Scenario vectors: two shapes, one of them unread
------------------------------------------------
The conformance vectors state their expectations two ways —
`expected_trace.observations` and `expected_outcomes` — and the parser only
read the first. Seven of the nine vectors here use the second, so they parsed
to ZERO expectations, replayed their steps and reported green without checking
anything. `in_group` was also treated as a leaf, which silently skipped every
step nested inside it, and `send_app_message` built a message the runner never
queued for delivery.

Now parsed and checked: `client_state`, `clients_converged`,
`pending_resolution`, `no_pending_work`, inline `assert`/`payload_count`
(the forward-secrecy and isolation assertions), `added_members`, `clear_events`,
and multi-group clients. `convergence_decision` has no counterpart in our
engine, so `convergence-committer-selected` is now REFUSED via
`UnsupportedScenarioOutcome` rather than passed on the parts that happen to be
modelled. A new guard test fails any vector that parses to nothing to check.

three-client-message-exchange and conversation now replay for real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 11:36:52 +00:00
Believethehype e9ae377e1b docs: record uniform-strict offline semantics for DVM liveness 2026-09-10 12:52:23 +02:00
Believethehype ad1d4ccf8b fix: stabilize DVM heartbeat freshness state and label the offline dot 2026-09-10 12:52:19 +02:00
Believethehype 97172c1bb3 docs: mark DVM heartbeat liveness as implemented 2026-09-10 12:35:36 +02:00
Believethehype eda92fa06b fix: restore DVM splash-screen TODO comment 2026-09-10 12:33:23 +02:00
Believethehype fed8f67ee1 feat: show DVM liveness (offline dot, offline banners) on pinned feeds and detail screens 2026-09-10 12:31:02 +02:00
Believethehype fc98d15364 feat: subscribe to DVM heartbeats from the Discover screen and add liveness helper 2026-09-10 12:26:03 +02:00
Believethehype 2f67d97cc6 feat: hide DVMs without a fresh heartbeat from the Discover list 2026-09-10 12:20:16 +02:00
Believethehype dac0033dcd fix: use collision-free 64-char hex ids in DvmHeartbeatTest 2026-09-10 12:16:50 +02:00
Believethehype 0e77473064 feat: store DVM heartbeats in LocalCache and expose the freshness gate 2026-09-10 12:09:03 +02:00
Believethehype cd14c15188 feat: add DvmHeartbeatEvent (kind 11998) for DVM liveness 2026-09-10 12:03:32 +02:00
Believethehype 967cae7f15 docs: add DVM heartbeat liveness implementation plan 2026-09-10 11:14:41 +02:00
Believethehype 4e1443e2e0 docs: add DVM heartbeat liveness design plan (kind 11998) 2026-09-10 10:45:01 +02:00
Claude 5b1ac283d5 feat(marmot): retention UI, pinned reference engine, and a scenario-vector runner
**Disappearing messages are settable.** A picker at group creation — off, 1
hour, 1 day, 1 week — and a read-only line on the group info screen so a member
who cannot change the setting still knows their messages are on a clock. Fixed
values rather than a free-form duration: the number is committed into group
state every member's client reads, and an arbitrary one buys nothing. Creation
is the only place it can be chosen, because promoting a component to required
after epoch 0 takes two commits and that screen makes one. Until now we obeyed
a setting neither Amethyst nor `amy` could set.

**The harness tests the engine users run.** It cloned mdk master and got
whatever was tip; both shipping White Noise clients embed an immutable
MarmotKit artifact and name its `mdk-sha` in a lockfile. It now checks out that
commit, and rebuilds `wn` when the checkout moves — a pinned tree beside a
binary built from a different commit would report a version it did not test.

**A scenario-vector runner.** Their manifest marks 31 artifacts `portable`,
meaning written for one engine and meant to be replayed by another. Three are
byte fixtures we already consume; the rest are scripts. `MarmotScenarioRunner`
replays them against our own stack — publishing captured into a queue,
`deliver_all` moving it into inboxes, `tick` draining them — and checks the
expected trace. Six pass. This is a different claim from the interop harness:
that proves we can TALK to `wn` over a relay, this proves the same events land
us in the same group state.

It refuses an unimplemented step by name rather than skipping it, because a
runner that ignored steps would report a pass for a script it never executed.

That refusal immediately found something. Three vectors create a group with
several invitees, and the reference adds them all in ONE commit — epoch 1.
`MarmotManager.addMember` stages one Add per commit, so we reach epoch N. Both
are valid MLS and any peer processes either, but our traces cannot match, and
creating a group costs an extra round trip per invitee. Asserted as a named
divergence so it stays visible and fails the day batched adds land.

**Two smaller things.** `DispatchStageBenchmark` is opt-in behind
`-DrunLoadBenchmark=true`: it pushed 30k events through six variants twice
inside a `runTest` whose cutoff is one minute, so on a loaded runner it failed
having measured the machine rather than the code. And encrypted-media-v1
(`0x8008`) is closed as not-needed — required only on the Legacy profile, which
strict cutover now forbids joining, so no group that asks for it is reachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 03:40:05 +00:00
Claude 37cd798e99 feat(marmot): pin retention to the delivering epoch, and set it from amy
Three related pieces.

**The pinning was approximate.** Expiry was pinned from the group's CURRENT
retention at persist time, which is right for a message delivered under the
current epoch and wrong for one delivered under an older one — a kind:445 held
back as a retained candidate, or replayed after a restart, is decrypted under
an epoch the group has since moved past. Pinning that to today's setting is
precisely what the component forbids. The store now keeps a small epoch →
retention history, written wherever the epoch may have advanced, and the pin
reads the delivering epoch's value. The fallback to the current value is not a
shrug: a group whose setting never changed has one value at every epoch, which
is the overwhelmingly common case. MDK carries `source_epoch` on its rows for
the same reason.

**`amy` can set it.** `marmot group create --disappearing-secs N`, on both
profiles: component `0x8005` for a current-profile group, and the legacy blob
for `--legacy`, where asking for it bumps the version to 3 because v1/v2
deliberately omit the field to stay byte-compatible with MDK's older parser.

**Interop test 26.** Nothing proved MDK accepts a GroupContext that REQUIRES
`0x8005` with our bytes, and retention has the nastiest encoding in the set:
eight big-endian bytes with no length prefix, unlike almost every other Marmot
field, and MIP-01 spelled it differently. amy creates such a group, wn joins
it, and messaging round-trips.

It asserts acceptance rather than read-back, because wn's CLI `group_json`
does not surface the retention value — that field is on the uniffi struct the
apps consume, not this surface. Acceptance is still the encoding test: a
required component whose bytes wn cannot decode leaves the group unreadable,
so `groups show` returning it at all means the eight bytes parsed. The
direction is one-way for the same reason as push — `wn groups` has no command
that sets retention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-10 02:16:25 +00:00
Claude 4beff0d76f feat: make Relevance actually rank, and say something when there is nothing to show
Two things the search screen showed that were not true.

**Relevance sorted by date.** `SearchSortOrder.RELEVANCE` shared a branch with
NEWEST, so picking it changed nothing. SearchResultSorter.scoreEvent — which
weighs an exact phrase, token boundaries and an article's title, and is fully
tested — was wired only into AdvancedSearchBarState, the desktop path, and had
no references in this module at all. It ranks here now, so the two front ends
agree.

Scored on the leftover terms rather than the whole box: `from:npub1…` and
`kind:article` are filters, and hunting for their literal text inside an
event's content ranks on noise. A query that is all chips has nothing to be
more or less relevant to, and falls back to newest. The sort still lives here
rather than calling sortEvents wholesale, because POPULAR ranks on a note's zap
total that a raw Event cannot see — the sorter says as much itself.

**An empty result list said nothing at all.** This was the only search surface
in the app without an empty state; settings, git repositories, the location
picker and app recommendations all have one. It is also the one that needs it
most: a screen that seeds only its kind opens holding a chip and showing
nothing, because SearchFilterBuilder rightly refuses "every recent article" as
an unbounded REQ rather than a search.

So the two cases are told apart. A query that asked nothing gets "type
something to search"; a query that asked and came back empty gets "no results".
Blaming the network for a search the reader has not written yet is the failure
worth avoiding, and the method that decides is pinned by tests so the two
messages cannot swap places.

"No results" waits out a grace period first. No EOSE from the search
subscription reaches this screen, so nothing actually knows the relays have
finished; without the delay an empty list would announce failure in the gap
before the first event arrives, which is every search, for a moment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-10 01:20:16 +00:00
Claude 99433d9f5c test(marmot): pin the broker's certificate in the stream tests
Tests 18 and 19 failed inside TLS before a single frame was written:

  CRYPTO_ERROR (TLS alert 42): certificate chain validation failed:
  PKIX path building failed: unable to find valid certification path

The reference broker generates a self-signed certificate — its startup JSON
says so, `"tls":"generated_self_signed"` — and there is no CA anywhere in this
picture, so chaining it to the JDK trust store could never have worked. The
binding anticipates exactly this: a client MAY pin the endpoint certificate by
SHA-256 instead of chaining, which is what `--pin-sha256` and
`PinnedCertificateValidator` are for. The broker prints the fingerprint the
pin needs, in the same JSON line the harness already waits on; the harness
just never read it.

So `start_quic_broker` now captures `server_cert_sha256_fingerprint` and fails
loudly if it is absent, and the three `amy marmot stream send|watch` calls pass
it. `wn` reaches the same place with `--insecure-local`; pinning is the better
half of that trade, since the peer still has to sign the TLS transcript with
the pinned certificate's private key.

25 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 23:32:41 +00:00
Claude df381bd80d style: apply spotless
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 23:28:22 +00:00
Claude 464ed1eb42 Merge remote-tracking branch 'origin/claude/search-filter-pills-jer24y' into claude/search-filter-pills-jer24y 2026-09-09 23:26:52 +00:00
Claude 4dfa80c342 fix: a picker's scrolling no longer drags the whole screen with it
Paging through the `kind:` list slid the top and bottom bars away as if the
results had been scrolled.

A picker is a scrollable that lives inside the *top bar*, and
DisappearingBarNestedScroll moves the bars on `consumed.y + available.y` — the
total scroll that entered the chain, deliberately, so the bars also ride
overscroll at a list's edges. That sum is conserved as a scroll walks up the
nested-scroll chain: a node hands its parent `consumed + myConsumed` and
`available - myConsumed`. So no connection placed under the picker can hide its
scrolling from the scaffold, however much it consumes — the scaffold has to be
told instead.

It already takes `allowBarHide`, and reads it through rememberUpdatedState, so
the search screen pins its bars while a picker is open and releases them when
it closes. Which is what you want anyway: chrome that slides around under a
dropdown the reader is aiming at is its own bug.

The flag is reported upward from the field rather than read downward, because
the scaffold that has to stop moving is composed above the field that knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 23:25:37 +00:00
Claude 35884332ce revert: drop the change/remove row under a tapped chip
Reverts 490cd4fa. The row offered two things the text field already does on its
own — put the caret in the token and press backspace — so it bought no new
capability, and it appeared whenever the caret so much as touched a chip, which
made it noise rather than an affordance.

The chips stay tappable in the sense that matters: the caret goes where you tap
and the token is ordinary editable text, which was always the point of keeping
the query as text rather than as a set of widgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 23:24:05 +00:00
Claude 24eea6a6d2 feat: pin the scope toggle to Notes while the query names a kind
A `kind:` window is a property of an event, and a person is not an event of any
kind — so All and People cannot answer such a query. Leaving them selectable
offers the reader a scope guaranteed to come back empty, which reads as the
search being broken rather than as the filter doing its job.

The toggle now shows Notes and greys the other two while a kind is in the box.
Greyed rather than merely unselected: an option that is present and silent is
worse than one that is visibly unavailable.

The applied scope is derived from the reader's pick rather than written back
over it, so dropping the `kind:` chip returns them to whatever they had chosen
before instead of leaving them pinned by a filter that is no longer there.
Every consumer of `scope` — the people, note, channel and hashtag result flows
— reads the derived value, so none of them can disagree with the toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 23:18:19 +00:00
Claude b3b4fdaf43 feat(marmot): honour disappearing messages
`marmot.group.message-retention.v1` decoded into `MarmotGroupState.retention`
and then nothing read it. In a group with disappearing messages enabled, every
member's copy vanished on schedule except Amethyst's, which kept the plaintext
indefinitely — not a wire incompatibility, the group still worked, but a
privacy divergence from what that group was told it had.

Expiry is pinned per message when it enters the log, never recomputed. That is
the component's rule and it is the easy one to get wrong: a message keeps the
retention of its OWN source epoch, so changing the setting later must not
shorten, extend, or restore the expiry of a message that already exists.
Recomputing from the current setting would let one member retroactively
shorten everyone's history, or resurrect what should already be gone. First
write wins for the same reason — the ratchet rewinds on restart and relays
replay recent kind:445s, so the same message really is persisted twice, and a
second write that re-timed it would let a message postpone its own expiry
every time it was replayed.

The retention itself is read from the `0x8005` component with a fallback to a
legacy group's `0xF2EE` field, because the two profiles express the same
setting in different places and reading only one would silently treat half the
groups as having no expiry.

Expiring deletes rather than hides. This store is the only copy — the ratchet
moved past the ciphertext it came from long ago — so a message that is merely
filtered out of a read is still on disk, and a disappearing message that is
gone from disk but still on screen has not disappeared either. Both stores
rewrite their logs, reads prune first so a restart cannot show something that
fell due while the app was closed, and the front end is told what went so it
can drop those rows from a conversation already open.

Traffic is the clock: a group being read is a group whose expired messages
should already be gone. There is no timer, so a group nobody opens keeps its
messages until someone does — worth knowing, and better than a wakeup that
exists only to delete.

Expiry stays advisory by design, as the component says: the duration is
authenticated but the base is the sender's own `created_at`, so it inherits
the trust already placed in an MLS-authenticated sender and is not a guarantee
against a hostile one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 23:15:18 +00:00
Vitor PamplonaandClaude Opus 5 6f83f6c5ed fix(search): initialise the list before the collector that scrolls it
Opening search from any seeded screen crashed the app on the spot:

    NullPointerException: Attempt to invoke virtual method
    LazyListState.scrollToItem(...) on a null object reference
      at SearchBarViewModel.updateDataSource(SearchBarViewModel.kt:479)
      at SearchBarViewModel.<init>(SearchBarViewModel.kt:131)

`sourceWatcher` shares its flow `Eagerly`, so `onEach { updateDataSource(...) }`
runs while the constructor is still executing, and `updateDataSource` scrolls
`listState` -- which was declared *after* it. Kotlin initialises properties in
declaration order, so at that moment the field is still null.

It was latent until this branch. `updateDataSource` returns early on a blank
term, and the box always opened blank, so the scroll was never reached during
construction. Seeding the field with the screen's own filter makes the term
non-blank on the very first pass, which turns the ordering bug into a crash the
moment search opens from any seeded feed.

Moving the declaration above the collector fixes it, and the comment says why it
has to stay there -- the next person to tidy these fields alphabetically would
put it back.

Reproduced deterministically before the change (Reads -> search, fresh crash
buffer, one FATAL) and confirmed gone after, on the same tap sequence. The
feature it was blocking now works: search opened from Reads seeds `kind:article`
as a chip, tapping the chip offers Change/Remove, and Change cuts it to `kind:`
and opens the new kind picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 18:58:35 -04:00
Claude d7b5884000 test(marmot): bound app-payload parsing and port two reference fuzz targets
Three pieces of hardening taken from the reference client's `:fuzz` module.

**Parse bounds.** An app payload reaches a decoder only after MLS has
authenticated that a group MEMBER sent it — never that it is well-intentioned,
and deep nesting or a huge collection costs a parser far more than it costs
whoever sent it. `MarmotJson` now pre-scans for the same three limits the
reference draws, at the same values: 64 KiB, depth 16, 64 elements per
container. The scan is linear and runs before any JSON library sees the string,
and it deliberately does NOT double as a validity filter — malformed input
inside the limits still reaches the parser, so its error paths keep being
exercised. `MarmotAppEvent.decode` is the choke point, so every inner kind is
covered, with kind:1210 checked again at its own entry point because
`fromAppEvent` can be reached without it.

The byte limit counts UTF-8 rather than UTF-16 code units, which is the
difference between a 64 KiB cap and a 256 KiB one for a payload of emoji.

**Two ported targets.** Neither could be a like-for-like copy, because the
reference fuzzes code we do not have in that shape — their metadata walkers are
deliberately Android-free byte functions, ours is `ExifInterface` over a `Uri`.
What ports is the set of oracles:

- Identity references, against `Nip19Parser`: never throws, deterministic,
  idempotent on what it canonicalises, and never emits a key that is not
  32 bytes of lowercase hex. The corpus is their grammar — `nostr:`, profile
  links, percent-encoded separators, truncated and over-long bech32 bodies,
  clipboard text with several references run together.
- Container sniffing, against `ShareHelper`: never throws, deterministic,
  always names a declared kind, a mismatched walker does not claim the
  container, and — the one that matters most — only the header decides.
  A sniffer that read past its header would let bytes deep inside a file
  relabel it, which is what content-type confusion needs.

Seeded rather than Jazzer-driven, so no fuzzing engine joins the build and a
failure reproduces from the printed seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 22:34:24 +00:00
Claude ab13369fd1 feat: seed the remaining feeds, and stop a screen omitting its filter by accident
Seventeen more feeds hand their kind window to the search box: communities,
workouts, music tracks and playlists, podcasts and episodes, software apps,
shorts, products, follow packs, live streams, polls, stories, calendars and
calendar sets, long videos. Each also carries whatever its list spinner
narrowed it to, on the same terms as the feeds wired earlier — a hashtag or a
geohash says itself as a token, a follow set does not and seeds nothing.

Home seeds only its spinner: its feed spans every kind Amethyst can render, so
there is no one window to hand over.

Thirteen new KindRegistry aliases so those chips read as names rather than
numbers, which also makes each one a row in the `kind:` picker. The four video
feeds are nested windows over the same kinds — `short` is 34236, `longvideo`
34235, `stories` both, `video` all four — and the alias matcher already refuses
to widen a window into a larger group that merely contains it. Pinned per
screen, because a window that serialized to a name the parser read back as a
different set would hand the reader a search their screen never asked for, and
nothing else in the round trip would notice.

`baseFilter` loses its default. It had one, and the result was that two thirds
of the app's search buttons opened bare without anyone noticing — an omission
that looks exactly like a decision. Now a screen must decide, and the four that
genuinely have nothing to say pass null and say why: the location-channel list
and the group-discovery list each show many, not one, and NIP-17 messages are
encrypted, so no relay can search them and no kind window would return anything
the reader could read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 22:34:15 +00:00
Claude f414a8b8e0 fix(marmot): never render a system row a peer asserted
Rendering kind:1210 rows, added earlier today, trusted the wrong thing. MLS
authenticates that a member SENT an inner payload; it says nothing about
whether the payload is true. The renderer read `actor` and `subject` straight
out of that payload, so any member could send a well-formed 1210 saying "X
removed Y" or "X renamed the group" and Amethyst would draw it as a system
caption — attributed, styled as history, indistinguishable from a real one,
in the part of a conversation a reader trusts most.

`syncGroupSystemRows` already documented the rule ("one that arrives over the
wire is an assertion by its sender, not a derived fact"); the render path
simply did not honour it. The reference client draws the same line from the
other side — its raw 1210 parser nulls attribution outright and marks every
result unauthenticated, with a fuzz target asserting exactly that.

The rule now lives at the one choke point every row passes through:
`MarmotGroupList` shows a 1210 only when this client authored it. That is the
right test because a derived row is diffed from MLS-authenticated state and is
always authored by the account itself. It has to be there rather than at
ingest, because rows arrive by two routes — live decryption and the restart
re-read of the local log — and the log holds received payloads too, so an
ingest-only guard would have let a forgery back in on the next launch.

Dropping the sender's version costs nothing: every client that applied the
same commits derives the same rows.

The same feature had a second defect, which the first one was hiding. Derived
rows were persisted but never surfaced, so they appeared only after a restart,
and Android derived them solely for its own commits. In practice the only 1210s
reaching the feed live were the untrusted ones. Rows now surface as they are
derived, and every accepted commit derives them, not just ours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 22:12:29 +00:00
Claude 490cd4fa7a feat: tapping a chip offers to change or remove that filter
A chip is drawn text inside one BasicTextField, not a composable of its own,
so tapping it can only move the caret — there is nowhere to hang a ✕ on the
chip itself. So the caret landing on a finished token is what stands in for
"the reader tapped this one", and a row under the field turns that into the
two things anyone wants from a filter they can see: change it, or drop it.

Change cuts the token back to its prefix — `kind:article` becomes `kind:` —
which leaves the field in exactly the state typing `kind:` and stopping would
produce, so the picker opens on the spot and every existing rule about what it
offers and what a pick splices in still holds. Nothing new had to learn how to
edit a token. A chip with no picker (`#tag`, `-term`, `"a phrase"`) is selected
instead, so the next keystroke replaces it.

Remove takes the one space the token leaves with it. That is not cosmetic: the
field's text round-trips through the parser on every keystroke, so a doubled
space compounds every time a filter is dropped, and a seeded query — which
arrives with a trailing space so its chip settles — has to come back to a
genuinely empty box rather than one that looks used.

The editor and the picker can never both be up: a picker only opens on a token
that is not finished, and the editor only on one that is. That is asserted
rather than assumed, along with every offset of a chip resolving to that chip,
since a tap can land on either edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 21:49:29 +00:00
Claude cec8d94631 fix(marmot): advertise 0x8006 and the receive role again
Advertising a capability and running a service are different claims, and
conflating them made an Amethyst user un-addable to any group a White Noise
user starts.

The reference client installs agent-text-stream-quic-v1 with
`required_member_roles = receive` into the required component set of EVERY
group it creates, then refuses an invitee whose KeyPackage omits either the
component or the `0xF2D1` role — checked before negotiation, so negotiation
cannot rescue it, and the invite path applies the same rule. Dropping the
advertisement on the grounds that nothing in the deployed network publishes
previews was right about the traffic and wrong about the capability: a
capability says "this client can handle it", never "this group uses it".

So the component and the receive role go back. `send` and `fanout` stay off
and no watcher is started — we can be shown a preview, we do not originate
one, and nothing dials a broker.

Two tests had encoded the old premise, one of them asserting the refusal as
if it were a feature. They now assert the rule that actually decides interop:
our default leaf is admitted by the reference's own stream policy, and is
refused by a group that requires `send`. The KDoc on
`currentProfileLeafCapabilities` had described the role as present the whole
time — it was the code that had drifted from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 20:33:09 +00:00
Claude 9b8729db22 feat(marmot): push token gossip in the shape the spec adopted
Kind 451 had no callers, and the reason turned out to be everything around
it: the 447/448/449 events in this tree were the exploratory shape the spec
now names as not interoperable — tokens in `token` tags with empty content,
the sender's leaf implicit, no removals at all, and no owner authentication.
The token encryption derived its key from the old `mip05-v1` salt, and the
446 trigger still carried the `encoding` tag the adopted rumor dropped.
Wiring the proof into that would have produced records no peer can read.

So the gossip is now content-JSON under `marmot-push-v1`, and the version
string is the gate: the old value is refused rather than translated, because
the two versions are not predecessor and successor.

The design the rewrite is really about is owner authentication. A record's
authority comes from its own `owner_sig` and current membership, never from
who carried it — which is what lets one member relay another's records so a
group converges without every owner being online, while stopping the relayer
from repointing, re-signing or restamping what it carries. `PushSignedRecord`
is the canonical byte string that makes both halves computable; it uses the
spec's fixed-width fields rather than this codebase's usual QUIC varints,
which look identical locally and are wrong on the wire.

The part that costs real machinery is revocation. A removal does not merely
delete: it leaves a tombstone at its own `(owner_ts, digest)` stamp, and that
stamp has to be durable. Any current member can re-emit a revoked but still
validly-signed record in a fresh kind 448 at any later epoch, so its carrying
epoch is unbounded and no retained-message window can bound it. The stored
stamp is the only thing that recognises such a record as stale, which is why
`MarmotPushStateStore` exists and why Amethyst backs it with a file.

Everything here is advisory end to end. A bad entry, an unverifiable
signature, a stale list — each drops on its own and none of it may reach the
validity of the kind:445 that carried it. The decoders return what they could
read instead of throwing, and the coordinator catches at its boundary, so a
surprise cannot escape into ingest.

Not wired: announcing a token of our own. That needs Amethyst's own
notification-server public key, which is a deployment decision rather than
something the protocol discovers — a server can only wake the app whose push
credentials it holds. Until it exists this client participates correctly in
other members' routing and announces nothing.

MDK's `wn` exposes no push commands, so the harness cannot drive this against
the reference. Coverage is the spec's published removal fixture, byte-layout
assertions written independently of the encoder, and the ordering and
tombstone rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 20:00:35 +00:00
Claude 2711b36584 fix: apply -term exclusions and the reply/media pseudo-kinds on Android, and chip them
`-scam` parsed into excludeTerms, serialized back out, and then did nothing at
all on Android. NIP-50 has no negation operator, so an exclusion can only ever
be applied to the results — and SearchResultFilter, which does exactly that,
was called from desktop and from nowhere else. The same held for the
`kind:reply` and `kind:media` pseudo-kinds, which describe a shape of an
event's tags rather than anything a relay indexes; they now matter more,
because the new `kind:` picker offers both by name.

SearchResultFilter gains a per-event `matches` predicate so a caller holding
its own list type can apply the same rules without going through `filter`,
which is what the Android search screen needs — it works in Notes, not Events.
`filter` is rewritten in terms of it, so desktop keeps the behaviour it had.

Quoted phrases needed no fix: the parser keeps the quotes in the text, they
travel to the relay's NIP-50 `search` as typed, and locally
EventSearchMatcher already reads a quoted span as one term rather than two.

Both are now chips as well. These two are drawn only — the tokenizer lifts
them out for the renderer and hands the raw text straight back to the pass
that already reads them, so what the query means is untouched (QueryParserTest
and QuerySerializerTest pass unchanged). An exclusion draws struck through in
the error colour rather than a tint, because it is the one token whose effect
a reader can misread as its opposite; a phrase drops its quotes, since the
chip itself is what says the words travel together.

`-#bitcoin` is deliberately left to the hashtag splitter: claiming it here
would quietly turn "the bitcoin hashtag" into "not the word #bitcoin", which
is a decision about the language rather than about how it is drawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 19:38:49 +00:00
Claude f7580c7f88 feat(marmot): show edits and system rows in the Android chat
Two app payload kinds already round-tripped through the protocol layer and
then stopped at the feed: a kind:1009 edit was indexed but nothing drew it,
and a kind:1210 system row was filtered out of the message list entirely.

Edits reuse the overlay rail the other chat protocols already use.
`latestMarmotEdit()` picks the winner off a message's own `edits` children,
and it is deliberately read-side: the transport cannot stop a member from
sending a well-formed 1009 that names someone else's message, so the reader
is the one that has to check the author matches. Ties on the same second
resolve by event id, otherwise two devices of one account could leave two
readers rendering different text for the same message forever, with neither
of them wrong. `RenderConcordEditedNote` was already exactly the renderer
this needs, so it loses the protocol from its name and gains a Marmot caller.

System rows needed somewhere to go. `ChatFeedRowRenderer` is a hook the feed
consults per item: a renderer claims a note, or the ordinary bubble draws it.
That keeps a Marmot-shaped row out of the generic chat feed, which serves
four other protocols. `MarmotSystemRowRenderer` reads the row's structured
fields rather than its `text`, so the caption is localized here instead of
being whatever string the sender happened to compose; `text` stays as the
fallback for a row whose fields we cannot read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 19:21:00 +00:00
Claude 8ce8d57998 feat: offer the kind vocabulary as a picker under a half-written kind:
`from:`, `since:` and `group:` each open a picker as they are typed; `kind:`
did not, so the only way to write one was to already know the alias by heart.
That was tolerable while `kind:` was invisible in the field. Now that it pills
— and now that screens seed it — a chip a reader cannot discover how to type
is the wrong half of the feature.

The kind picker is the one this component can fill in itself. People and
groups are account-scoped, relay-backed questions that `commons` deliberately
refuses to answer, which is why they arrive as caller-supplied rows; "which
kinds are there" is a constant in KindRegistry. So every caller of
TokenizedSearchField — Android's search screen and the desktop spotlight —
gets the picker with no wiring at all.

A bare `kind:` offers the whole vocabulary, because a reader who typed the
prefix and stopped is asking what the options are. A partial offers what it
could still become, prefix-matched rather than substring-matched so the list
stays a completion instead of a search. Each row says the kinds it will
actually ask a relay for — the difference between `kind:video` (four kinds)
and `kind:21` (one) is worth seeing before picking — and a pseudo-kind says it
filters on results instead, because it asks a relay for nothing.

Unlike `group:`, the picker stands down on an exact match: the vocabulary is
closed, so `kind:article` really is finished, where `group:gen` is both a
plausible id and a prefix of `general`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 19:07:55 +00:00
Claude 95947cc337 feat: seed the search box with the filter of the screen it was opened from
A screen is already a filter — the articles feed is a kind window, a profile
is one author, a location channel is one geohash — and none of that survived
tapping search: the box opened empty and the reader retyped what the screen
already knew.

Every search button now hands over a SearchQuery, serialized into the route
and seeded straight into the field. It is ordinary field text from there on,
so it draws as chips through the path anything typed takes, and a backspace
drops it — rather than a base filter held beside the box that the reader
cannot reach.

Three things had to be true first, and were not:

- `kind:`, `lang:` and `domain:` were never tokenized. They were read only by
  QueryParser's second pass over the leftover text, so they filtered but drew
  as plain words. They are tokens now, and pill like the rest. An alias the
  registry cannot resolve, a language that is not a code and a domain that is
  not a hostname stay text, so a chip still cannot claim a filter that is not
  sent.

- `query.kinds` never reached the REQ on Android: searchPostsByText always
  fanned out over the three fallback kind groups, and the local cache scan
  asked for every kind. A `kind:` chip promised a narrowing that stopped at
  the field. Desktop already honoured it; both paths now do.

- QuerySerializer named kinds one at a time, so the two-kind `channel` alias
  wrote itself twice. KindRegistry.tokenize matches alias groups whole and in
  input order, and refuses to widen: 30312+30313 is `kind:nest`, but 30312
  alone stays `kind:30312` rather than borrowing the larger `live`.

Seeded screens: profile (from:), articles/pictures/highlights/nests/nsites/
emoji/badges/public chats/napplets/git repos (their kind window), the hashtag
and geohash feeds and the geohash chat (#tag, geo:), relay group chats
(group:, not on DMs), and notifications (to:me). Where a feed has a top-nav
list spinner, what that spinner narrowed to seeds too — but only when the
token language can say it. A follow set seeds nothing rather than some of its
authors: spelling one out fills the box with chips, and truncating it would
quietly seed a narrower query than the feed the reader was looking at.

Profile, hashtag, geohash and relay-group chat gain a search action; they had
none before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWTxEzzvD3mKkkgE4N7H66
2026-09-09 18:54:12 +00:00
Claude 1794ed5249 fix: make the sync benchmark opt-in; stop advertising the QUIC preview path
Two unrelated things that both amount to not paying for something nobody
asked for.

**`MirrorSyncThroughputTest` is a benchmark, so it now opts in.** It
preloaded a million events and pulled them over a real WebSocket on every
ordinary test run: 4,584 s of `:geode:test`'s 4,636 s — 98.9% of the
module's test time for one test that asserts nothing about correctness and
reported `skipped` at the end anyway. Every other benchmark in the module
is already gated this way (`perf.LoadBenchmark`). It now bails before
building anything, and enables on `-DrunLoadBenchmark=true` OR on any of
its own sizing properties, so every invocation its kdoc documents still
runs it — naming a size is itself the opt-in. Measured after: the test
takes 5 ms, the module takes 64.8 s, and `-DsyncN=2000` still prints a
throughput number.

**The agent text stream QUIC path is kept but no longer advertised, and
nothing starts it.** Nothing in the deployed network publishes those
previews. So:

- `SUPPORTED_COMPONENTS` drops `0x8006` and the leaf capabilities drop
  `0xF2D1`/`0xF2D2`/`0xF2D4`. A capability is a standing promise to every
  peer that reads our KeyPackage, and one for a path nobody exercises
  costs something and buys nothing. The captured reference KeyPackage in
  our own conformance vector does not advertise `0x8006` either.
- The Android chat screen no longer builds a stream watcher and dials the
  brokers a kind:1200 advertises. That was a UDP connection attempt to a
  third-party endpoint on every feed change, on behalf of a feature with
  nothing to show — a service we start, not a capability we hold.

The implementation stays and stays tested: `:marmotQuic`, the codecs,
`amy marmot stream`, the direct path, the certificate pinning and the
interop tests are all untouched. The module README records the posture and
the exact way back.

Three tests asserted the old advertisement and were reworked rather than
deleted. The role-enforcement gate is still covered — the tests now build
leaves that explicitly carry the roles, which is the better shape anyway,
since a test that exercised the gate through OUR default was really
asserting the default and stopped testing the gate the moment it changed.
A new test pins the new default: our KeyPackage carries no role and is
therefore refused by a group requiring one. That refusal is the deliberate
cost, so it is asserted rather than discovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 18:29:36 +00:00
Claude 9e1120fc07 test(marmot): run the reference implementation's own fixtures
Our Marmot tests agreed with nobody but themselves. They were written from
the same reading of the spec as the code they test, so a parser that read
a tag differently from every other client would pass all of them.

MDK ships fixtures built for exactly this. `fixtures/encrypted-media/
imeta-v2.json` says so in its own description: "Shared by marmot-app,
marmot-uniffi, and wn-cli tests so every layer agrees on validation
verdicts and exact wire round-trips." We are another layer and were not
using it. Same for the byte-level component vectors under
`cgka-conformance-simulator/vectors/byte-fixtures/`, whose manifest marks
31 of its 41 artifacts `"status": "portable"`.

Copied verbatim and wired to our codecs:

- **10 imeta v2 cases** — 5 golden, 5 rejections. The rejections are the
  half that matters: a merely lenient parser passes every golden case and
  still cannot be interoperated with, because it accepts tags a conformant
  sender never emits and then renders media another client refuses. The
  fixture also distinguishes an absent hint from a present-but-empty one,
  which is a real wire distinction we now assert rather than assume.
- **10 imeta v1 cases as NEGATIVE cases.** `0x8008` is frozen and "MUST
  NOT be reinterpreted as v2"; the two share enough field layout that a
  parser keying only on fields would read one as the other and derive a
  file key under the wrong scheme. Every v1 case now has to bounce off the
  v2 parser, including the ones v1 itself calls valid.
- **3 nostr-routing byte fixtures.** These are the first tests we have
  that pin a component's wire bytes against another implementation instead
  of against our own encoder — a round trip proves we can read what we
  wrote, which is a different and much weaker claim. The invalid fixture
  is the sharp one: a decoder that deduplicated the relay list rather than
  refusing it would hold bytes no peer agrees with.

All 8 tests pass unmodified, so this is coverage rather than a fix — but
it is coverage that can now fail for a reason our own tests never could.

The fixtures carry a README with their provenance and a refresh command,
because a copied artifact drifts silently. It also records what is still
missing: the 19 portable scenario vectors need a runner that drives our
client through a scripted trace and projects state per
`foundation/conformance.md`, and that is where the convergence and
crash/restart coverage lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 17:43:25 +00:00
Claude 9e41858c8f test(marmot): interop coverage for avatars, edits, deletions and media
Six new harness tests against MDK, all green in one clean run:

  20 avatar-url amy->wn   21 avatar-url wn->amy
  22 edit amy->wn         23 deletion amy->wn
  24 media-v2 amy->wn     25 media-v2 wn->amy

The media pair needed a blob store, so the harness now runs a loopback
Blossom server of its own (`blossom-server.py`, PUT /upload + GET
/<sha256>). It holds nothing but ciphertext — the file key comes from each
group's MLS exporter — so a download that hashes back to the original
bytes is proof both implementations derived the same key. That is the
whole point of tests 24 and 25, and they pass in both directions.

Test 20 sends a URL that is deliberately NOT normalized
(`https://Example.COM:443/a/./avatars/../pic.png`) and asserts both what we
store and what wn reads back. Normalization is the wire format for this
component — a decoder rejects bytes that differ from its own serialization
— so a disagreement here is a group the other side cannot read at all,
not a cosmetic difference.

Tests 22 and 23 assert what the protocol actually says rather than what a
renderer happens to do. For the edit that means a well-formed kind:1009
reaching wn (one `e` tag naming the target, the replacement as its body,
the right author) plus our own reader applying the overlay — MDK's storage
deliberately leaves the original row's body alone and lets the client
compute the chain, so asserting on painted text would be testing its TUI.
For the deletion it means the `deleted` flag on wn's materialized
timeline, which is its user-visible truth.

Two harness bugs surfaced while getting there, both of the kind that make
a failure unreadable rather than wrong. `run.env` — where tests hand each
other group ids — survived the per-run state wipe, so a `--tests` subset
that consumed without re-creating failed on "not a member" for a group id
from a previous run. And `amy_json` read `$?` inside `if ! cmd`, where it
is the status of the negation, so every failure reported "exit 0".

Push (kind 451) has no cross-implementation test here and cannot: the
owner proof is an UNPUBLISHED event handed to a push service, the
reference CLI exposes no command that emits one, and the harness runs no
push service. There is nothing for two implementations to disagree about
on the wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 17:05:13 +00:00
Claude fad7347ca9 feat(marmot): encrypted media v2 end to end
`marmot.group.encrypted-media.v2` (0x800b) had a codec, a policy type and
a key schedule in quartz, and not one caller. The group could not carry a
media policy, nothing derived a v2 key, and Android still sent MIP-04
attachments — which peers on the current profile no longer create.

The policy is now group state: read through `MarmotGroupState`, committed
through `setEncryptedMediaPolicy`, and honoured by the sender. Both of its
lists are ordered on purpose — `default_blob_endpoints` order IS the
upload/fetch fallback priority — so reordering one is changing where the
group uploads, not reformatting it.

Sending and receiving run through the group's own MLS exporter, so the
blob store sees ciphertext and its hash and is storage rather than a party
to the conversation. `amy marmot media` drives the whole loop
(policy/set-policy/send/get), and Android picks v2 per group: a group
carrying `0x800b` gets a v2 reference, one that does not keeps MIP-04, and
the frozen v1 policy at `0x8008` is never reinterpreted as v2.

The URL normalizer is now shared rather than approximated twice. The media
policy says its base URLs use the same WHATWG normalization the avatar
component defines, and it was instead checking a hand-rolled structural
subset — which rejected `https://host//double/`. That URL is normalized:
WHATWG keeps the path as a segment list and only `.` and `..` are special,
which the reference `url` crate confirms. So the old check refused group
state the reference implementation produces, the exact failure the shared
normalizer exists to prevent. `http` is permitted for a blob store and not
for an avatar, because the components differ there and a self-hosted store
on a private network is real.

Two smaller things the reading turned up. `0x8007` was implemented but
missing from the advertised supported-component list, so a group requiring
an avatar URL would have refused our KeyPackage. And the note claiming we
do not advertise the agent-stream send/fanout capabilities has been false
since the sequence store landed.

Epoch 0 deliberately does NOT carry the media policy: the reference
implementation's own epoch-0 GroupContext does not, and adding it unasked
would both diverge from that and require every joiner to advertise
`0x800b` before it could be added. The conformance vector caught that when
the default went the other way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 16:49:39 +00:00
Claude 7e98978d21 feat(marmot): message edits and derived group system rows
Kinds 1009 and 1210 had codecs in quartz and not one reference anywhere
above them. So an edit from a peer landed in the log and changed nothing,
and a group state change produced no row at all.

**Edits (1009).** An edit is not chat: it replaces the target's text in
place, must never render as its own row, and must not advance an unread
count — a reader caught up with the original is caught up with the edit.
Two rules decide which text a reader sees, and both are enforced at READ
time because a sender cannot be trusted to have applied them: only the
account that wrote a message may replace it (by account, not by leaf, so
a second device of the same account still qualifies), and the latest edit
wins with the event id breaking a tie. The tie-break is not decoration —
two devices of one account can stamp the same second, and without it two
readers would render different text for the same message forever.

**System rows (1210).** These are synthesized locally from canonical group
state, never received: a row derived from an MLS-authenticated commit
cannot be forged by one member, and every client that applied the same
commits derives the same rows. The derivation is a pure diff of two
snapshots with a fixed output order, because two clients ordering rows by
hash iteration would show the same history differently.

Diffing against a PERSISTED baseline rather than against the pre-commit
state in hand is what makes it safe: it is idempotent, it survives a
restart mid-transition, and it cannot write a second caption for a change
it already described. The first look at a group establishes the baseline
and writes nothing — a joiner announcing every existing member as newly
added would be a timeline full of events that did not happen.

Two bugs the tests found rather than the reading did. The row content's
quote escape was written as the literal text `ESC"`, which produced a
content string no decoder could read back — and since a 1210's content is
inside the app event's id preimage, a peer would have rejected the row
outright rather than merely mis-rendering it. And the snapshot read the
group name off the current profile's components alone, so every legacy
MIP-01 group — which keeps its name inside `0xF2EE` — looked permanently
nameless and no rename ever derived a row.

Android now also keeps 1009, 1200 and 1210 out of the chat feed. None of
them is a chat bubble: an edit would show the same sentence twice, a
stream anchor has an empty body and would render blank, and a system row
would render as a bubble of JSON. Rendering 1210 in its own style, and
applying the edit overlay in the bubble, still needs a renderer that
knows about them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 16:13:13 +00:00
Vitor PamplonaandGitHub 8e3c604d45 Merge pull request #4068 from vitorpamplona/claude/remove-kind-5-62-search-7t2emu
Add DeletionEvent and RequestToVanishEvent to CacheSearch filter
2026-09-09 11:58:23 -04:00
Claude 3a5c25ad36 feat(marmot): the direct QUIC path, and a pin instead of blind trust
Two halves of the same gap in `transports/quic.md`.

**The direct path.** The binding has a second delivery mode we had not
built: the sender dials the receiver, opens one unidirectional stream and
writes records with no control envelope at all. It is deliberately smaller
than the broker path — the dialed endpoint is already the one receiver, so
there is no room to claim — and it negotiates its own ALPN so an
incompatible change to either mode cannot reach the other. Note the
inverted direction: here the RECEIVER listens and the SENDER dials, which
is also why v1 gives it no start-payload discovery and it is only usable
against an endpoint known out of band.

Only the sending half is here. `:quic` is a client stack with no server
role, so this module can dial a direct receiver but cannot be one; that is
recorded in the README rather than half-built.

**The pin.** Preview endpoints and brokers are commonly self-signed and
the binding expects that, saying a client MAY pin by exact DER or SHA-256
fingerprint. What we had instead was `PermissiveCertificateValidator` on
the CLI path, which is not a weaker trust model — it is none, and anyone
on the path can be the broker. `PinnedCertificateValidator` replaces the
chain and the hostname check and nothing else: the peer still has to sign
the TLS transcript with the pinned certificate's private key, so copying a
public certificate off the wire buys an attacker nothing. `amy marmot
stream send|watch` takes `--pin-sha256`, and `--insecure` still exists for
a throwaway local broker but now has to be asked for by name.

Both are verified against the reference implementation, which is the only
thing that can tell an ALPN string, a stream direction, an absent envelope
and a frame prefix from an implementation agreeing with itself: our direct
sender against `wn stream receive`, and the pin — accepted and refused —
against a real handshake with `marmot-quic-broker`.

One thing that only showed up under a real handshake: a certificate the
validator refuses closes the connection before it is established, and the
transport was reporting that as PeerClosed. A caller walking a candidate
list reads that kind to decide what to do next, and "never connected" is
not "the peer hung up on us", so it is classified on the connection's
actual status now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 15:30:50 +00:00
Claude fa8b41e7bc feat(marmot): group avatars behind a plain https link
`marmot.group.avatar-url.v1` (0x8007) is the lightweight alternative to
the encrypted Blossom blob: a link, two opaque render hints, no key
material. We had neither the codec nor a carrier for it, so a group MDK
gave a URL avatar rendered as if it had none.

The hard part is not the struct, it is that the URL is canonical state.
The spec makes normalization a producer-side encoding rule and requires a
decoder to re-run the WHATWG parse-and-serialize and REJECT bytes that
differ — never repair them, because two members repairing differently
hold different bytes for the same group. So `MarmotHttpsUrl` is a WHATWG
serializer, not a validator with a regex: lowercased scheme and host, the
default port dropped, dot-segments resolved against a segment list (a
trailing slash is a final empty segment, which is also why `/a/.` keeps
one), percent-encoding normalized with existing triplets left verbatim.
The vectors in the test come from the Rust `url` crate the reference
implementation uses, so the two agree byte for byte. Non-ASCII hosts are
refused rather than guessed at: IDNA is not implemented here, and a wrong
punycode encoding would be worse than a refusal.

Contact safety is deliberately a separate function. A URL can be perfectly
valid group state and still be somewhere this client refuses to go, and
the spec is explicit that the fetch decision "MUST NOT affect component or
commit validity" — so the SSRF check lives at the renderer, where an
unsafe destination falls back to the Blossom image instead of erroring.

Clearing writes the canonical empty state rather than removing the
component. Removal is not a free substitute: a component MUST NOT be
removed while `app_components` still lists it as required, so a remove is
only legal in the same Commit that stops requiring it — and the empty
state is what the reference implementation writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 15:17:03 +00:00
Claude 7f497b781b fix: hide deletion (kind 5) and vanish (kind 62) events from search results
Both kinds land in LocalCache as regular Notes, so the search screen's
scan over `cache.notes` could match their `content` (a kind-5 deletion
reason, a kind-62 vanish reason) or an id prefix and render them as
result cards, which they can't meaningfully be. Add them to
`excludeNoteEventFromSearchResults` alongside the other non-renderable
kinds (reposts, reactions, zaps, metadata, contact lists, app data).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeT6g4wLvn2sJofnntvsZ1
2026-09-09 14:51:27 +00:00
Vitor PamplonaandGitHub 224ad016cb Merge pull request #4067 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-09 10:47:23 -04:00
vitorpamplonaandgithub-actions[bot] 3321d306da chore: sync Crowdin translations and seed translator npub placeholders 2026-09-09 14:45:57 +00:00
Vitor PamplonaandGitHub 8bf30c90ae Merge pull request #4066 from vitorpamplona/claude/amethyst-search-field-vespa-xyao1i
feat(search): run the search box's tokens as real filters, locally and on relays
2026-09-09 10:42:58 -04:00
Vitor PamplonaandClaude Opus 5 7fc7a3e059 test(napplet): read the shell resource where a resource can be read
`shellTemplateKeepsTheOpaqueIframeAndSourceChecks` sat in `commonTest`, so it ran
on both targets: green under `:commons:jvmTest`, and red under
`:commons:testAndroidHostTest` with

    MissingResourceException: ... files/napplet/shell.html.
    Android context is not initialized.

`NappletWebContract.shellHtml()` goes through Compose Resources, whose Android
reader needs an initialised `Context`. A host unit test has none and commons does
not use Robolectric, so the same assertion was red or green depending only on
which target happened to run it. That is the worst shape for a test: it fails on
a developer's machine having passed in whatever ran last, and it kept the whole
module red regardless of the change under review.

Moved to `jvmTest` as `NappletShellResourceTest`. Nothing about it is
platform-specific -- `shell.html` is one shared file, so reading it once on the
JVM checks its contents everywhere. What is genuinely not covered is the Android
resource plumbing, which needs a `Context`; that wants an instrumented test, and
the KDoc says so rather than leaving the gap silent.

The other two contract tests never touch a resource and stay in `commonTest`,
still running on both targets. `:commons:testAndroidHostTest` now passes 1530
tests with no failures, and `:commons:jvmTest` 1913 -- the shell assertion among
them, so it is still enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-09 10:39:42 -04:00
Vitor PamplonaandClaude Opus 5 96133560ff test(search): stop the date tests asserting UTC when the parser means local
Six tests failed on any machine outside UTC, by exactly that machine's offset:

  QueryParserTest     sinceDate, sinceDateYearOnly, combinedQuery
  QuerySerializerTest sinceDate, untilDate, combinedQuery

They hardcoded 1735689600L -- 2025-01-01 midnight *UTC* -- but the parser now
returns the reader's own midnight, and the serializer formats through
`DateUtils.localDay`. In America/New_York the parser answered 1735707600 and the
serializer rendered "2024-12-31", both correct. The production code is right; the
assertions were left behind when the bound became local.

The same file already had it right further down, where the newer cases assert
`LocalClock.startOfDay(SearchDate(...))` under a comment explaining that a bound
is the reader's midnight and not UTC's. These six now say the same thing, so they
state the intended behaviour rather than the behaviour of a UTC build machine.

`timestampToDate2025` keeps its UTC literal deliberately: `timestampToDate` is
plain epoch arithmetic in `DateUtils`, not the local formatter `serialize` uses.
Changing it would have broken a passing test -- the two paths in `QuerySerializer`
genuinely differ.

Verified green in America/New_York, UTC, Pacific/Auckland and Asia/Kolkata --
37 + 19 tests, zero failures in each. Full quartz, commons and amethyst suites
pass locally, which they did not before this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-09 09:39:45 -04:00
Claude f24cb95902 feat(marmot): advertise every agent-stream role and render previews on Android
Our leaf advertised `receive` only, which was honest while nothing could
originate a stream and is not any more. It now advertises `receive`,
`send` and `fanout` — the same set MDK puts on every KeyPackage it
publishes — so a group requiring any of them admits us. A capability is a
claim about what the client supports, not a duty to stream: a member that
never originates one is a quiet member, not a broken one.

The role-gate test asserted the old behaviour, so it was testing our own
capability set rather than the gate. It now builds a deliberately reduced
leaf and checks that THAT is refused, which keeps working whatever we go
on to advertise; a second case pins the new fact that we fill every role
the profile defines.

`MarmotAgentStreamWatcher` in commons follows the newest kind:1200 in a
group, folds the QUIC records behind it under the receive discipline, and
settles the result against the durable kind:9 — confirmed when the
transcript agrees, dropped when it does not, because a disagreement means
we rendered something the publisher never sent. Resolving the final
message lives here rather than in the UI so a front end only has to say
"the feed moved", and so the whole decision is testable without a UI.

Android shows it as an italic, labelled row between the transcript and
the composer. Provisional content has to look provisional: preview text
is not durable history until the final message vouches for it, and the
row disappears the moment it is confirmed or contradicted. Progress and
status records render as separate chrome, never as answer text, which is
what the spec requires of them.

Every failure path ends as "no preview" rather than as a broken group: no
stream, no broker candidate, an unreachable broker, an unimplemented
stream type, or a platform with no QUIC at all. `receive` explicitly does
not require the QUIC data plane.

The desktop app has no Marmot chat screen to render into — its chat UI is
NIP-17 only — so there is nothing to wire there yet. The watcher is in
commons and speaks only quartz's transport port, so desktop inherits it
the day that screen exists.

Interop unchanged at 19 of 19 with all three roles advertised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 13:32:30 +00:00
Claude 497859dc76 feat(search): give the desktop spotlight its people picker
Typing `from:` or `to:` in the spotlight opened nothing — the field was
constructed without `people`, `displayName` or `onPeopleQuery`, so the
picker had no rows to offer and a key had to be pasted in as a raw npub.

It now builds the same UserSearchEngine the full search screen does, with
the same DesktopRelayUserSearchDelegate behind it: cache hits first, then
whatever the account's search relays answer with. The built-in `people`
list is used rather than the `peoplePicker` slot, because that path is the
keyboard-walkable one — arrows walk the rows and Enter takes the highlighted
one, which is what a spotlight is driven by. `displayName` also gives
finished key chips a name instead of a short npub.

The input row switches from centre to top alignment. The picker opens below
the field inside the same column, so centring would drag the leading search
icon halfway down it; with no picker up the two are the same height and the
resting layout is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-09 13:22:13 +00:00
Claude 7269bae11c fix(search): give the iOS calendar its real week start, and test its day math
firstDayOfWeek() returned a hardcoded ISO Monday on iOS. It now reads
NSCalendar.currentCalendar.firstWeekday, converting Foundation's 1-based
Sunday index to this API's 0-based one, so a reader in the Americas, East
Asia or the Middle East gets the column order their region actually uses.
Read per call, so a settings change lands without a restart.

The rest of the day arithmetic moves to ZoneMath in commonMain, behind a
ZoneOffsets fun interface, leaving the iOS actual holding only Foundation
lookups. The reason is that an Apple source set compiles off a Mac but
never runs off one, so anything expressed in platform calls is unexercised
until someone opens Xcode.

Moving it also fixed a bug the old two-pass had. Resolving local midnight
is a fixed point, not a subtraction, and the two probes oscillate when
midnight does not exist at all — a zone that springs forward *at* midnight,
which Santiago, Havana and Tehran have all done. The old code took the
earlier probe, an hour before the day starts; it now detects the
non-convergence and takes the later one, the first instant of the day that
exists. The repeated-midnight case already converged on the earlier of the
two, matching java.time's atStartOfDay, and still does.

ZoneMathTest covers a fixed-offset zone, a 23-hour spring-forward day, a
25-hour fall-back day, both midnight edges, dayAt either side of local
midnight, and a full-year sweep asserting every day starts exactly where
the one before it ended.

Verified: 7/7 ZoneMathTest, 12/12 SearchCalendarTest, and both
compileKotlinIosArm64 and compileKotlinIosSimulatorArm64 build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-09 13:11:08 +00:00
Claude 1f72b3c080 fix(search): stop typing an author filter from navigating away mid-query
`directRouteResolver` auto-navigated on any nip19 code found *anywhere* in the
box. `Nip19Parser` extracts a code from inside a string, which is right for
spotting a mention in a note and wrong for reading a search field — so once
`from:`/`to:` existed, typing `from:npub1…` looked exactly like pasting a profile
and threw the reader onto that person's page halfway through writing a filter.

The profile/post auto-navigation is gone. Invite links stay: they cannot be typed
by accident (both need a URL carrying `/invite/`, which no token produces) and
they open a redeem flow rather than a profile or a post.

Deleting it alone would have regressed pasting, though — the old code read
"navigate on hit without displaying results", so the jump *was* the only path to a
pasted code, and a paste of somebody the cache had never seen would have surfaced
nothing at all. A whole-input code now resolves into the results list instead:
consumed into the cache and offered as an ordinary user or note row. Still one tap
away, but the reader chooses when to leave.

`wholeInputNip19` in commons is the rule that separates the two cases — a paste is
the only thing in the box, a token always carries its prefix. Desktop's
`parseSearchInput` had the same extract-from-anywhere behaviour and now goes
through it too, so a `from:` filter no longer produces a direct-lookup row for
somebody the reader was merely filtering by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-09 12:52:15 +00:00
Claude 7014d88b2e feat(marmot): wire agent text streams end to end, both directions
The transport was there and the codecs were there; nothing joined them to
a group. Now `amy marmot stream start|send|watch|finish` does: a hidden
kind:1200 anchors the stream over MLS, records ride raw QUIC through a
broker, and a kind:9 closes it carrying the transcript a receiver checks
its own fold against.

`AgentTextStreamSubscriber` is the receive discipline the binding spells
out, and it matters because a preview that quietly diverges is worse than
no preview: `seq` accepted at most once and never folded out of order, a
replayed record (which a broker WILL send from the start of its replay
window on reconnect) discarded silently and never stream-fatal, a gap
that cannot be backfilled marking the preview unverifiable because the
transcript hash can no longer complete. Only TextDelta and Checkpoint
reach the answer text — progress and status are chrome the spec forbids
from ever reaching notifications, indexes or automation input.

The start payload also grew the tags it was missing: `stream-type`,
`final-kind` and the optional `parent`, plus the rule that a final
payload whose kind disagrees with `final-kind` is ignored.

Verified in both directions against MDK in harness tests 18 and 19: `wn
stream verify` confirms our transcript from our own kind:1200 + kind:9,
and our subscriber folds MDK's stream to a transcript hash identical to
the one `wn stream send` computed. That equality is the key schedule, key
context, AEAD, framing and transcript construction all agreeing with an
implementation that is not ours. 19 of 19 harness tests pass, twice.

Two defects only that exercise could have found:

  - The epoch belongs to the stream, not to the clock. The record key
    context binds mls_epoch, and both sides were resolving it as "the
    group's current epoch" at each command, so a commit landing between
    the start and the send put them on different keys and produced an
    empty preview. The epoch that DELIVERED the kind:1200 is the
    stream's; it is persisted with the message now and read back by
    publisher and receiver alike.

  - close() dropped the tail of a stream. enqueue only fills the send
    buffer, so tearing the connection down before the driver flushed it
    lost records silently — the publisher had already counted them. QUIC
    ACKs a FIN only once everything ahead of it arrived, so finish() now
    waits for finAcked. This is exactly why the test passed alone and
    failed inside a full run.

The `send` (0xF2D2) and `fanout` (0xF2D4) role capabilities stay
unadvertised: a role is a promise to the whole group, and only the CLI
originates a stream so far.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 12:43:44 +00:00
Claude 4cfe644eb8 feat(quartz): give every searchable kind the allocation-free read path
`forEachIndexableField` shipped on seven kinds — the ones local search happened to
scan — leaving 126 on the default that falls back to `indexableContent()`. All 133
implementors now have one.

The conversion is worth nothing if it changes what a store indexes: the SQLite and
filesystem stores index through `indexableContent()`, and
`references/searchable-kinds.md`, which external engines mirror at version bumps,
is a transcription of it. A silent change there ships stale results downstream and
needs a `reindexFullTextSearch()` on every existing database.

So the output of all 126 kinds was recorded BEFORE any edit and diffed after every
wave. `indexable-content.golden` keeps that recording as a permanent guard, and it
is byte-identical to the pre-change baseline. Two more tests join the golden one:
every kind's visitor rejoins to exactly its `indexableContent()`, and every kind
actually stops when the visitor says stop — a class that ignored the stop signal
would still agree about its content, so nothing else would catch it.

Most of the work was mechanical and done by script — 31 content-only, 65
`listOfNotNull(…).joinToString`, 14 list-append and nullable-single shapes, 10
JSON-backed `?.let { … }`. The last six were hand-written because their joined
form is not a plain list: `LabelEvent` filters empty strings, so the visitor skips
rather than visits them; the two poll kinds append `content` unconditionally, so
the visitor must offer it even when empty or the separator it produced goes
missing; and `NIP90TextGenerationRequestEvent` filters by input type.

`indexableSeparator()` is added to the interface for the handful of metadata-ish
kinds that join with a space rather than a newline. Nothing on the read path uses
it — it exists so the agreement test can rejoin what the visitor hands over and
prove it reproduces the indexed string exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-09 12:35:27 +00:00
Claude 3b2b00e385 Merge remote-tracking branch 'origin/main' into claude/amethyst-search-field-vespa-xyao1i 2026-09-09 11:49:57 +00:00
Claude 6d43d1941a feat(marmot): agent text stream previews over the repo's own QUIC stack
The transport binding was the last piece missing from agent text streams,
and it did not need a new QUIC implementation — `:quic` already had the
whole hard part. What it needed was entering that stack at the right
layer.

`nestsClient` speaks WebTransport: HTTP/3, Extended CONNECT, QPACK,
SETTINGS. Its `WebTransportSession` abstraction begins above all of that.
Marmot's binding is raw QUIC — it negotiates its own ALPN
(`marmot.quic_broker.v1` / `marmot.quic_stream.v1`) and writes frames
straight onto QUIC streams, with no HTTP/3 anywhere in it. So this reuses
everything below that line — connection, TLS 1.3, ALPN negotiation,
stream multiplexing, loss recovery, the UDP socket — and none of the
WebTransport wrapper.

The codecs are in quartz next to the rest of agent-text-stream, because
they are pure bytes and that is where the conformance risk lives: the
control envelope with its literal 21-byte protocol string and its
trailing-byte rejection, the uint32 frame codec with both the broker's
blind cap and a policy-aware one, `quic://` candidate parsing down to
ignoring everything after the authority and never sending an IP literal
as SNI, and the first record's stream id pinning the rest.

`:marmotQuic` is the connection layer, mirroring how `:nestsClient` sits
on `:quic`. A publisher claims a room on a uni stream, a subscriber reads
the fan-out on a bidi one, and an endpoint that does not take our ALPN is
reported as unusable so the caller moves to the next candidate rather
than waiting on records that never come.

Verified against MDK's own `marmot-quic-broker`, which is the only way to
know a wire format is right: our publisher and subscriber meet inside the
reference broker, the records come back, open under the group-derived key
and fold to the publisher's transcript hash, and the broker keeps rooms
apart. Opt in with -DmarmotQuicBroker=host:port; the cases skip visibly
without one, so an ordinary test run needs no broker.

Still not wired at the app layer: nothing yet mints a kind-1200 start,
picks a candidate, or renders a live preview, so `send` (0xF2D2) and
`fanout` (0xF2D4) stay unadvertised. The direct path has no start-payload
candidate format in v1 and is unimplemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 11:29:21 +00:00
Claude 7e187e39df fix(relay): a hang-up before the OK is not the relay's answer
A relay that drops the socket between our EVENT frame and its OK has told
us nothing: the event may be stored, or it may not. We recorded that as
the relay's verdict and stopped waiting — even though the pool's own
outbox still owed the relay the event and would have flushed it on
reconnect. Nobody was listening by then, so the publish came back failed
and the event landed on the relay a second later anyway.

publishAndCollectResults now holds a transport failure as provisional for
one retry: it drops the tentative verdict, ignores the echoes of the same
drop, clears the backoff and dials, and takes the OK when the pool's
flush earns it. Everything happens inside the caller's existing timeout,
so no publish waits longer than it used to, and a relay that keeps
hanging up is still reported as a transport failure rather than a
success. transportRetries = 0 restores the old behaviour exactly.

Found through the Marmot interop harness, which was losing a message
every few runs to a loopback relay that was healthy a second later. The
same race is every publish that meets a network change on mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 09:49:25 +00:00
Claude 7cfa0758d9 feat(marmot): sequence discipline for agent text stream publishing
Publishing preview records was blocked on one thing: the spec requires a
publisher to never restart or reuse a `seq` for one key context —
"including after reconnect, retry, process restart, or daemon resume" —
and to stop publishing entirely when it cannot prove which value is next.
That is a cryptographic requirement, not bookkeeping. `seq` is XORed into
the ChaCha20-Poly1305 record nonce and the key is fixed for the stream,
so a repeated `seq` repeats a (key, nonce) pair, which leaks the XOR of
the two plaintexts and forfeits authentication for every record under
that key.

`AgentTextStreamPublisher` owns that discipline. Sequence values are
reserved in the durable store before a record is handed out, in windows
so a chatty stream is not a write per record — a crash then skips the
unused tail of a window rather than replaying it, and a gap is something
the transport binding already handles while a repeat is a nonce
collision. `resume` returns null, rather than starting over at 1, both
when nothing was retained and when the stream was finished or aborted;
the caller falls back to the authoritative final kind:9 and a later
preview needs a fresh stream id. A frame the group's
`max_plaintext_frame_len` refuses is rejected before it claims a value,
so a refused frame does not leave every receiver with a permanent gap
where no record ever existed.

Only an in-memory sequence store ships here. `send` (0xF2D2) and
`fanout` (0xF2D4) stay unadvertised: there is no QUIC data plane behind
them yet, and claiming a role we cannot serve is worse for a group than
not claiming it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 09:11:40 +00:00
Claude fa5e14e605 fix(marmot): mint a rotated KeyPackage the way the first one was minted
MIP-00 replaces a KeyPackage as soon as a Welcome consumes it, so rotation
is not a rare path — it runs right after the first group we are ever
invited to. It went through the legacy generator, so from that moment on
the only KeyPackage on relays for us was a MIP-era one with no account
identity proof. A current-profile peer refuses that outright ("member
KeyPackage identity or profile is invalid") and keeps inviting from
whatever stale copy it still has cached, so an account went silently
uninvitable one join after it was set up.

Rotation and first publication now share one mint path, so a replacement
cannot land on a different profile than the KeyPackage it replaces.

Harness, two tests that were reporting our bugs as theirs and one that
was reporting the reverse:

  - Test 16 asked `wn keys publish` to rotate. That verb is the
    idempotent retry of the durable stable-slot replacement — with
    nothing pending it republishes the same event id, so there is no
    rotation to observe. `wn keys rotate` is the one that mints.

  - Test 13 swallowed `wn keys check`'s output, so "no prior KP for A"
    read as a missing fixture when it was MDK refusing what we had
    published. The raw answer goes to the log now and the message says
    what actually happened.

  - Test 09 polled `reactions.by_emoji`, which belongs to the
    materialized timeline; `wn messages list` reads the raw app-event
    log, where a reaction is its own kind:7 entry with an "e" tag naming
    the anchor. The reaction had been arriving and being stored
    correctly the whole time.

The MDK 0.9.20 interop harness is now green, 17 of 17, twice in a row
from a clean state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 08:50:12 +00:00
Claude e493ecbe6e fix(marmot): a last-resort KeyPackage is not single-use
We publish every KeyPackage marked last resort, and then threw away its
private keys the moment one Welcome consumed it. Those two things cannot
both be true. OpenMLS is explicit about the contract — on the Welcome
path it deletes the consumed bundle only `if !key_package.last_resort()`
and otherwise logs "KeyPackage has a last-resort marker, not deleting" —
and MDK leans on it: it marks all of its own KeyPackages last resort,
caches the peer KeyPackage it resolved in its user directory, and invites
from that same cached copy every time after. So the first invite
addressed to us worked and every one after it died on "No matching
KeyPackageBundle", which is four of the interop harness's failures.

Consumed bundles now stay reachable when the KeyPackage says they may
be, bounded on both axes: at most eight of them, and never past the
KeyPackage's own not_after. That retention is the entire forward-secrecy
cost of the last-resort marker, and it is a cost we already accepted by
publishing the marker.

Two things had to be right for it to work at all:

  - `isLastResort()` has to read both carriers. The MIP-era profile sets
    MLS extension type 0x000A on the KeyPackage; the current profile —
    the one we actually publish — carries a `last_resort_key_package`
    component inside the KeyPackage-level app_data_dictionary. Reading
    only the first made every KeyPackage we ship look single-use.

  - The Welcome lookup has to trust the MLS refs over the Nostr "e" tag.
    RFC 9420 addresses each EncryptedGroupSecrets to a KeyPackageRef and
    the joiner takes the first it holds keys for; the "e" tag is a
    routing hint an inviter can get wrong, and MDK gets it wrong exactly
    here — it stamps the event id of its cached copy, which is stale the
    moment we rotate. Refs first, tag as fallback.

The restore path also stopped throwing the whole snapshot away when the
eventId→slot index is empty. It drops the active bundles that index made
unreachable, and keeps the retained bundles (keyed by event id, always
reachable) and the named slot d-tags (a fresh d-tag would republish into
a new addressable slot and orphan the old one).

Harness: reset A's amy home and the relay database at the start of every
run, keeping the relay build. wnd already wiped B's and C's data dirs,
but A's store and the relay's events survived, and the leftovers are not
inert — a KeyPackage from an earlier run is still on the relay to be
invited with, and old kind:445 events still arrive undecryptable. That
drift alone accounted for tests 03 and 08. `--reuse-state` opts out and
`--tests "..."` runs a subset.

Interop: 10 → 14 of 17 passing. 05, 12, 14 and 15 (every "A never
received invite") now pass, as do 03 and 08.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 08:20:37 +00:00
Claude 6fb364394d fix(marmot): a joiner needs its Welcome path secret, and a tree it can accept
Two MLS defects that between them meant nobody but a group's creator could
successfully invite anyone into a group with three or more members.

**GroupSecrets.path_secret was neither sent nor used.** A Commit's
UpdatePath refreshes every node from the committer's leaf to the root, and
a refreshed node has no unmerged leaves — so a member added by that same
Commit is MERGED at their common ancestor the instant it joins. RFC 9420
§12.4.1 also excludes newly-added leaves from the copath resolution, so
that ancestor's secret is not in the UpdatePath at all. The only place it
exists is `GroupSecrets.path_secret` (§12.4.3.1), and we sent `null` and
ignored what MDK sent us. The joiner therefore held nothing above its own
leaf, and the first commit from the other side of the tree — which
resolves the joiner's sibling subtree to that merged ancestor — was
undecryptable. MDK reported it exactly:

    UpdatePath at common ancestor carries no ciphertext for us
    (my_leaf=1, my_node=2, resolution=[1], held_path_nodes=[])

**Parent-hash validation was stricter than RFC 9420 and rejected valid
trees.** We re-derived every COMMIT-source leaf's `parent_hash` top-down
from the CURRENT tree and demanded a match. §7.9.2 makes a much weaker
claim, per PARENT node: for each non-blank parent P, exactly one of its
subtrees must contain a node whose `parent_hash` equals `ParentHash(P,
other_subtree)`. The strong version cannot hold — a later commit
refreshes ancestors and a later Add changes the tree's shape, so a leaf
set two epochs ago legitimately no longer re-derives — and it rejected the
GroupInfo of every group whose inviter was not the last committer.

Both halves need the RFC's `original_sibling_tree_hash`: the sibling
subtree's tree hash with the parent's `unmerged_leaves` removed. Those are
exactly the leaves added since the parent was populated, so excluding them
reconstructs the tree as the parent's author saw it. `RatchetTree` gains
`originalTreeHash` and `resolutionExcluding` for it.

The regression test builds the case that no two-party test can reach: a
member added by its own sibling, so its ancestor is merged on arrival,
followed by a commit from the other subtree. It fails on either half of
this change alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 07:03:18 +00:00
Claude b29decc620 fix(marmot): decrypt an UpdatePath at the node we actually hold a key for
RFC 9420 §7.6 does not say the committer encrypts the path secret to your
leaf. It says the committer encrypts one secret per node in the copath
RESOLUTION, and each member decrypts at whichever of those nodes it holds
a private key for. A merged subtree resolves to its PARENT, so from three
members on the ciphertext meant for us stops naming our leaf at all.

We kept only our own leaf key and looked ourselves up by leaf index. That
worked for two members and failed for three, which is exactly why it
survived every test we own: two-party tests never produce the case. MDK
did, on its first commit after a three-member Add:

    UpdatePath at common ancestor carries no ciphertext for us
    (my_leaf=1, my_node=2, resolution=[1], encrypted_path_secrets=1)

Node 1 was the parent we had held a key for since the commit that merged
us, and we had thrown it away.

`MlsGroup` now keeps the private halves for its whole direct path — filled
on our own commits from the path secrets we mint, and on inbound commits
from the secret we recover at the common ancestor — and scans the
resolution for a key it holds rather than assuming its leaf. Candidates
are tried in order rather than committing to the first: an Add or Remove
renumbers nodes, and a stale key fails the AEAD instead of producing a
wrong secret, so trying the next one is exact. `MlsGroupState` v3
persists them; losing them to a restart would make the same group stop
decrypting on relaunch with nothing tying the failure to the restart.

Also completes the durability and lifecycle work:

  - `PublishOutcome.UNKNOWN`. A non-confirmed publish used to discard its
    obligation and return the group to Stable, which let a REPLACEMENT
    commit be prepared for the same epoch. "No OK arrived" is not "no
    peer took it" — a timeout or a dropped connection leaves it unknown,
    and a second commit for an epoch a peer already holds is precisely
    the fork this gate exists to prevent. The obligation now stays
    durable and the group stays held. `FAILED` remains for the case where
    retrying is genuinely impossible.
  - Publish obligations are durable in the CLI and on Android, and
    `restoreAll` republishes each unresolved one VERBATIM. The same bytes,
    not a fresh commit: a peer that already has the event deduplicates it.
  - `Disbanded` and `Unrecoverable` now gate rather than describe.
    Convergence terminalizes a group when an applied commit's lifecycle
    component says so, and marks one unrecoverable when a selected branch
    cannot be rebuilt from retained material — the one thing a client must
    not do there is keep its own losing branch and call that settled.
    Outbound work and inbound application are both refused in those
    states, and `MarmotManager.lifecycle` now merges the publish gate's
    view with convergence's instead of reading only the former (which
    reported Stable for a disbanded group).
  - Durable ingest markers. A relay `since` cursor cannot skip a backdated
    event, and NIP-59 wraps are backdated by up to two days on purpose, so
    every wrap in that band was unwrapped, decrypted and re-decided on
    every single sync — forever. Only outcomes that cannot change are
    marked: a Welcome we joined from, and one naming a KeyPackage whose
    private half we never held. An event that is merely undecryptable
    right now is not marked, because a kind-445 under a future epoch
    becomes readable the moment its commit arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 06:19:10 +00:00
Claude 894a999689 fix(marmot): read and write group metadata through the profile in use
Everything that touched a group's name, admins, relays or avatar went
through `groupMetadata`, which decodes ONLY the legacy `0xF2EE`
extension. It returns null for every current-profile group, so:

  - `amy marmot group show` / `list` / `admins` printed a blank name and
    an empty admin set;
  - `amy marmot await group --name X` never matched, which is what test
    03 was actually reporting — we had joined MDK's group, we just could
    not find it by name;
  - the Android chatroom showed no name, no admins, no relays, no avatar;
  - `group rename` / `promote` / `demote` / `set-image` BOOTSTRAPPED a
    legacy blob and committed it into a current-profile group, so the
    rename appeared to work locally while every peer kept the old name.

Adds `MarmotManager.groupView` (read) and `setGroupProfile` /
`setGroupAdmins` / `setGroupImage` (write). The setters dispatch on the
group's actual profile: a current-profile group takes an
`app_data_update` naming ONE component, so a concurrent admin-policy
change does not lose its work to a rename; a legacy group has no such
separation and its single extension is rewritten whole. Every call site
in the CLI, the Android app and the relay-subscription manager now goes
through them.

`createMarmotGroup` creates a CURRENT-profile group. The profile is
decided once, at creation, and cannot be migrated later — a legacy
group's existing leaves have no account identity proofs to add — so a
group made the old way is joinable only by other legacy clients. The
name and description are passed in at creation because the routing
component has to exist from epoch 0 anyway: it carries the
`nostr_group_id` every kind-445 event in the group is addressed to.

`MarmotGroupIconUpload` gains `mediaType`. MIP-01's image blob never
carried one; the current profile's `0x8002` component requires it on a
present image and binds it into the AEAD's AAD, so a receiver cannot be
steered into decoding the plaintext as a different type than the
uploader meant.

Also: a gift wrap is no longer broadcast to the public default relay set
when the recipient advertised an inbox we declined to reach. "Advertised
nothing" and "advertised only local-network relays" are different facts,
and treating the second as the first sends someone's invite to a relay
set they never chose — the opposite of what the filter is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 04:55:33 +00:00
Claude 4a89d29f6c feat(marmot): implement agent-text-stream over QUIC (0x8006), receive role
MDK requires component `0x8006` in every group it creates, and its
`required_member_roles` mask names MLS leaf capabilities each member must
advertise. A client that carries neither is refused at the Add — which is
why `wn groups create "Interop-03" <us>` failed outright, taking five
interop scenarios with it. The `0xf2d1`/`0xf2d2`/`0xf2d4` extensions MDK
advertises are exactly those role capabilities.

Implements the component and the record layer it gates:

  - `AgentTextStreamQuicPolicyV1` — the 12-byte component state, decoded
    strictly (a short, long, or out-of-range payload is rejected, never
    defaulted: these bytes sit in signed group state and a guessed role
    mask admits a member the group refuses).
  - `AgentTextStreamRecordV1` — the wire record, with the QUIC varint
    length prefixes the Marmot binary profile uses. Unknown record types
    decode fine on purpose; a newer advisory record must not tear down an
    otherwise valid preview stream.
  - `AgentTextStreamCrypto` — HKDF-Expand-only key and nonce derivation
    over the full key context, `nonce_base XOR uint96_be(seq)`, and the
    record AAD. `seq` is in both the nonce and the AAD, so a replayed or
    reordered record fails to open rather than being noticed afterwards.
  - `AgentTextStreamTranscriptV1` — the rolling hash the final kind-9
    chat publishes, so a receiver can tell that it saw exactly the stream
    the publisher sent.
  - `AgentTextStreamStart` / `AgentTextStreamFinal` — the kind-1200 anchor
    tags and the kind-9 closing tags.

We advertise the RECEIVE role only, and the group state validator refuses
a Welcome whose policy requires a role we do not advertise. Publishing
would need durable per-stream sequence state to avoid reusing an AEAD
nonce across a restart, and we have none — advertising `send` without it
would be a claim we cannot keep.

Also fixes three places that read only the legacy `0xF2EE` extension and
therefore did nothing at all on a current-profile group:

  - The Welcome's `nostr_group_id`, which is the `h` tag every kind-445
    event carries. Without it a joiner cannot subscribe, so MDK's welcome
    decrypted and was then discarded — "GroupContext is missing the
    NostrGroupData extension" — for a routing id that was present the
    whole time in the `0x8004` component.
  - The admin gate on GroupContextExtensions changes, which read
    `adminsConfigured` as false for every current-profile group and so
    skipped the check instead of failing closed.
  - Disappearing-message expiration, which silently never applied.

And `syncMetadataTo`, which left every current-profile group with a blank
name, no admins, no relays and no avatar in the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 04:14:43 +00:00
Claude cfd44c2108 test(marmot): read the JSON shapes MDK 0.9.x actually emits
The harness was parsing a wire format `wn` no longer speaks, and every
mismatch failed silently as "nothing arrived".

`wn --json groups invites` now answers
`{"ok":true,"result":{"account_id":…,"invites":[…],"npub":…}}`. The
harness iterated `(.result // .) | .[]?`, which walks that object's three
VALUES — two strings and an array — so `jq_group_id` matched nothing on
every poll and the pending count printed 3 forever. Test 02 reported "B
never received invite" for welcomes that had in fact arrived.

Named collections moved the same way (`members`, `admins`, `messages`),
per-entry id fields were renamed (`member_id`, `admin_id`,
`message_id`), the decrypted body is `plaintext` rather than `content`,
the group display name lives in the profile component
(`.group.profile.name`), and `keys check` nests its event id under
`.key_package`.

Adds two helpers so this is fixed in one place rather than at 20 call
sites: `jq_list <name>` peels the envelope and names the collection, and
`jq_member_ids` reads whichever id field the collection uses. `jq_group_id`
now searches `.result.group_id`, `.result.group.group_id` and the bare
element shape, keeping the older serde encodings so a run against an
older `wn` still reports a real mismatch instead of an empty string.

Also fixes test 05, which fed wn's MLS group id to `amy marmot message
send`; amy indexes by the MIP-01 nostr_group_id, which it only learns
from its own `await group`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 03:21:11 +00:00
Claude 36c73fd654 fix(marmot): a Commit must not rebuild our own leaf from defaults
A Commit replaces the committer's leaf through the UpdatePath. It is the
same member with new key material, so everything the leaf says about the
member has to survive — but `buildLeafNode` was called without either
`capabilities` or `leafExtensions`, so it fell through to the legacy
defaults every time.

That cost us every invitation we have ever sent to MDK. A current-profile
leaf carries `marmot.member.account-identity-proof.v2` inside an
`app_data_dictionary` LEAF extension, and no proposal can put a leaf
extension back, so our very first Commit silently demoted the group
creator out of the current profile. The rebuilt leaf also stopped
advertising the `app_data_dictionary` extension (0x0006) and the
`app_data_update` proposal (0x0008) that the group's own
`required_capabilities` demands, which makes the resulting tree fail
RFC 9420 §7.3 leaf validation for every receiver. MDK reported
`PublicGroupError(LeafNodeValidation(UnsupportedExtensions))` and dropped
the Welcome minted by that same commit — the invitee simply never saw an
invite, with nothing logged on either side.

The same omission was in `proposeSigningKeyRotation`, where an Update
proposal replaces our leaf for forward secrecy, and in `externalJoin`,
which had no way to express a current-profile joiner leaf at all.

Verified against MDK 0.9.20 on the interop harness: before, the invitee's
pending-invite list stayed empty; after, our group arrives with its
routing, profile and admin policy intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 03:01:17 +00:00
Vitor PamplonaandGitHub f97b6f3b5e Merge pull request #4065 from vitorpamplona/claude/event-kind-34259-parsers-jocu9w
docs(amethyst): plan for kind-34259 entity ratings in the Home feed
2026-09-08 22:00:55 -04:00
Claude 56a7e74a04 fix(publications): audit fixes — name the contents rows, honour hardbreaks, cache the conversion
Findings from auditing the branch against the real corpus (897 kind-30040
indexes and 820 kind-30041 sections pulled off thecitadel, theforest, damus and
nos.lol), not against the sample events.

**Every table of contents read "Untitled section".** NKBIP-01 lets an index
carry each entry's title in the `a` tag's slot 2, and parsing it is why that
slot is disambiguated from a relay hint and a nesting level. Across those 897
indexes, not one of 11,176 entries uses it: every publisher writes
`["a", coord, relay, <event id>]`. So a 240-chapter book showed 240 rows of
"Untitled section" until 240 separate fetches came back. The coordinate's own
`d` is a slug of the title (`pg59225-chapter-1-introduction`) and
`humanizeIdentifier` already existed, so rows now name themselves immediately
and for free. The section's real title still wins the moment it lands.

**`[%hardbreaks]` was dropped, running verses together.** Asciidoctor honours
it and the reference client renders a break per line; the converter treated it
as an ordinary block-attribute line and CommonMark then joined every line into
one paragraph. The KJV chapters are published exactly this way. Both the block
option and the `:hardbreaks:` document attribute now emit CommonMark hard
breaks, with the block form lapsing at the end of its own block.

**The thread view composed the whole contents at once.** It passed
`Int.MAX_VALUE`, and that table is a plain `Column` inside one lazy-list item,
so every row composed together and each opened its own event observation and
relay subscription. 234 of the 897 indexes list more than 12 sections, 21 list
more than 100, the largest 240. Capped at 60, which still shows almost every
publication whole; past it the count is shown and the pager walks the rest.

**The AsciiDoc conversion ran uncached on the composition thread.** `remember`
is dropped when a card leaves a lazy list, so scrolling back re-converted.
Measured over the 638 non-empty sections (1.9 MB of prose) on a desktop JVM:
p50 114us, p95 1.4ms, worst 7.2ms for a 13.5 KB section — and a phone is
several times slower than that. Now behind an LRU keyed by event id, trimmed
with the other caches under memory pressure.

Also checked and found clean: the converter is stable on the whole corpus (no
unbalanced emphasis introduced, no output corruption) and has no catastrophic
backtracking — every adversarial input (2–10 KB of `*`, `_`, unterminated
macros and fences) converts in under 1ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-09 01:57:19 +00:00
Claude a044f4c38b fix(marmot): create current-profile groups from the CLI, and fix the relay sets that broke them
`amy marmot group create` now builds a current-profile group; `--legacy`
keeps the MIP-era path for reproducing groups already on disk. The
difference is what a group REQUIRES of a joining leaf — the account
identity proof, which every conformant peer's KeyPackage carries, versus
`0xF2EE`, which none of them do. With this, `group add` accepts an MDK
KeyPackage where it previously failed the capability gate outright.

Making that work surfaced two bugs that would each have been fatal on
their own.

`MarmotManager.groupRelays` read only the legacy `0xF2EE` extension, but
a current-profile group routes through `NostrRoutingV1` (`0x8004`). Every
current-profile group therefore had an EMPTY recipient scope — so under
publish-before-apply no commit could ever be acknowledged, and no such
group could ever advance past epoch 0. It now reads both.

And the local-network relay filter turned up a third time, in NIP-65:
`parseReadNorm`/`parseWriteNorm` dropped loopback entries, so our own
outbox and inbox read as empty while `nip65` showed the relay. Everything
then published to the default relay set — which is why commits and
Welcomes were going to public relays instead of the harness's loopback.
Same split as before: filtered for someone else's list (it is
attacker-supplied input, and it is what exempts a relay from Tor),
unfiltered for reading back our own.

Two conformance fixes came with it. The Welcome rumor carried an
`encoding` tag, which the binding forbids outright for every event shape
it defines — a receiver that switched decoders on one could be steered
into a different parse of the same bytes. And we implemented
encrypted-media v2 last commit but never advertised `0x800b` in the leaf,
so a group requiring it would refuse us; the advertised list now carries
it, with a note that an id belongs there only when the component is
actually implemented.

`MarmotMipBehaviorTest` asserted the MIP-era rule that a rumor MUST carry
an encoding tag. The adopted binding reverses it, so the test now asserts
the current rule.

Interop test 01 still passes. Test 02 (invite MDK into our group) reaches
MDK but is not yet ingested; a control run confirms MDK->MDK invites work
in this harness, so the remaining defect is ours, in the Welcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-09 00:27:59 +00:00
Claude cab660d0fc Merge remote-tracking branch 'origin/claude/event-kind-34259-parsers-jocu9w' into claude/event-kind-34259-parsers-jocu9w 2026-09-09 00:08:17 +00:00
Claude 92883066e6 Merge remote-tracking branch 'origin/main' into claude/event-kind-34259-parsers-jocu9w 2026-09-09 00:08:12 +00:00
Vitor PamplonaandClaude Opus 5 a735498a6f perf(relay): build the bulk filters in one pass
These run on every filter rebuild, several times a second, and the previous shape
was a chain of operators that each allocated an intermediate: `partition`,
`groupBy`, `map`, `distinct`, `sorted`, `chunked`, `map`, then a list concat.
`groupBy { kind to pubKeyHex }` allocated a `Pair` and a boxed `Int` per address
on top of that.

Now one pass into nested maps, sorting in place, appending straight to the output
list. `distinct()` went entirely: the input is a `Set<Address>` and `Address` is a
data class, so two entries in one (kind, author) group cannot share a `d` -- it
was dead work every call. The two group maps are allocated only if that kind of
address turns up, and `forEachChunk` hands the whole list through untouched when
it already fits, which is nearly always, instead of `chunked` building an outer
list and a copy.

Measured, same machine, JVM, against the previous implementation:

  8 addresses (typical), 200k reps : 368.8ms -> 75.8ms   4.9x
  240 addresses (a book),  5k reps : 132.5ms -> 52.0ms   2.5x

The small case gains most, which is the one that runs constantly -- 1.84us to
0.38us per call. Behaviour is unchanged: same 7 tests, and the 240-section index
still renders all its rows on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 19:41:57 -04:00
Vitor PamplonaandClaude Opus 5 7f056f152d docs(relay): correct why the bulk filters are chunked
The previous commit justified the chunk with "relays cap the values they will
accept in a tag filter". No relay was observed doing that, and no such cap
exists: neither NIP-11 nor `RelayLimits` has a values-per-filter field, and
`LimitsPolicy` never checks one. That was inferred from the `chunked(100)` the
other bulk builders use, and stated as fact. It was not.

The mechanism that does exist is the limit clamp. `LimitsPolicy.applyLimits`
rewrites a filter's `limit` down to the relay's `maxLimit`, so the addressable
group -- which sets `limit` to its coordinate count -- would ask for 240 and be
answered with `maxLimit` of them, the remainder missing silently. Chunking keeps
each `limit` under the clamp. That is the real reason, and it is now what the
comments say.

The chunk size itself stays convention, and says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 19:32:59 -04:00
Claude 46845d9058 fix(marmot): frame published KeyPackages as MLSMessage — interop test 01 passes
Test 01 (bidirectional KeyPackage discovery with MDK) now passes. It was
failing on a four-byte omission.

`foundation/key-packages.md`: "a transport publication is unambiguously
the framed MLSMessage, not a bare KeyPackage struct." We published bare
bytes. That is not a cosmetic difference — a reader expecting the envelope
reads a bare KeyPackage's leading 0x0001 0x0001 as version 1, wire format
1 (mls_public_message), parses on as a PublicMessage, and dies several
fields later on a byte that means nothing. MDK reported
`UnknownValue(112)`, a number that appears nowhere in a KeyPackage, which
is why this was invisible from our side: our own decoder round-tripped our
own bytes perfectly. Only a second implementation could find it.

The KeyPackageRef stays over the INNER KeyPackage, as RFC 9420
MakeKeyPackageRef defines — framing the ref too would make our `i` tag
disagree with everyone else's. Bare bytes are still accepted on read:
every KeyPackage we published before this is bare and still inside its
lifetime, and refusing them would leave our own users unable to invite
each other until all of them rotated.

With framing fixed MDK got one field further and rejected the next thing:
"mls_extensions tag does not exactly match decoded KeyPackage metadata".
Those id-list tags duplicate metadata already inside the KeyPackage, and
we were writing them by hand — so adding one leaf capability (the legacy
0xF2EE, added so our KeyPackages stay addable to existing groups) silently
invalidated every KeyPackage we published. They are now derived from the
KeyPackage itself and cannot drift. `app_components` lists the Marmot
registry ids only; the upstream MLS-extensions component ids below 0x8000
are not app components being advertised.

The harness needed one more fix: MDK 0.9.x reports a found KeyPackage
under `result.key_package`, and the harness probed two older shapes, so a
successful check read as a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 23:11:02 +00:00
Vitor PamplonaandGitHub 6207a357e0 Merge pull request #4064 from vitorpamplona/claude/event-rendering-links-5f1vsq
Make Birdex species names clickable links to Wikidata
2026-09-08 18:32:15 -04:00
Claude d35b4fecf3 fix(birdstar): guard species links and bound how many render at once
Audit of the previous commit turned up three problems.

A stray `i` tag was being read as the next species' reference. Birdstar
writes `i`/`n` as adjacent pairs, but the pairing only tracked "the last
`i` seen", so an unrelated reference earlier in the event — a NIP-73
identity for the event itself, say — attached to whatever name came next
and pointed its link at the wrong page. Adjacency is now required.

Clicking a link could take the app down. Compose's own handling of
LinkAnnotation.Url swallows IllegalArgumentException and nothing else,
while DesktopUriHandler builds a java.net.URI (URISyntaxException on a
malformed URL) and can throw UnsupportedOperationException or IOException
besides — and these URLs come from a stranger's event. The click now goes
through the same runCatching that ClickableUrl has always used.

Expanding no longer dumps the whole list into one feed row: Compose lays
out an interaction region per link, so "+N more" reveals a page of 30 at a
time and a full expansion no longer costs hundreds of composables in a
single tap. "Show less" folds it back.

Also: speciesCount() counted `n` tags by building the list of names and
taking its size; it now counts them in place. Rebuilding the shown sublist
on every recomposition is gone with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUzRwL1XtMad5xZepJHock
2026-09-08 22:23:20 +00:00
Claude 55918dc64c chore(strings): retire birdex_species_preview_more in every locale
The preview suffix was replaced by a standalone "+N more" toggle, so the
old key is gone from the default catalog. Retiring it in the same push
keeps lint from reporting 55 [ExtraTranslation] orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUzRwL1XtMad5xZepJHock
2026-09-08 22:06:14 +00:00
Claude 4a99275a2c feat(birdstar): link each Birdex species to its Wikidata entry
A Birdex (kind 12473) carries its species list as positional `i`/`n` tag
pairs — a Wikidata entity URL next to each scientific name — but the card
rendered the names as one flat, comma-joined string, so the references in
the event were unreachable from the UI.

BirdexEvent gains species(), which pairs each `n` with the `i` next to it
(either side, since the pairing is positional) and keeps only references a
UI can open, matching what BirdDetectionEvent already does for a single
sighting; both now share that http(s) check.

The card renders those pairs as italic scientific names — every one with a
reference a link to its Wikidata entry — and, because a life list grows
without bound, keeps showing 6 up front behind a "+N more" toggle that
expands the rest in place instead of truncating them away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUzRwL1XtMad5xZepJHock
2026-09-08 22:05:27 +00:00
Claude 0f264dd767 fix(marmot): run the interop harness, and fix what it found
The harness had never actually been run. It now builds MDK 0.9.20 —
against the same OpenMLS fork rev our vector generator pins — boots a
local relay, brings up both wnd daemons and amy, and executes all 17
scenarios. They all still fail, downstream of MDK not finding A's
KeyPackage, but "it runs" is the difference between having an interop
signal and not having one.

Four environment blockers stood between preflight and a run: protoc is
now a build prerequisite; MDK 0.9.x needs WN_ALLOW_LOOPBACK_RELAYS=1
before it will accept a ws:// loopback relay at all; it refuses to create
its socket unless the parent directory is 0700; and `wn --json whoami`
moved to {"ok":true,"result":{"accounts":[…]}}, which the harness's
extractor probed right past.

Two defects in our own code came out of it.

`amy relay add` reported success from its DECISION to write rather than
from the store's answer, so a rejected or no-op write printed
`added: yes` and the caller only discovered otherwise much later.

The more consequential one: we read our OWN relay lists back through the
local-network filter. That filter is correct for someone else's list — it
is attacker-supplied input, and it is also what exempts a relay from Tor
— but applied to a list we published ourselves it made a deliberately
configured local relay look like no configuration at all. The publisher
then fell back to a default set, and the harness sent A's KeyPackage to
five PUBLIC relays instead of its loopback, which is the exact opposite
of what a "nothing leaves the machine" harness is for. `allRelays()` now
exists for reading back our own lists; the KeyPackage publish goes only
to the configured relay.

Test 01 is still blocked on a narrower puzzle: the kind-10051 list
persists under `relay key-package set` but not under `relay add`, while
kind 10050 works through the identical code path. That is a storage/CLI
thread, not a protocol one, and it needs its own pass.

Separately, and not a bug on either side: MDK accepts ws:// only for a
loopback host while quartz strips exactly those hosts from relay lists.
No address satisfies both, so a loopback-relay harness cannot pass until
one side moves — and changing a Tor-adjacent privacy guard is a
maintainer call, not one to make in passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 22:01:42 +00:00
Claude 88fddce324 feat(marmot): publish current-profile KeyPackages and groups
The Quartz half of the current profile was ready and tested; nothing in
the app layer called it. KeyPackage publishing now defaults to the
current profile, which is the half that decides whether anyone running
the adopted spec can invite us at all — a leaf without the 0x8009
account identity proof is simply not addable to a current-profile group.

`createCurrentProfileGroup` builds the group through
`CurrentProfileGroupFactory` and hands it to the manager via `adoptGroup`.
Group creation is the one place a group cannot be built through the
manager: a leaf's identity proof covers its OWN signature key, so the
keypair has to be generated and authorized by the account signer before
the leaf exists.

Switching the KeyPackage path surfaced the mirror image of the bug this
whole effort started from. A current-profile leaf advertised only the
draft app_data_dictionary extension, and a legacy group REQUIRES 0xF2EE —
so our new KeyPackages were un-addable to every group that already
exists. Capabilities say "this client can handle it", not "this group
uses it", so the leaf now advertises both. Advertising more than a group
requires is always fine; advertising less is what gets a leaf rejected.
The reference-shape test now states that rule rather than asserting
byte-equality with MDK's leaf.

The current-profile KeyPackage event also carries neither a `relays` nor
an `encoding` tag, both per transports/nostr.md: a KeyPackage is fetched
from the account's own inbox relay set, so repeating the relays would be
a second drifting source of truth, and the binding forbids `encoding`
outright because a receiver that switched decoders on one could be
steered into a different parse of the same bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 21:07:39 +00:00
Vitor PamplonaandClaude Opus 5 69b986ef67 perf(relay): ask for many addresses in one filter instead of one filter each
`filterMissingAddressables` built a filter per address. Opening a 240-section
publication therefore put 240 filters -- each `kinds`+`authors`+one `#d`, `limit`
1 -- into a single REQ per relay, when every one of them shares a kind and an
author and differs only in `d`.

Grouping by (kind, author) turns that into one filter carrying every `d`. The
`limit` becomes the number of coordinates asked for rather than 1: these are
replaceable, so a relay holds exactly one event per coordinate and that count is
the ceiling.

Both bulk builders now chunk at 100 values, the size the other bulk filter
builders in this module already use. Relays cap the values they accept in a
filter, and a filter silently truncated loses its tail with no error to notice --
which is the failure this was reported as. The id path had the same exposure: it
already coalesced ids into one filter per relay, but unbounded.

Generic by construction: `filterMissingEventsForThread` calls these same two
functions, so threads get it without touching the thread assembler. Replaceables
with no `d` keep their own group, since kind and author alone address them.

Measured on the 240-section Aeschylus index, cold start each time: a black screen
for ~2 minutes at 120% CPU before, first paint at ~22s after. All 240 rows still
resolve to real titles, with zero "Untitled section" -- and those titles can only
come from fetched sections, since the index's `a` tags carry a relay hint in slot
2, which `fromAddressTag` correctly refuses to read as a title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 17:03:02 -04:00
Claude 1ff2bcc198 feat(marmot): add encrypted-media v2 and the kind-451 push owner proof
Two Stage 7 surfaces, both verified against fixtures the spec publishes
rather than against my own reading of it.

**encrypted-media v2 (component 0x800b).** Supersedes the frozen v1
policy at 0x8008, which must never be reinterpreted as v2. The unusual
rule here is that neither list is sorted: `default_blob_endpoints` order
IS the upload/fetch fallback priority, so sorting it — as nostr-routing
and admin-policy both do — would silently change which server a group
uploads to. Two policies differing only in order are different canonical
values, and the decoder preserves what the producer wrote.

Its field checks look excessive until you see why they exist.
`plaintext_sha256`, `m` and `filename` all feed both the key derivation
and the AEAD AAD, joined by single 0x00 bytes with no length prefixes.
That is unambiguous only because each field excludes 0x00 — fixed-width
hash, ASCII-token media type, filename profile forbidding U+0000. It is
also why a duplicate single-occurrence `imeta` field is rejected rather
than resolved: a first-wins decoder and a last-wins decoder would derive
different keys from the same authenticated tag, so one sender could hand
two conformant clients tags that decrypt to different content.

**Push owner proof (kind 451).** A BIP-340 signature over the id of an
exact, never-published Nostr event. The event id is a ready-made
canonical digest over the tuple that needs binding, and binding it is the
whole point: because the id covers group_id, server_pubkey, relay_hint,
the encrypted token and owner_ts, a member who merely RELAYS someone's
record cannot move it to another group, repoint it at a different
notification server, swap the token, or restamp it. A record's authority
comes from owner_sig and current membership, never from who carried it.

A current-profile group accepts only kind 451; a legacy group also
accepts the superseded kind-450 form so upgraded and un-upgraded members
can share a group. That split is a security boundary, not a courtesy —
in a group where every leaf already carries a 0x8009 identity proof,
accepting the weaker form would let anyone able to produce one bypass the
stronger binding.

Both fixtures reproduce exactly: the spec's published removal event id
and its owner_sig verify under our tag construction, which is what proves
tag order, arity and value formatting are right rather than merely
self-consistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 20:41:53 +00:00
Claude f2b27fd6da feat(marmot): send the canonical unsigned app-payload shape
Marmot app payloads are a Nostr event MINUS the signature, and we were
sending them WITH one. A conformant decoder rejects a payload carrying a
`sig` member at all, so every message we sent was refusable by any peer
following the adopted spec — and a signed inner event is a valid
standalone relay event, so one leaked plaintext could be republished
publicly as a signed statement by its author.

`MarmotAppEvent` is that shape, with the strict decoder the spec
requires. Each rejection closes a different hole: a `sig` member for the
reason above; an unknown top-level member, because two implementations
that disagree about what to ignore disagree about the id preimage; a
duplicate key, because "last one wins" and "first one wins" are both
defensible and yield different events from identical bytes; and a
mismatched id, because the id is what edits, history and dedup all
reference.

Duplicate-key detection needed its own scan. Every JSON library here
resolves duplicates before the caller sees them, so `MarmotJson` walks
the raw text tracking nesting depth and string boundaries — it has to be
right about exactly one thing, where a top-level key sits.

The id is unchanged by the switch. NIP-01 hashes
[0, pubkey, created_at, kind, tags, content], which never covered the
signature, so message identity survives and existing history still lines
up. The Android pipeline already treated inner events as unsigned rumors
with an empty sig and skipped verification, so the app layer needs no
change: the empty `sig` is re-added at the inbound boundary instead of
travelling on the wire.

Also adds the two Stage 7 kinds. Kind 1009 edits carry the deterministic
tie-break the spec implies but does not spell out — two devices of one
account can stamp the same second, and without it two readers would
render different text for the same message forever. Kind 1210 system rows
are synthesized from canonical state rather than received, which is what
makes them unforgeable by a single member.

Verified against the spec's published fixture: our canonical serialization
hashes to the exact event id the spec prints for its kind 1210 example.
That is the only check that distinguishes correct from merely
self-consistent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 20:27:06 +00:00
Claude 63c852c2f9 feat(marmot): enforce publish-before-apply and the outbound gates
A locally generated group-state change was becoming canonical the moment
it was prepared, before anything had been published. The spec forbids
that, and the reason is not bookkeeping: if the publish then fails, this
client holds an epoch no peer has, and every message it sends next is
undecryptable to the group.

Apply-then-undo would not have fixed it. Between the apply and the undo
there is a window in which we are already forked, and a crash inside that
window makes the fork permanent. So a local commit is now prepared on a
CLONE restored from the current state: the live group does not move, keeps
its pending proposals (which is exactly the "proposal stays available for
retry" rule on failure), and the pending state becomes canonical only via
installState once publication is acknowledged.

Acknowledged means what the spec says it means — at least one endpoint in
the recipient scope returning an accept, which over Nostr is OK true from
a relay. `MarmotPublisher` makes that explicit and the manager owns the
publish, because a caller handed bytes may or may not report back. Its
default refuses everything: a client that never configures a publisher
can read a group but never advance it, which is the safe direction to
fail. The recipient scope comes from the group's own relay list, so the
accept has to come from an endpoint the GROUP names.

The obligation record is durable before the publish, not after. The other
order leaves a crash window in which peers have accepted a commit this
client has no memory of preparing — and on restart it would generate a
replacement, forking itself at its own epoch.

Group creation keeps its exception: a one-member epoch-0 group has no peer
that failure to publish could fork, so its obligation is empty and
immediately satisfied. Departure keeps its own shape too — a SelfRemove is
a proposal, not a local commit, so it has no pending state, but it raises
the LEAVING outbound gate, and a gated group refuses new commits rather
than preparing epochs it has no standing to publish.

Also carries a quiet fork to its cutoff. Inbound traffic ticks convergence
opportunistically, but a group where the fork was the last thing to arrive
had nothing to settle it; the settler runs only while a pass is open, so a
quiet client still does no periodic work.

Two test premises of mine were wrong and the code was right: a sole admin
cannot SelfRemove without demoting first, and a payload for an unretained
branch is transport-deferred rather than rejected. Both tests now assert
the actual rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 20:06:25 +00:00
Claude b5506726d6 Merge remote-tracking branch 'origin/claude/event-kind-34259-parsers-jocu9w' into claude/event-kind-34259-parsers-jocu9w
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Library.kt
2026-09-08 19:40:30 +00:00
Claude eb1f3c85a3 feat(marmot): trial-decrypt app payloads on retained candidate branches
Convergence could score witnesses but nothing ever produced one, so the
witness steps of the branch comparison were dead code. This wires the
producer, and it turned out to need two layers rather than one.

The MLS layer is the obvious half: an app message that decrypts on no
canonical epoch is tried against the candidate states convergence
retains. That set is bounded by the rollback horizon, so a flood of
undecryptable ciphertext costs a bounded number of attempts rather than
an unbounded key search. Candidate states are now replayed when a
divergent commit is admitted, not only at resolution, because witnesses
have to accumulate DURING a pass to influence the selection that pass
makes.

The transport layer is the half that is easy to miss. Marmot's outer
ChaCha20 layer is keyed by a per-epoch exporter secret, so an event
published on a fork does not merely fail to decrypt — it does not peel
at all, and never reaches the MLS layer to be tried. Retained candidate
states now also contribute outer keys, derived on demand rather than
stored, so the release condition stays in one place: when the state goes,
the key goes with it.

A payload that decrypts on a candidate branch is NOT delivered — the
canonical state contradicts it — but it is not dropped either. It is
reported as living on a branch, and counted as a witness only if it
passes the same author check a delivered payload does. Decryption alone
is not a witness: without that check one member could mint many sender
identities and buy the witness quorum outright.

Canonical decryptions now witness too. The incumbent is rebuilt and
rescored at every resolution, so counting only divergent branches would
have let any fork win the witness steps unopposed.

Writing the tests corrected one of my own premises: a payload for a
branch we do not retain is `transport_deferred`, not a terminal error.
The commit that makes it readable may still arrive, and retaining that
branch is exactly the change of transport decryption context the spec
says must trigger a retry — so the test now asserts the deferral and
then the successful retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 19:32:36 +00:00
Claude 67bd37678f feat(library): render a learning resource's attached PDF instead of linking it
No — the card only showed the raw URL as a blue link. These events attach their
material through an `encoding:*` group (Blossom URL, MIME type, size, sha256)
and name it nowhere in the body, and most of the corpus attaches a PDF: a
worksheet, an instruction sheet, the thing the event is actually about.

The file now goes through the same classify-then-view path kind 1063 uses, so a
PDF gets `PdfPreviewCard`'s inline first page and `PdfViewerDialog` on tap, an
image gets its picture, and a webxdc bundle or an archive — anything no viewer
can show — gets `FileAttachmentCard` with its type and size rather than a URL to
squint at. The `encoding:sha256` is passed through as the content hash, which is
also what the viewer verifies its download against.

Also reads `datePublished`, a fourth spelling of the publication date: the
Caesar-Scheibe event that prompted this carries no `author` and no `published`,
so without it the byline came out empty. Its `image` stays the cover, distinct
from the attached material.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 19:29:10 +00:00
Vitor PamplonaandClaude Opus 5 6d1f679f45 feat(publications): give a chapter its book, and a way through the book
A section opened on its own said nothing about what it was a section *of*, and
offered no way to the next one -- so a book was readable only by returning to the
index and picking the next row by hand.

Sections carry the back reference already: `T` holds the index's bare `d` (59 of
the 60 live sections surveyed carry it; `c` is a second spelling the same
publisher emits). It is an identifier, not a coordinate, so `publicationAddress()`
reconstructs the index from it plus the section's own author -- an index and its
sections share one, and the alternative, scanning the cache for an index that
lists this section, costs a walk per chapter.

- A crumb above the title names the book and opens it. Deliberately a crumb and
  not `PublicationHeader`: a cover, blurb and 34-row contents on top of the
  chapter you just opened would bury it.
- A pager below the body moves to the neighbours in the index's own order. Drawn
  only when the index actually lists this section, since that listing is the only
  thing that defines an order. The ends stay blank rather than disabled -- there
  is no chapter before the first, and a greyed control invites a tap that cannot
  do anything.

Verified on device across Wuthering Heights: Chapter II shows "Wuthering Heights"
above its title and CHAPTER I / CHAPTER III below, tapping CHAPTER III lands on
it, and Chapter I correctly offers CHAPTER II with no previous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 15:20:08 -04:00
Claude 9effa7735d feat(marmot): resolve same-epoch forks by convergence, not by timestamp
`MarmotInboundProcessor` decided same-epoch races by the superseded MIP
rule: lowest outer `created_at`, then lowest Nostr event id. Both are
transport metadata the sender picks and MLS does not authenticate, so a
member could win every race by backdating. `CommitOrdering.kt` is deleted
and `MarmotConvergenceEngine` takes over, running the bounded pass, the
candidate graph and the six-step comparison end to end.

The wiring decision worth recording is that convergence does NOT hold
every commit for the quiescence window. A literal reading of the bounded
pass would tax the overwhelmingly common single-commit case with a second
of latency for nothing. It does not have to, because MLS is its own fork
detector: once a commit is applied, a competitor authored against the
same parent stops authenticating against the new tip but still
authenticates against the RETAINED parent. So linear commits apply
eagerly, the state each was applied to is retained, and a commit that
authenticates a retained state rather than the tip IS the fork — only
then does a pass open.

That does not change the answer, and the reason is which state resolution
treats as the base. It is the newest retained state a divergent commit
authenticates against, not the current tip, and the canonical commits
applied at or after it are replayed back into the graph. So the incumbent
is rebuilt as a branch and scored by the same rule as its challengers
instead of winning by having been applied first. Eager application only
decides which branch is provisionally displayed while the pass runs.

Supporting pieces: `MlsGroupManager.snapshot` takes a state without
touching storage, and `installState` is the rewind primitive — it pushes
the outgoing epoch's secrets into the retention window first, so messages
already sent on the abandoned branch still decrypt. `MarmotManager`
records locally-authored commits too: our own commit is half of any fork
we are party to, and without it a peer's competitor would look like an
unplaceable orphan and be deferred rather than compared.

The headline test builds a real same-epoch fork between two admins, feeds
two observers the same two commits in opposite orders, and asserts they
land on the same GroupContext with exactly one of them having rewound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 18:34:44 +00:00
Vitor PamplonaandClaude Opus 5 b8de178c91 feat(library): read the rest of a file record, and let it open
A 32176 is a manifest for a file split across Blossom servers, but the parser
stopped at five tags, so the card showed a title, a summary, a poster and a raw
byte count -- and a tap did nothing, because no URL had been read.

Everything that makes it a *piece* index went unparsed: `r` (a whole-file URL),
`blossom` (the servers holding the pieces), `x` (the file hash) and every `b`
(one piece's hash and byte count). Those are now accessors, with `pieces()`
keeping publication order because that is reassembly order and must not be
sorted.

The card gains what a reader can act on:

- the byte count humanized -- `31838839` was a number you had to decode
- the piece count, the thing the kind exists for
- the Blossom servers on the byline; without them the hashes name something
  unreachable
- a tap opens the whole-file URL. Nothing in-app reassembles pieces yet, so this
  is the one address that plays. With no `r` there is nothing to open and the
  card stays inert rather than pretending otherwise.

Verified against silberengel's "Glyfada evening tide": reads `file - 32 MB -
31 pieces` over `files.sovbit.host`, and tapping opens the video. The 31 `b`
tags sum to exactly the declared 31838839 bytes, so the piece list parses whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 14:09:56 -04:00
Claude ce3f87f591 feat(marmot): derive candidate branches by replaying MLS bytes
Convergence had a selector with nothing to select over. This adds the
candidate-graph builder that turns retained group states plus a bag of
inbound commits into the branches `BranchSelector` compares.

The central rule is that parentage is DERIVED, never declared. A commit
carries no parent pointer, and it must not be believed if it did, so the
builder finds a parent by asking which retained state the commit's
membership tag authenticates against. That makes it a fixed point rather
than a sweep: replaying a commit produces a state that may be the parent
of a commit nothing could place a moment earlier, so it keeps sweeping
the unplaced set until a pass produces no new edge.

`CandidateStateEngine<S>` splits the graph algebra from MLS so each half
is testable on its own terms; `MlsCandidateStateEngine` is the real
adapter. A state id is SHA-256 over the serialized GroupContext, not the
epoch number — two states can share an epoch number and be different
states, which is exactly what a fork is. Every trial replay restores a
fresh group from the retained snapshot, because a candidate parent gets
tried by several competing commits and "advance it then roll it back"
works right up until an exception escapes halfway through.

Dispositions keep "I cannot place this" apart from "I caught you
misbehaving": an unauthorized commit whose parent IS known is terminal
`authorization_failed`, while a commit nothing authenticates is
`deferred`, and only `stale` once the live canonical tip passes the
rollback horizon. A resulting state that breaks a component invariant
produces no edge at all, so convergence can never select it.

`MlsGroup` grows three non-mutating helpers for this —
`resolveCommitProposals`, `isCommitAuthorized`, `isSelfOnlyCommit` —
reusing the same gates the local commit path runs, so an inbound commit
and one we authored are held to one rule instead of two that drift.

Writing the real-MLS test found a bug in the builder: an unattributable
tip was zero-filled into a 32-byte committer, which would have handed it
the lowest possible account pubkey and won it a step-5 tie-break it never
earned. Such a tip is now dropped.

Tests: 12 over a fake engine for the graph algebra, 9 over real MLS
groups and a genuine same-epoch fork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 17:44:38 +00:00
Claude b747f71c5f feat(library): read what a 30142 actually carries; move the rating stars under the card
Two fixes from looking at the real corpus (69 kind-30142 events off theforest,
damus, nos.lol and thecitadel) rather than at the one sample event.

**Learning resources rendered as a title and a summary line.** The card only
read title/summary/image, and the wire has two publisher families that carry
neither pair fully:

- *Codices* (25 of 69) publish books: `title`, `author`, `published`, `image`,
  and an empty body. The card showed a title over a cover with nothing to say
  who wrote it or when.
- *Edufeed / EKW* (41 of 69) publish schema.org: `name`/`description` plus a
  vocabulary of facets spelled as flat tag pairs — `about:id` with the URI and
  `about:prefLabel:de` with the label, repeated per value, plus
  `learningResourceType:*`, `educationalLevel:*`, `creator:name`,
  `inLanguage`, `license:id`, `isAccessibleForFree` and an `encoding:*` group
  naming the file the resource *is*. None of it was read.

`LearningResourceEvent` now parses all of it, and the card shows a byline
(author · year), a chip row of facets, and a link to the attached file. Facets
resolve to one language — a real event carries `learningResourceType` in six —
and deduplicate, because the vocabularies overlap and a chip row that repeats
itself reads as a bug. With this, every one of the 69 events shows at least one
fact beyond its title; before, 44 of them showed none.

This is a superset of what the reference Android client renders: it maps 30142
onto its generic hero card, which reads `author`, `published_on`, `type` and
`l` but none of the schema.org facets, so its cards for the Edufeed corpus are
title + description + cover.

**Rating stars sat above the rated thing.** A verdict a reader meets before the
card it judges has nothing to attach to; they now sit under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 17:23:19 +00:00
Claude d2d6f03f99 fix(search): a query that is only a token still names a word to search for
Routing the name searches through the leftover terms was half right and I said so
too confidently last time: `#bitcoin` does not reach the channel finder as
"#bitcoin" any more, but nor does it reach it as "bitcoin" — the tokenizer lifts
the tag out, nothing is left over, and the finders return early on a blank
string. So the fix that was meant to make a channel called "Bitcoin" findable
made it unfindable, along with every user.

`SearchQuery.nameSearchTerms()` gives these finders the word the reader actually
typed: the leftovers when there are any, and otherwise the value inside the token.
Somebody typing `#bitcoin` into a search box means "bitcoin" by it, and
`from:vitor` means "vitor"; neither means "search for nothing". One term, never a
join — these match a single name, so `"bitcoin lightning"` would match nothing at
all.

Desktop had the same hole through a different door: its people subscription fell
back to serializing the whole query, so the relay's people index was asked for the
literal "#bitcoin". Same function, same answer.

Checked the other surfaces rather than assuming: the hashtag suggestion row, the
NIP-05 resolver, the bech32 auto-navigate and the relay-URL row all read the raw
box on purpose — an npub or a `wss://` url is the whole input, not a word inside
it — and none of them were touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-08 17:22:10 +00:00
Claude a9b38b0c52 feat(marmot): build current-profile groups and KeyPackages, not just parse them
Every previous change read the reference. This one writes, which is the half
that decides whether MDK accepts anything we author.

CurrentProfileGroupFactory assembles current-profile leaves, KeyPackages and
groups. The order it enforces is forced by the protocol rather than chosen: a
leaf must carry an account identity proof over its OWN MLS signature key, and
only an account signer — possibly a remote bunker or an external app — can
produce that proof. MLS leaf construction is synchronous and normally generates
its signature keypair internally, so the proof cannot be attached afterwards by
code that only sees a finished leaf. The keypair is therefore generated first,
authorized, and only then built into a leaf. MlsGroup.create and createKeyPackage
gained leaf-extension, capability and required-capability parameters to allow it.

Writing the producer side immediately found two bugs that the reader-side tests
could not have found:

buildLeafNode accepted a leaf-extensions parameter and then wrote
extensions = emptyList(). Every leaf we built would have silently dropped its
identity proof — the exact component that makes a group classifiable at all.

Fresh KeyPackages carried Lifetime(0, Long.MAX_VALUE). That fails the bound
Stage 4 had just started enforcing, so every KeyPackage we published would have
been rejected by any conformant peer, including by us. Now it spans now-1h to
+84 days: the backdate gives a peer with a slow clock a window where the package
is already valid, and 84 days leaves the spec's whole one-hour skew allowance as
headroom rather than sitting on the limit.

Six tests, including the two directions that matter for interop: a KeyPackage we
build has the same component set, capabilities and dictionary layout as the one
the OpenMLS fork emits, and a KeyPackage the fork authored can be added to a
group we created. Full quartz jvmTest: 4,600 tests, 0 failures. commons and cli
compile.

What this does NOT do, recorded in the plan rather than implied: the app layer
still creates MIP-era groups — nothing in commons, amethyst, desktopApp or cli
calls this factory yet. The Quartz half is ready and tested; the wiring is not
written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 17:12:43 +00:00
Claude 789641067d feat(marmot): add the bounded convergence pass
The other half of the convergence machinery: the input-collection window that
decides which inputs a resolution sees.

A pass exists so a client resolves a fixed batch rather than chasing a moving
one. It snapshots pass_base_epoch when it opens, closes at the earlier of the
quiescence window and the absolute deadline, and then freezes: resolution
reaches a deterministic fixed point using only what was admitted, without
waiting for a fetch or admitting later input.

Two asymmetries carry real weight, and both are tested.

Only selection-relevant input restarts quiescence. Ordinary chat traffic that
cannot change branch selection must not, because outbound work is gated on the
group settling — if it did, a busy group would never send anything.

Neither a detected fork nor an admitted disband candidate restarts anything. A
pass that becomes a recovery is the same pass; restarting its timers or
resnapshotting its base epoch would let a steady trickle of forks hold it open
indefinitely. The disband case forces Stable -> Recovering even on a linear edge
with no fork, so terminalization can only happen after branch selection.

Deferred-commit expiry deliberately tracks the LIVE canonical tip, so obsolete
input ages out as state advances across completed passes, while branch
eligibility uses the FROZEN pass_base_epoch, so an open pass cannot move its own
rollback horizon while comparing candidates. Using one epoch for both would make
the horizon shift underneath a pass.

The timers are scheduling, not semantics: input arrival time, cutoff time and
pass membership never enter candidate validity or the branch score, and there is
a test that splitting the same inputs across passes reaches the same answer.

Driven by an injected monotonic clock — a wall clock would make these tests
flaky and prove less, and a clock adjustment must not be able to shorten or
extend a real pass either.

Twelve tests. Full quartz jvmTest: 4,594 tests, 0 failures.

Two of these tests started out asserting my own bad arithmetic — the tick at
exactly the absolute deadline is refused, not admitted, and a pass cannot be
kept alive to 4800ms without feeding it — and now assert the rules explicitly.

Still open: the candidate-graph builder that replays MLS bytes against retained
states, and wiring the pass and selector into MarmotInboundProcessor.
CommitOrdering's transport-metadata tiebreak still stands until that exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 16:59:39 +00:00
Claude c5f50b84f4 Merge remote-tracking branch 'origin/claude/event-kind-34259-parsers-jocu9w' into claude/event-kind-34259-parsers-jocu9w 2026-09-08 16:52:39 +00:00
Claude 08703f8de0 fix: observe LocalCache and relay subs in the new addressable renderers
Every new kind whose defining semantic is replacement was reading its event
once, with `note.event as? X ?: return`. `Note` is `@Stable` but `Note.event`
is a plain `@Volatile var`, not snapshot state, so that read registers no
snapshot dependency: when a newer version of an addressable event supersedes
the old one on the same `Note` instance, the card keeps drawing the version it
first composed with. The one-time read also skips the relay side —
`observeNoteEvent` opens the EventFinder subscription, so an unobserved card
never asks for what it is missing.

Switched to `observeNoteEvent<T>` in the eight addressable renderers:
30040 publication index, 30041 section, 30045 directory, 30142 learning
resource, 30819 wiki redirect, 31987 relay review, 32176 Blossom piece index,
34259 entity rating. Left the regular kinds alone (17, 31/32/33 citations,
818/819 wiki merge): a non-addressable event is immutable once it lands, and
`WatchNoteEvent` already covers the not-yet-arrived case.

Also fixed the one unobserved User read: the rated-note card named its author
from `targetNote.author?.toBestDisplayName()`, keyed on the note's own state.
A kind-0 routinely lands long after the note it signs, so the card froze on
the hex. `observeUserInfo` both watches the cache and asks for the metadata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 16:52:18 +00:00
Claude 486e93f873 feat(marmot): add the lifecycle state machine and convergence branch selection
Stages 5 and 6, protocol cores.

The lifecycle model: six canonical states with their legal-transition table,
four derived convergence statuses with the legal-combination table, and the
durable local gates (Leaving, Disbanding, realized removal) that restrict
outbound work without being lifecycle states.

Two table entries are load-bearing rather than bookkeeping, and both have tests.
There is no Merging -> Recovering edge: a competing branch observed while
applying our own confirmed commit is retained, the merge completes to Stable,
and admission into a bounded pass then triggers Stable -> Recovering. Diverting
mid-merge would leave a half-applied epoch. And Disbanded has no outgoing edge
at all — no later branch supersedes a terminalized disband.

Branch selection replaces the superseded MIP-03 rule, which broke a same-epoch
tie on the outer Nostr created_at and then the event id. Both are transport
evidence: timestamps are chosen by senders, and each transport copy of one MLS
message carries a different event id. The replacement reads only authenticated
values.

Three details that decide whether two clients agree:

Byte ordering is unsigned. Account keys and SHA-256 digests are uniformly
distributed, so a signed comparison inverts roughly half of all final ties, and
two implementations would disagree that often.

raw_commit_depth gets no comparison step of its own — it is already inside
effective_commit_depth, so once effective depth and quorum status tie, a further
raw-depth comparison is necessarily tied too. A widely circulated write-up of
this algorithm lists raw depth as a step; the spec does not, and there is a test
that fails if it is added.

Witnesses count distinct sender ACCOUNTS per branch epoch, capped at the quorum
size, and epochs at or before fork_epoch do not count. Counting by account stops
a multi-device member counting twice; counting distinct senders stops one member
inflating a branch by sending a lot; the per-epoch cap stops one busy epoch
outweighing several quiet ones.

The policy constructor enforces max_witness_override_depth <= max_rewind_commits,
because without that bound app-payload traffic could push a branch past the
rollback horizon and beat an arbitrarily longer valid commit branch.

Twenty-five tests, including the worked example: a three-commit branch with
witness quorum ties a four-commit branch without one at effective depth four and
then wins on quorum, while a five-commit branch beats both because the boost is
capped at one. Selection is asserted invariant under input order, reversal,
shuffling and every rotation. Full quartz jvmTest: 4,582 tests, 0 failures.

Still open in these stages: the bounded pass scheduler and the candidate-graph
builder that replays MLS bytes against retained states, plus wiring the
lifecycle states into MlsGroup so they gate anything. CommitOrdering's
transport-metadata tiebreak therefore still stands — deleting it is only safe
once something replaces it end to end, and selection alone does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 16:50:47 +00:00
Vitor PamplonaandClaude Opus 5 6099ed6d03 fix(library): name nested directory entries, and read the schema.org spelling
Two things a real bookshelf turned up.

**A nested directory read "Untitled section".** `Library.kt` reuses
`PublicationSectionRow`, whose title `when` knew only the publication kinds, so a
directory listing another directory fell to `else -> null` and then to the
section placeholder -- both the wrong name and the wrong noun. A directory lists
whatever it likes, so the row now names the library kinds too, learning resources
and piece indexes included.

**A learning resource read as its `d` slug.** `title()` looked only at `title`,
but publishers that tag themselves `type: LearningResource` follow schema.org and
emit `name`/`description`. Both spellings are accepted now, `title`/`summary`
winning where an event carries both, so nothing that renders today changes.

Not a bug, checked and left alone: Laeserin's directory shows `my-book-collection`
because that event carries no `title` *or* `name` at all -- the `d` fallback is
the right answer there.

Verified on device against the same two events that showed the defects: entry 3
of `my-book-collection` now reads "nostr" instead of "Untitled section", and the
German resource reads "Caesar-Scheibe - 30 Buchstaben: A-Z plus Ae Oe Ue ss"
instead of "17xu8qb7". Two tests added for the vocabulary split, including the
precedence case; LibraryEventsTest 11/11 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 12:43:12 -04:00
Claude a8d11e4c58 feat(marmot): current-profile image crypto and Nostr transport corrections
Finishes Stage 3 and lands most of Stage 4.

Image crypto (0x8002). GroupBlossomImageCrypto implements the current-profile
scheme, which breaks from MIP-01 in three ways: image_key IS the AEAD key rather
than an HKDF seed, image_upload_key IS the Blossom-auth secret rather than a
seed, and the AAD is domain-separated and binds the media type where MIP-01 used
an empty AAD. That last one closes a real hole — with no AAD a blob could be
replayed as a different media type. MarmotMediaType implements the frozen
canonicalization; it uses ASCII case folding explicitly, because a locale-aware
lowercase would map a dotted capital I to a dotless one and change the AAD
bytes. Decryption verifies the content hash before attempting the AEAD: the blob
is addressed by hash, so a store returning different bytes is broken or hostile,
and finding out through an authentication failure loses that distinction. The
MIP-01 scheme stays for groups already on disk and nothing falls back between
them, because the component id is the version.

Transport. kind:30443 gains a current-profile builder emitting the required tag
set and omitting the two tags the spec forbids: encoding (a receiver decodes
each field by the rule that defines it, never by a negotiated marker) and relays
(discovery is the author's NIP-65 write set). Validation is profile-aware, told
apart by the presence of app_components rather than a version tag.

KeyPackage relay discovery moves to the NIP-65 write set. publishRelaysFor no
longer prefers a kind:10051 list — publishing only where a now-removed list
points would make us invisible to a conformant peer, which looks in the NIP-65
set and nowhere else. The legacy list is unioned in rather than substituted, so
peers that have not migrated keep finding us.

Deduplication now uses SHA-256 over the recovered MLS bytes rather than the
Nostr event id, which the transport spec forbids as a dedup key. The old scheme
collapsed nothing it was supposed to: relays redeliver, and every transport copy
of one MLS message carries its own fresh ephemeral pubkey and therefore a
different event id — so cross-relay duplicates always got through, and a hostile
republisher could mint unlimited distinct ids for a single message. Dedup
necessarily moved after outer decryption, since there is nothing to hash before
that, and OutboundGroupEvent now carries the id so a publisher suppresses its own
echo by MLS identity.

Also enforces the KeyPackage Lifetime bound (present, current, at most
7,261,200 seconds) and validates the embedded account identity proof on inbound
current-profile KeyPackages — the app_components tag is only an advertisement, so
the decoded LeafNode is what decides.

Fifteen new tests. Full quartz jvmTest: 4,557 tests, 0 failures. commons and cli
compile.

Left for Stage 6: bounded retained-candidate trial decryption for kind:445. Its
rule is defined over the retained-state set convergence owns, so it cannot land
ahead of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 16:40:18 +00:00
Vitor PamplonaandClaude Opus 5 9ba5a7663c fix(threadview): render the six library and citation kinds when opened
`RenderNoteRow` and `NoteMaster` are two independent dispatch chains, and the
library, citation, section and relay-review kinds only reached the first. So
every one of them rendered in a feed row and then fell apart when you tapped it.

Six kinds were affected -- 31/32/33 citations, 30045 directories, 30142 learning
resources, 32176 piece indexes, 30041 publication sections and 31987 relay
reviews -- degrading two ways depending on whether the kind carries `content`:

- 30045 rendered as an avatar and an action row with nothing between them, since
  a directory's whole substance is its `a` tags.
- 30041 fell through to the generic body: the prose showed, but with no title,
  no AsciiDoc conversion and no wikilink resolution, so `RenderPublicationSection`
  never ran. That one is on a path the feature builds itself -- a publication's
  table of contents links straight into it.

Citations get one branch on the `CitationEvent` supertype, matching the feed.

Verified on device against live events for the four kinds that have any:
Laeserin's `my-book-collection` now lists its 7 entries by title, Wuthering
Heights' CHAPTER I renders titled and converted, the German learning resource and
the "Glyfada evening tide" file record both reach their own renderers. Nothing
has published a kind 31/32/33 on any of the eight relays checked, so those three
are wired and compiled but unverified against real data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 12:24:58 -04:00
Claude 14b5e1e304 fix: address the branch audit — auto-sign scope, AsciiDoc mangling, unrendered targets
Audited the whole branch diff. Seven real defects, the first of them the one
that mattered.

SECURITY. Nine kinds had been added to REASONABLE_SIGN_KINDS — the set a
connected app may sign WITHOUT asking the user — as part of mechanically
working through a registration checklist, never against that list's own stated
test ("when a kind's blast radius is unclear, it is left out"). Four fail it and
are removed: a kind-30045 directory is a replaceable *list* one bad write wipes
(exactly the case the KDoc excludes), and ratings (34259), relay reviews (31987)
and wiki redirects (30819) attach a replaceable opinion or redirect to the
user's identity. The addressable *content* kinds stay, matching 30023/30818.

AsciiDoc, both proven with failing tests first:
- A trailing space defeated matchEntire on a block image, so `image::url[Alt] `
  fell through to the inline rule, which captured `:url` and emitted
  `![Alt](:url)` — a dead link, breaking the file's "never mangled output"
  promise. Block constructs now match the trimmed body.
- A `[source,kotlin]` attribute with no block after it kept its language and
  labelled an unrelated later `....` block as Kotlin. The pending language is
  now cleared once ordinary content intervenes.

Rendering:
- A rating using the spec's DEFAULT `event` mark names its target by event id,
  and the card printed the raw 64-hex string as its bold title. It now loads
  the note and shows its author and opening, and opens it on tap.
- Relay reviews printed a redundant "relay" chip beside the stars, directly
  under the relay URL — the comment claimed MARKS_WITH_A_CARD suppressed it,
  and it did not.
- The directory overflow row reused the total-count plural, so a 20-item shelf
  read "20 items … 8 items". It has its own "N more items" now.

Parsing:
- Slot 2 of a section `a` tag was dropped as a title whenever it parsed as any
  integer, losing a chapter genuinely called "1984"; only a value inside the
  level range is a level now. A schemeless relay hint (`relay.example.com`) was
  read as a title, putting a hostname in the table of contents.

Also two CLAUDE.md style violations: inline fully-qualified arrayOfNotNull in
WikilinkTag and ExternalTargetTag, now imported.

Tests: 4 new, all written to fail against the old behaviour first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 16:24:18 +00:00
Claude 1c4f7a1324 feat(marmot): split marmot_group_data into its six app components
Stage 3 of the Marmot resync. MIP-01 packed name, description, admins, routing,
image and retention into one 0xF2EE extension, so any change rewrote the whole
blob. The adopted spec splits them into six independently versioned components
in the GroupContext app_data_dictionary; this implements all six, plus
MarmotGroupState as the read view that replaces MarmotGroupData.

Several are not mechanical translations of the MIP-01 fields:

  - message-retention is a fixed uint64 with no length prefix, and 0 now means
    disabled where MIP-01 rejected it outright;
  - blossom-image gains media_type, and that type is bound into the AEAD's AAD;
  - profile equality is byte equality, so nothing may Unicode-normalize a group
    name before hashing, comparing or storing it;
  - admin keys sort by unsigned byte value, which matters because roughly half
    of all x-only keys start above 0x7f.

Decoders reject unsorted, duplicate, ragged and trailing bytes rather than
repairing them, for the same reason the dictionary codec does: these bytes sit
in the signed GroupContext, and normalizing on the way in would leave two peers
each holding bytes they consider valid and disagreeing about which is canonical.

This also closes the authorization gap Stage 1 left open and flagged. A
current-profile group keeps its admin list in marmot.group.admin-policy.v1
(0x8003), not in marmot_group_data, so both gates had nothing to read and let
everything through. They now resolve admins through currentAdminIdentities(),
which prefers 0x8003 and falls back to 0xF2EE, and depletion resolves an
admin-policy change carried by an AppDataUpdate rather than only by a
GroupContextExtensions proposal.

The admin lookup deliberately decodes only 0x8003 and not the whole component
set. The first version went through MarmotGroupState, which made a malformed
profile component freeze the group by taking the admin check down with it — its
own tests caught that. Authorization must not depend on components it does not
read.

Thirty-five tests: the MDK-generated GroupContext dictionary decoded component
by component and re-encoded byte-identically, per-component validation rules the
happy-path fixture cannot reach, and authorization driven through real groups
rather than by calling the gates directly. Two of those tests started as wrong
premises of mine and became real coverage: naming an admin who holds no member
leaf is rejected by the admin/leaf coupling rule, and removing the admin policy
is rejected because it is the group's sole admin authority for its lifetime.

Full quartz jvmTest: 4,542 tests, 0 failures. commons and cli still compile.

The 0xF2EE decoder stays as the legacy read path. Left for a follow-up: the
image ENCRYPTION still uses an empty AAD and treats image_key/image_upload_key
as HKDF seeds rather than the keys themselves, which touches the Android and CLI
image paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 16:00:06 +00:00
Claude 25466add94 Merge remote-tracking branch 'origin/main' into claude/event-kind-34259-parsers-jocu9w 2026-09-08 15:49:50 +00:00
Claude e69db62fa3 Merge remote-tracking branch 'origin/claude/event-kind-34259-parsers-jocu9w' into claude/event-kind-34259-parsers-jocu9w 2026-09-08 15:49:45 +00:00
Claude 3b20aede9a test(search): pin the local search engine against a live relay's answers
Adds a recorded corpus of 64 real events from search-staging.brainstorm.world — a
vespa-relay, the same software this token language was ported from — and 18 tests
over it: 8 on the engine in :commons, 10 on `LocalCache.filter` in :amethyst.

Recorded, not fetched. `tools/search-parity/fetch_fixtures.py` drives `amy fetch`
against the relay by hand; the tests read the committed fixture, so `./gradlew
test` stays offline and the pre-push hook does not depend on somebody else's
uptime. The fixture stores each case's filter FIELDS rather than a prebuilt
filter, so the test rebuilds the Filter in view of the reader and a wrong rebuild
cannot quietly make the assertions vacuous.

What the relay can and cannot referee turned out to be the whole design, and it
was measured rather than assumed:

- **NIP-01 it can.** Given kinds, #t, since and until there is exactly one right
  answer, and across all 64 events our matcher agrees with the relay about every
  one it chose to return — 0 violations. That is now a hard assertion, with a
  converse test so it cannot pass by matching everything.
- **NIP-50 it cannot.** This relay retrieves topically: asked for `bitcoin` it
  returns a block-height summary that never says "bitcoin". Eight of 64 events
  carry no literal occurrence of the term that fetched them. Asserting our
  substring matcher reproduces that would encode someone else's semantic
  expansion as a requirement on a lexical one — a test that fails on correct
  code. So text results are deliberately not compared, and the divergence is
  pinned as a range instead: zero would mean the relay turned lexical and the
  comparison should be rewritten, a quarter would mean we regressed.

The fixture uses the `include:spam` lens, which waives the web-of-trust gate.
Also measured: it makes the corpus reproducible, where `observer:<pubkey>` ties
every answer to one account's moving trust graph — but it does not make retrieval
lexical, and in fact widens the divergence from 4 events to 8 by letting more
topical matches through.

The LocalCache tests cover what the relay knows nothing about and where the bugs
actually were: the regular/addressable split, the viewer-policy predicate
composing with rather than replacing the filter, and the result cap keeping the
newest — the ordering whose absence let `take(limit)` run before the sort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-08 15:42:35 +00:00
Claude 1a5c264f6c feat(library): parse and render citations, directories, learning resources and piece indexes
The six kinds that have real renderers in jumble but none here. None is defined
by a NIP; the shapes come from the publishing clients' draft builders and
manifests, which is stated on each class rather than implied.

31/32/33 citations get one card, because they are one idea aimed at three sorts
of source and a reader should not learn three layouts. A shared CitationEvent
base carries what they have in common; each subclass adds only its own fields —
a URL and timestamp for the web, a containing work and page range for print, a
model name for a prompt. The provenance line is assembled from whichever fields
the citer actually recorded rather than templated, since a fixed layout would be
mostly blank labels.

Kind 30, "internal citation", is deliberately NOT modelled: Quartz already
registers kind 30 as a Jester chess move, so the two vocabularies collide on the
wire and a kind-30 citation already parses as chess here. Picking a winner is a
protocol decision, not a parsing one. A test states the collision rather than
leaving it to be rediscovered.

Two wire details worth pinning: a hardcopy citation's VOLUME has no tag of its
own and rides in the second slot of `published_in`, and the reference client's
manifest names the external URL tag `url` while its card and draft builder use
`u` — `u` is the wire truth, `url` is accepted so a publisher who followed the
manifest is not dropped.

30045 directory is a curated shelf, and its items use exactly the `a`/`e`
grammar a publication index uses for its table of contents — including the
uppercase-is-a-source rule. So it reads them through PublicationSectionRef and
renders them with the same rows, rather than growing a parallel parser that
would drift.

30142 learning resource renders its body in full: the reference Android client
marks the kind `reader = true`.

32176 blossom piece index keeps `size` as the string it is published as, with a
separate sizeInBytes() that returns null rather than guessing units on anything
that is not a byte count.

Uses AutoAwesome and Collections from the existing subset instead of adding
SmartToy and School, so the font does not need regenerating for two glyphs.

Tests: 22 new (13 citations, 9 library).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 15:38:45 +00:00
Vitor PamplonaandClaude Opus 5 7ffd43e750 fix(publications): translate the summary, and stop double-framing the post
Two things wrong with how a kind-30040 index rendered as its own post.

**The summary was a bare `Text`.** It is authored prose -- a blurb, unbounded,
with no `maxLines` -- so a plain Text gave it no translate offer and rendered any
url, mention or hashtag inside it as dead literal characters. It now goes through
`TranslatableRichTextViewer`, the same overload the rating and relay-review bodies
use, which wraps `ExpandableRichTextViewer` and so also brings the Show-More
collapse that a blurb of this length wants. Threading it needs `makeItShort`,
`canPreview`, `quotesLeft` and `backgroundColor`, which puts `PublicationHeader`
and `RenderPublicationIndex` on the same parameter shape as `RenderEntityRating`.

The visible asymmetry this removes: a German *review* of a book offered
translation while the book's own German blurb did not.

**The card framed the whole post.** `replyModifier` draws the quote border used
for something cited inside a note. A 30040 carries no content beside this header,
so the border wrapped the entire post and read as a citation of something else --
a card inside the note row's own frame, with nothing outside it. Now a plain
Column, and the card's horizontal insets go with it since the note row already
indents. This is the split LongForm already makes: `LongFormHeader` keeps the
card for a feed row, `RenderLongFormHeaderForThread` drops it for the thread.

Verified on device against two of `npub1m4ny6...`'s publications: the English
Wuthering Heights index renders flush with the note frame with a Show-More
summary, and the German "Odysseus: Mythos und Wahrheit" renders its blurb with
"Auto-translated from German to English" -- which the bare Text could not do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 11:37:43 -04:00
Claude 9eee9a719c feat(marmot): implement the MLS extensions draft's app_data_dictionary carrier
Stage 1 of the Marmot resync, and the foundation the rest of it needs. The
current profile keeps every piece of application-owned group state in the
draft-ietf-mls-extensions `app_data_dictionary` rather than in a bespoke
extension, so nothing downstream can be built without it.

Adds ComponentData, AppDataDictionary (extension 0x0006) and the ComponentsList
payload shared by app_components (0x0001) and safe_aad (0x0002); the
AppDataUpdate proposal (0x0008) with its update/remove operations, wired
through MlsGroup on both the committing and receiving paths; and reads
last-resort as the KeyPackage-level 0x0004 component rather than an MLS
extension type, which is what the MIP-era profile used 0x000a for — a value
that now means the self_remove proposal.

Building a dictionary sorts its entries; decoding refuses to. A receiver that
silently sorted would accept two encodings of one dictionary, and since the
dictionary sits inside signed LeafNodes and the GroupContext, two peers would
then hold bytes they each considered valid and disagree about which is
canonical. Same reasoning for ComponentsList and for rejecting trailing bytes.

Two application rules are taken from openmls rather than inferred, because both
change the resulting GroupContext and therefore the epoch key schedule:
AppDataUpdate applies after the rest of the proposal list, so a
GroupContextExtensions proposal in the same commit is already reflected
regardless of list order; and the dictionary extension is added-or-replaced in
place and never dropped, so removing the last component leaves an empty
dictionary rather than no extension.

MLS leaves update-payload semantics to the application — openmls hands the
proposals back unresolved because a payload can be an arbitrary diff. Every
Marmot component document defines its update as a full replacement state, so
resolution here is the identity function; that assumption is documented where a
future diff-shaped component would have to break it.

Twelve tests parse the MDK-generated KeyPackage in marmot-current-profile.json
end to end — MLSMessage, KeyPackage, LeafNode, dictionary, components — and
re-encode the dictionary byte-identically. A JSON echo of the component map
could not establish that: the generator would just be handing back what it was
told to write. Nine more cover the proposal wire format and its group-level
application, including two members converging on the same dictionary across the
separate receive path. Full quartz jvmTest: 4,507 tests, 0 failures.

One gap is left open on purpose and marked at the source: the MIP-era
authorization gates read marmot_group_data (0xF2EE), which a current-profile
group does not have, so both return without enforcing anything there. The
admin-policy component closes that in Stage 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 15:33:58 +00:00
Vitor PamplonaandClaude Opus 5 b9165f421d fix(ratings): show a user's own ratings and publications on their profile
A rating only reached its author's profile if somebody had reposted it. Both
profile gates were missing the kinds, so the events never arrived and would not
have been accepted if they had:

- `UserProfilePostKinds2` did not request 34259 or 30040, so the profile REQ
  never asked the author's outbox relays for them.
- `UserProfileNewThreadFeedFilter.acceptableEvent` did not accept either type.

The branch wired the Home feed's equivalent pair but not this one. The
addressable scan here has no kind allow-list of its own (unlike Home's
`ADDRESSABLE_KINDS`), so the DAL side is just the two type checks.

The rating check mirrors Home's: a rating with nothing to point at cannot be
rendered, so `hasTarget()` gates it rather than leaving an empty row on the
author's own profile.

Kinds deliberately left out: 30041 publication sections, because they are
chapters rather than posts and one book would bury a profile under 34 entries;
and 31987 relay reviews, which have the same shape as ratings but were never in
any feed and should be a decision of their own.

Verified on device against `npub1m4ny6...`, whose two ratings differ usefully:
the Wuthering Heights one has three kind-16 reposts and so was already visible,
while "Am Fluss der Zeiten" has none. The latter now renders on the profile as a
plain entry with no repost header, which it could only do by arriving through
these gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 11:09:05 -04:00
Claude 1d66e4e2f6 feat(marmot): implement account identity proof v2, the current profile's leaf binding
Stage 2 of the Marmot resync. A member leaf carries two unrelated keys — the
MLS BasicCredential identity, which is the member's Nostr account key, and the
MLS leaf signature key MLS generates per device — and MLS never checks that the
account agreed to the leaf key next to it. Without a proof, anyone able to
author a leaf can claim any account's identity.

This is also what makes us classifiable at all. MDK decides Legacy vs Current
purely on whether a group requires extension 0xf2f1 or component 0x8009; we
required neither, so profile classification errored out before any component
check ran.

Adds the common authorization-proof envelope (foundation/authorization-proofs.md):
104 fixed-width bytes of signer pubkey, big-endian uint64 timestamp and BIP-340
signature, with event-id reconstruction. The created_at bounds are load-bearing
twice over — the lower bound rejects zero, and the upper bound (2^53-1) catches
a uint64 whose top bit is set, which reads back negative as a Kotlin Long.

Deliberately absent: any comparison of created_at against a local clock. A proof
authorizes a long-lived key binding, not a one-time operation, and a wall-clock
rule would let skew make two members reach different verdicts on the same Commit.

The component itself signs a kind-450 template through NostrSigner rather than
raw BIP-340, which is the whole point of the indirection: a NIP-46 bunker or
NIP-55 app can produce a proof without exposing arbitrary signing. create()
therefore re-verifies everything the signer returned — pubkey, timestamp, kind,
tags, content, recomputed id, signature — since an external signer is free to
substitute a stale or altered event.

Also adds the app-component id registry, and the RFC 9420 signature-scheme
mapping to MlsCiphersuite (declared outside the companion: an enum's entries
initialize before its companion object, so entry constructor arguments cannot
read companion properties).

Tested two ways. Sixteen tests pin the spec's published fixture — canonical
event serialization, event id, signature, the 104-byte layout — and check that
every signed input actually binds, including a ciphersuite change that leaves
the signature scheme untouched. Six more validate the proofs in
marmot-current-profile.json: those come from a separate implementation, for
randomly generated keys, which is the interop property a fixed vector cannot
establish. Full quartz marmot suite: 395 tests, 0 failures.

Nothing reads or writes these on a real leaf yet — the carrier is the
app_data_dictionary, which is Stage 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 15:04:59 +00:00
Claude abc1caa666 feat(wiki): parse and render the NIP-54 collaboration kinds and kind-17 reactions
Four more of the kinds imwald publishes that Amethyst dropped on arrival.

818 wiki merge request (NIP-54 Appendix 1) — target article, destination
author, the version to merge, the version it was based on, and the
explanation. Renders both ends as rows that resolve to the articles they name.

The spec and the implementations disagree on one thing, and it is the load
bearing one: NIP-54 writes the merge-source marker as `source`, while the
client actually publishing these writes `fork`. Reading only the spec's word
turns every merge request in the wild into one with nothing to merge, so both
are accepted. The marker slot is read directly rather than through
MarkedETag's enum — `source` is NIP-54 vocabulary, and adding it to the NIP-10
threading markers would imply it takes part in threading, which it does not.

819 merge acceptance — NOT a NIP-54 kind. The spec stops at the request and
says the author answers with a NIP-25 +/- reaction; 819 is an extension that
records the acceptance and the version it produced, which a bare reaction
cannot carry. Documented as such rather than presented as spec.

30819 wiki redirect — the spec section is literally `[INSERT EVENT EXAMPLE]`,
so the publishing clients are the only reference: `d` is the slug redirected
from, `a` the article redirected to.

Writing the slug normalizer from first principles produced a real interop bug,
caught by its own test: folding every non-alphanumeric to a dash gives
`C++ Programming` the slug `c--programming`, where the rest of the network
computes `c-programming` — a redirect published under it would match nothing.
The rule is separators to a dash, letters and digits kept (Unicode-wide, so
non-Latin titles survive), everything else DROPPED, runs collapsed. Pinned by
tests. One documented divergence: the reference NFC-normalizes first, which
commonMain cannot portably do.

17 external reaction (NIP-25) — a like on something that is not a nostr event:
a web page, a podcast episode, a book. Borrows the activity-card frame so a
like reads the same whatever it targets, but there is no note to quote and no
recipient pubkey, so the card names the NIP-73 target instead — as a link when
one is openable, as plain text when it is an ISBN or a GUID that only looks
like one.

Tests: 25 new (18 wiki, 7 reactions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 14:59:23 +00:00
Vitor PamplonaandGitHub 2e4d36a0b5 Merge pull request #4063 from vitorpamplona/fix/repost-icon-tint
fix(ui): tint the un-boosted repost icon like the rest of the action row
2026-09-08 10:52:14 -04:00
Claude c90851610a test(marmot): point the interop reference at mdk and generate current-profile vectors
Stage 0 of the Marmot resync: get a live reference back, so the later stages
are written against real bytes instead of a careful reading of the spec.

The vector generator pinned stock crates.io openmls 0.8. MDK builds against
erskingardner/openmls with the `extensions-draft` feature, and the whole
current Marmot profile is expressed in terms of what that feature adds —
app_data_dictionary (0x0006), app_components (0x0001), safe_aad (0x0002),
app_data_update (0x0008). Vectors from the published crate cannot reach any of
it. Pinned to MDK's exact rev instead.

Adds `marmot-profile-gen`, which builds a group the way cgka-engine does:
required capabilities of extension 0x0006 plus proposal 0x0008; GroupContext
dictionary carrying the required-component list, group profile, admin policy,
Nostr routing and lifecycle; per-leaf dictionaries carrying the supported list,
an empty safe_aad list and the 104-byte account-identity-proof v2 component;
last resort as the empty-data 0x0004 component in the KeyPackage dictionary,
not an extension type; PublicMessage handshakes. It emits the Add commit, the
Welcome, and exporter KATs for both group-event and the conformance commitment.

The identity-proof encoder is hand-rolled from the spec rather than lifted from
MDK, and asserts itself against the fixture published in
account-identity-proof-v2.md before emitting anything — so if the generator
runs at all, the kind-450 canonical serialization, its id, the BIP-340
signature and the component layout are known to match.

The interop harness cloned marmot-protocol/whitenoise-rs, which was archived on
2026-08-05 pinned to mdk-core 0.8.0: it was testing us against a frozen
MIP-era client, which is part of how the drift went unnoticed. Repointed at
marmot-protocol/mdk, building -p wn-cli. Both source patches are dropped —
mock-keyring is replaced by MDK's native --secret-store file, and
skip-unprocessable-retry targeted a path MDK does not have. The daemon socket
is now pinned via wnd --socket rather than guessed from a derived default.

The harness changes are read off MDK's DaemonArgs and wn-cli manifest, not off
a passing run; building MDK's workspace needs its pinned toolchain and a local
relay. A human run of marmot-interop-headless.sh is the acceptance test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 14:42:35 +00:00
Claude 0bbcf1e029 feat(publications): give a publication a table of contents you can read from
A kind-30040 card announced "34 sections" and offered no way to open one.
Sections parsed and rendered individually, but nothing ever linked to them, so
the reader was unreachable. This lists them.

Reviewing how imwald reads the same events turned up two interop gaps in our
parser, both of which silently lose most of a real book:

- Sections are listed with `e` tags as well as `a` tags, interleaved in tag
  order. We only read `a`, so an index that lists its chapters by event id
  looked empty.
- Slot 2 of an `a` entry is often a TITLE, not the documented relay hint, and
  slot 3 is often a nesting LEVEL rather than the documented event id. The
  title is the valuable one: it lets the whole table of contents render from
  the index alone, with no round trip.

PublicationSectionRef models all of that, and keeps the case-sensitivity that
matters — uppercase `A`/`E` name the original source of a derivative work, not
its contents, so reading them as sections would splice in the wrong book.

Section rows resolve lazily and upgrade in place: the index's own title shows
immediately, observing the section drives the fetch, and the event's better
title replaces it on arrival. Nested entries indent by level, so a
part-and-chapter structure reads as one. Titles are taken from every kind an
index may list — 30041, nested 30040, long-form, wiki and spec — not just
30041.

Feed cards cap at 12 entries with a "+N more" line; the thread view lifts the
cap, since that is where a publication is actually read. The cap is a fetch
budget as much as a layout one — each row carries its own subscription.

Not adopting imwald's assembly model: they render each section with its own
kind's markup rules rather than concatenating, and their own docs say so, which
is what this does too.

Tests: 15 new, covering tag order, interleaving, the uppercase exclusion, title
vs relay hint in slot 2, event id vs level in slot 3, and clamping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 14:21:12 +00:00
Claude 87763c93b3 docs(marmot): map our MIP-era implementation onto the adopted spec
Marmot deprecated the MIP documents on 2026-07-02 and MDK followed. Our
implementation still targets MIP-00..MIP-05, so it is no longer a valid
Marmot client under either profile the current spec defines.

The decisive break is identity. MDK classifies a group as Legacy or Current
purely by RequiredCapabilities: legacy requires MLS extension 0xf2f1
(account-identity-proof v1), current requires app component 0x8009
(account-identity-proof v2). We require neither, so
protocol_profile_of_group_extensions errors out before any component check
runs. We never implemented an account identity proof at all.

Records what changed upstream, what that costs us surface by surface
(app_data_dictionary components replacing marmot_group_data, convergence
replacing the timestamp+event-id tiebreak, NIP-65 replacing kind 10051,
group disbanding, the durability contract), and stages the work. Also notes
that whitenoise-rs — the reference our interop harness clones — was archived
on 2026-08-05 and moved into mdk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 14:06:42 +00:00
Vitor PamplonaandClaude Opus 5 88f5c30d59 fix(ui): tint the un-boosted repost icon like the rest of the action row
`RepostIcon` defaults to `tint = Color.Unspecified`, so the boost button drew
its glyph in the ambient content colour while every other idle icon in the row
-- reply, like, zap -- is drawn in the `grayTint` the row is handed. Passing it
explicitly puts the un-boosted state back in line; the boosted state is
unaffected, since `RepostedIcon` keeps its own `RepostedColor` default.

Follow-up to a31364b999, which fixed the liked and reposted colours.

Authored by Vitor Pamplona; committed from a Claude Code session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 09:20:25 -04:00
Claude 8a68ebc095 fix(search): eight defects found auditing the new search code
A review of the branch turned up eight, four of them wrong answers rather than
rough edges.

**An unresolved name asked for everything.** `from:vitor` — a name the picker has
not turned into a key — made `SearchQuery.isEmpty` false while contributing no
authors and no search text, so the builder emitted filters constrained by nothing
but their kinds: three unbounded REQs to every search relay, and locally the first
200 notes the cache happened to walk, presented as results. A query that can
express nothing now asks nothing.

**The result cap cut before sorting.** `LocalCache.filter` did
`(addressables + notes).take(limit).toSortedSet(...)`, and both halves arrive in
hash-walk order — so it dropped whichever matches the walk reached last, the
newest as often as not, and 200 addressable matches pushed out every regular note.
Sorted first, then cut; the comparator is descending, so the cap now keeps the
newest.

**A quoted phrase could never match.** Splitting the NIP-50 string on whitespace
turned `"hello world"` into `"hello` and `world"`, two terms each carrying a quote
character. Terms are now split respecting quotes, with an unterminated quote
running to the end as a lexer reads it.

**The group-metadata arm inherited the query.** A kind-39000 event is written by
the host relay and carries the room's name — not the searched author, not the
searched words — so inheriting `search`/`authors` made the room-naming lookup
return nothing exactly when a `group:` query had any other content.

And four narrower ones: the desktop clear button cleared the field but left the
results, relay states, sort orders and deduplicator behind; the advanced panel
rendered its now-local bounds with the UTC formatter, showing a day off in most
timezones; the `group:` picker's channel list was a keyless `remember`, frozen at
first composition; and the interaction source feeding the desktop field's outlined
chrome reached no text field at all, so it never showed focus or hover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-08 11:46:27 +00:00
Claude bf00d00123 feat(publications): render kind-30041 AsciiDoc bodies and resolve their wikilinks
Kind 30041 parsed and rendered as a titled plain body, with the two things that
make it a publication section deferred. This does them.

AsciiDocToMarkdown converts the common AsciiDoc subset — headings, lists,
emphasis, link/image macros, source and literal blocks, quote blocks,
admonitions — and the body then goes through the same CommonMark renderer that
draws kind-30023 long-form, which brings media, imeta and nostr-link handling
with it.

Deliberately NOT the reference implementation's approach: imwald-android runs
Asciidoctor.js in a headless WebView, because JRuby cannot run on Android. That
is reasonable for an Android-only app and wrong here — it is Android-only while
Quartz targets JVM, iOS and native; it vendors a JS bundle; and it puts a
WebView on the text path of every article. The trade is fidelity: no tables,
includes, conditionals or cross-references. Anything unrecognized passes
through unchanged, so an unsupported construct degrades to the plain text it
already was rather than to mangled output.

The correctness rule the reference implementation also learned the hard way:
every inline rewrite is skipped inside ----, .... and ++++ blocks, so a code
sample containing *stars* or [[brackets]] survives verbatim. An unterminated
block still closes its fence, or the whole tail would render as code.

Wikilinks now resolve. WikilinkTag parses the positional addressing slots and
drops malformed ones rather than shifting them — an event id read as a pubkey
would address the wrong thing. A [[target]] becomes an nevent when the tag
names an exact revision, an naddr when it only names an author, and falls back
to the bare label otherwise. imwald points these at its own web wiki; pointing
them at kind-30818 pages keeps the reader in the app.

Tests: 28 new (21 converter, 7 event), covering verbatim-block protection,
snake_case surviving emphasis, unterminated fences, unknown-construct
passthrough, and the case/separator-insensitive body-to-tag matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 11:36:31 +00:00
Claude fc4931625a fix(search): name what the chips stand for, and stop hiding tokens from name search
Three seams the token language opened and left open.

**People and channels were still searched with the raw box.** Those flows read
`searchValueFlow` directly, so `#bitcoin` reached `findPublicChatChannelsStartingWith`
as the literal string and a channel actually named "bitcoin" did not match. That
was true before too, but it did not matter while nothing honoured the tokens —
now notes do, so one box meant two things depending on which result list you
looked at. All four name searches take the leftover terms.

**A `group:` chip drew the raw id.** A group id is a stranger's opaque string,
and the name is the only part a reader can check against the room they meant —
doubly so because there was no picker, making the token type-from-memory. The
chip now draws the name and the picker offers the rooms from
`LocalCache.allRelayGroupChannels()`, with its relay underneath and a "shared id"
warning where two relays mint the same id, which a `#h` filter cannot tell apart.
The id stays the value, so the query is unchanged.

**A `geo:` chip drew the raw geohash.** "9q8yy" says nothing about what was
filtered on. It now reads the same `CachedReversedGeoLocations` cache that the
feed spinner and thread view already reach through `LoadCityName`, so the chip
says "San Francisco". Resolution is asynchronous and a text transformation cannot
wait, so the resolver is synchronous by contract and returns null until the cache
has an answer — which leaves the geohash showing rather than a guess.

All three names come in as caller-supplied resolvers, like `displayName` for keys:
`commons` has no business reverse-geocoding or reading an account's group list.
Offset mapping is fuzzed over the renamed tokens too, since a name is a different
length to the id it replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-08 03:34:50 +00:00
Claude 9490bad137 feat(publications): parse and render kind 30041 sections and kind 31987 relay reviews
Audited every event kind imwald-android and its jumble web client know (95
distinct kinds across ImwaldConstants.kt and jumble's ExtendedKind) against
Amethyst's parsers. 22 had no event class; these are the two that mattered most.

30041 — NKBIP-01 publication section. The prose a 30040 index points at, so
without it the publication support added earlier indexes chapters it cannot
open. Renders as a titled body. Two limits are deliberate and documented: the
spec allows AsciiDoc, which we have no renderer for and which degrades to
readable plain text rather than mangled markup; and `wikilink` tags are parsed
but not resolved, which belongs to a reader that does not exist yet.

31987 — relay review. The same gesture as the kind-34259 entity rating aimed at
a relay, and it reuses the machinery: RatingStars is now shared between the two
so a reader does not have to learn two star vocabularies. It needs one new tag
accessor, CategoryRatingTag, because a relay review may carry several `rating`
tags — one overall, plus per-aspect ones with the category in the third slot.

Unlike 34259 there is no scale ambiguity here: every publisher of 31987 uses
the 0..1 fraction, so a value outside that range is malformed rather than a raw
star count, and parse() rejects it instead of guessing. Per-aspect scores render
as percentages; five more star rows would drown the overall one.

Tests: 13 new (9 relay review, 4 section), covering the overall-vs-category
split, both boundaries, out-of-range rejection, the d/relay fallback order and
URL normalization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 03:19:17 +00:00
Claude e492b1a956 docs(ratings): correct the star-fill note now that the painter has a FILL axis
87a44b97 added variable-axis support to MaterialSymbolPainter and aa54508e
switched the star row to `filled = isOn`. The KDoc and the plan still claimed
tint was the only lever available, which is no longer true and reads as an
argument against the fix that just landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-08 02:58:08 +00:00
Claude 7d928ee005 Merge remote-tracking branch 'origin/claude/event-kind-34259-parsers-jocu9w' into claude/event-kind-34259-parsers-jocu9w 2026-09-08 02:50:42 +00:00
Claude 9b4db2e714 Merge remote-tracking branch 'origin/main' into claude/event-kind-34259-parsers-jocu9w 2026-09-08 02:36:04 +00:00
Vitor PamplonaandClaude Opus 5 aa54508e21 fix(ratings): fill earned stars, and render ratings and 30040s in thread view
Three defects found testing the branch on device against the two real kind-34259
events in the wild (both `m=books`, both five stars, from `npub1m4ny6...`).

**The stars were never filled.** Section 11.1 is right that `Star` and
`StarBorder` sharing U+F09A is not a bug, but the conclusion drawn from it --
that tint alone can carry the score -- does not survive contact with the screen:
a five-star review drew five *hollow* stars, and since `showLabel` is false
without a decimal there was no numeral either, so a full score read as an empty
row. Worse, `StarHalf` (U+E839) *is* a distinct half-solid glyph, so 4.5 would
have drawn four hollow stars beside one half-filled one -- the half star looking
more earned than the full ones. Now passes `filled = isOn` to draw the earned
stars solid off the font's FILL axis.

**Tapping a rating showed no rating.** The `EntityRatingEvent` branch went into
`NoteCompose.RenderNoteRow`, but a thread's focused note is drawn by `NoteMaster`
in `ThreadFeedView`, which has its own dispatch chain -- so opening a rating fell
through to the generic body: no stars, no cover, no publication. `HighlightEvent`,
the precedent section 4 cites for the no-`computeReplyTo` design, *is* in that
chain; the rating was not. Added beside it.

**The rated publication was a dead end.** `PublicationIndexEvent` is parsed but
had no renderer at either seam, so the one tappable thing on a rating card led to
a note showing nothing but a hashtag. `Publication.kt` renders the index: cover,
title, author, `type - version - N sections`, summary and topics. Built on the
`LongFormHeader` idiom but with a 2:3 portrait cover -- NKBIP-01's default type
is `book`, and the wide hero letterboxes every jacket. Wired at both seams.

It renders the card, not a reader: a 30040 carries no content of its own, and the
30041 sections it points at are still unparsed, exactly as sections 7 and 11.3
intended. `type` is shown as published rather than mapped through a string table,
because the spec calls that vocabulary open-ended and a table would blank every
value it has not enumerated. The publication branch sits in the body chain rather
than the header `when` above it: a 30040 has no body, so the generic renderer
would otherwise add a second copy of the topics the card already shows.

No font regeneration: the cover placeholder reuses `MaterialSymbols.MenuBook`,
already in the subset and already this feature's book icon.

Verified on device (Pixel 9, API 36) against the Wuthering Heights rating: solid
stars in both the thread and list paths, and the publication resolving to its
title, author, cover, blurb and `Book - 1.0 - 34 sections`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-07 22:19:34 -04:00
Vitor PamplonaandClaude Opus 5 87a44b97b2 feat(icons): let Material Symbols draw solid via the FILL axis
Material Symbols expresses fill through a variable **FILL axis**, not through a
second codepoint: `star`, `star_border`, `star_outline` and `grade` all map to
`f09a`. The bundled variable font carries that axis (`FILL 0..1`), but
`ProvideMaterialSymbols` pinned it to `MaterialSymbolsDefaults.FILL` (0) for the
whole tree, so there was no way to draw any symbol solid — a "filled" icon and
an "empty" one differed only by tint.

Adds a second family at FILL=1 on its own CompositionLocal, built beside the
outline one so both are allocated once per subtree rather than per call site,
and threads `filled: Boolean = false` through `rememberMaterialSymbolPainter`
and `Icon`. The two Font constructions collapse into one `symbolFont(weight,
fill)` helper so the variants cannot drift on the other three axes.

`filled` is ignored when the caller supplies its own `family`: that font is the
caller's (Amethyst's own icon font, via `AmethystIconGlyph`) and need not have
the axis at all.

Additive with a default, so no existing call site changes. Requires API 26 for
`FontVariation`, which is the project minSdk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-07 22:18:07 -04:00
Vitor PamplonaandGitHub 1b4acc15b5 Merge pull request #4062 from vitorpamplona/claude/gif-loading-freeze-yt06sl
fix(images): stop copying whole GIFs into RAM before decoding them
2026-09-07 21:56:39 -04:00
Claude 8a0cf4548e fix(search): route only id-shaped text to the legacy scan, not every empty result
The first cut fell back to `findNotesStartingWith` whenever the filter path came
up empty, so every zero-result keystroke scanned the whole cache twice — while
typing, which is exactly when it is worst.

The fallback exists for one reason: an id matches on `idHex`, which is not content
and so nothing a filter's `search` can reach. So only text that could name an
event takes it — a bech32 pointer, or a run of at least eight hex characters. An
ordinary query now scans once.

Also records the outcome in the plan: what shipped, the two things that changed on
contact with the code (step 7 became moot, so `FilterMatcher` stays untouched and
its blast radius never opens), and what is deliberately left — the user and channel
finders, which are name-prefix lookups rather than event filters and would be worse
expressed as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-08 00:33:49 +00:00
Claude c9cd62a203 feat(search): run the search box's tokens as filters, locally and on relays
The token language shipped drawing chips that nothing acted on outside desktop's
relay path. `SearchBarViewModel` handed the raw box — chips and all — to both the
local scan and the NIP-50 `search` string, so `from:npub1… bitcoin` asked relays
for the literal text of its own tokens and matched nothing, and `since:`/`#t`
narrowed neither side.

Both paths now go through the one builder:

- `searchPostsByText` parses the text and builds its three kind-group REQs from
  `SearchFilterBuilder`, so `from:`/`to:` become `authors`/`#p`, dates become the
  window, and only the leftover terms travel as `search`.
- `LocalCache.filter` grows a predicate overload — the place for everything a wire
  Filter cannot say — and `CacheSearch.findNotesMatching` runs the same filters
  against the cache under it. Local and relay results stop disagreeing about what
  a query means.

The predicate carries the two things that are not filter fields: the NIP-50
`search`, via one `EventSearchMatcher` per filter reused across the scan, and
viewer policy — mute list, unsearchable kinds, encrypted content — which is the
reader's business and not a relay's.

Notably this means `FilterMatcher` never had to learn `search`, so the blast
radius the plan worried about (33 feed filters, FilterIndex, geode's MirrorWorker
all silently narrowing) never opens. Search opts in by composing a matcher; every
other caller is untouched. Plan step 7 is moot.

A full-text query still falls back to the old scan when the filter path finds
nothing, so no existing search gets worse while the two are compared in the wild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-08 00:28:56 +00:00
Claude cfd57cde47 feat(quartz): add an allocation-free read path for NIP-50 indexable text
`indexableContent()` is a write-path API. A store calls it once per event on
insert, so joining a string there is free — and 86 of the 126 implementations do
exactly that, via `listOfNotNull(...).joinToString("\n")`: a list, a
StringBuilder and a joined String per call. The JSON-backed kinds are worse;
`MetadataEvent.contactMetaData()` has no cache, so kind 0 reparses its profile
every single call.

Matching a query against the whole cache inverts that cost — once per event per
keystroke — so this adds a read path beside it rather than changing it:

    fun interface IndexableFieldVisitor { fun visit(field: String?): Boolean }
    fun forEachIndexableField(visitor: IndexableFieldVisitor)

A `fun interface` rather than a lambda parameter, because an interface method
cannot be inline and a lambda written at the call site would allocate per event —
which is the entire thing being avoided. One visitor is built per scan and
carries the term, so the walk allocates nothing and stops at the first field that
matches: a hit on the title never builds the body.

`indexableContent()` is untouched and stays the store contract, so the
externally-mirrored kind table needs no reindex. The default visitor falls back to
it — already free for the ~28 kinds whose indexable content is `content` itself —
and the kinds local search actually scans override it: text notes, long-form,
wiki, highlights, classifieds, live activities and community definitions.

`EventSearchMatcher` matches an event against a NIP-50 search string in memory:
terms ANDed, each a case-insensitive substring of a tag value or an indexable
field, with unsupported extensions ignored per the spec so an extensions-only
search matches everything rather than nothing. Substring because that is what
Amethyst's local search has always done and tokens would silently stop matching
mid-word; AND because that is what a relay does with the same string.

A test pins the two paths together: for every overriding kind, the visitor's
fields rejoined must equal `indexableContent()` byte-for-byte, including the
subsets where a null field is what makes a join drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-08 00:07:59 +00:00
Claude 2d229d859e test(images): stop the reconciler's cadence tests racing the clock and eviction
Two flakes in ImageDiskCacheReconcilerTest, both mine, both green locally and
red on CI.

aStartAfterTheIntervalIsDueAgain measured the interval from the clock it
sampled, but isDue() compares against the marker's file system mtime. A file
system that keeps mtime at whole-second resolution reads the marker back up to
a second before the write that made it, so `now + interval - 1` was already past
the interval and the pass ran when the test expected it skipped. Reproduced
exactly by truncating the marker's mtime to its whole second: same result object
CI reported, ceilingBytes and all.

It now pins the recorded pass to a whole second and measures from that, so the
boundary holds at any mtime resolution — and asserts the file system kept the
value, so an environment that cannot would fail loudly instead of flaking.
Renamed to theIntervalIsMeasuredFromTheRecordedPass, which is the property.

aSecondStartWithinTheIntervalSkipsTheWalk asserted the directory's byte total
was unchanged across the skipped call. Coil evicts asynchronously on its own
scope and the drainer unlinks behind it, so the two measurements raced both:
CI saw 24103 where the test had recorded 25127. What the test needs to rule out
is a wipe, and clear() takes DiskCache.size to zero — so it asserts on that
instead, which no amount of eviction churn can move.

Verified by running the class ten times, and by running CI's own task list
locally (both lintBenchmark variants and both unit-test variants — the pre-push
hook covers only testPlayDebugUnitTest, which is how these reached CI).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RYCgbvhtBCBNVLMxSWoCJ
2026-09-07 23:59:13 +00:00
Claude 5c77643e9d perf(quartz): stop FilterMatcher allocating once per event
`FilterMatcher.match` runs once per event per candidate filter. On a full-cache
scan that is tens of thousands of calls per keystroke, and it allocated three
different ways inside that loop:

- `tags.forEach { tag.value.toSet() }` built a Set per event, per tag key.
- `tagsAll` built a MutableSet per event per key, via a full walk of the event's
  tags, before checking anything.
- `event.tags.any { }` allocated an iterator over the tag array each time, where
  the repo's hot-path rule calls for the in-place `fast*` operators.

All three are replaced by indexed loops that read the tag array in place. The
filter's own value lists are short — a handful per key — so a linear `contains`
over them beats hashing and needs no allocation to do it.

No API change, so all 33 feed filters, FilterIndex and geode's MirrorWorker get
this without touching their call sites.

Guarded by a differential test that keeps the previous implementation verbatim as
an oracle and fuzzes 20,000 random event/filter pairs against it — including the
edges a hand-written set would have missed: empty value lists, absent tag keys,
duplicate values, short and empty tags, and `tagsAll` values spread across two
tags. Plus explicit since/until inclusivity, which the rewrite restates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-07 23:52:08 +00:00
Claude c17e26574b docs(amethyst): plan retiring CacheSearch for LocalCache.filter(Filter)
Records why local search cannot use the generic path today (FilterMatcher takes
every NIP-01 field except `search`), what CacheSearch does that a wire Filter
cannot express, and the two performance findings that shape the design:

- `FilterMatcher.match` allocates inside the per-event loop — a Set per event per
  tag key, a MutableSet plus a full tag scan for tagsAll, and stdlib `any` on the
  tag array where the hot-path rule calls for `fastAny`. Tolerable at feed-rebuild
  rates, not on a full-cache scan per keystroke, and the search field's own
  multi-spelling tag filters make it worse. The fix — hoisting loop-invariant work
  into a prepared matcher — pays for every existing caller, so it leads.
- `indexableContent()` is a write-path API: 86 of 126 implementations allocate a
  list, a StringBuilder and a joined String per call, and the JSON kinds reparse
  on every call with no cache. Proposes a `fun interface` field visitor that
  allocates once per scan rather than once per event and short-circuits on the
  first hit, with `indexableContent()` kept and derived so its output stays
  byte-identical for the stores and the externally-mirrored kind table.

Also records the correctness hazard that decides sequencing: `Filter.match` is
reached by 33 feed filters, FilterIndex and geode's MirrorWorker, so honouring
`search` must be opt-in until those are audited.

Three decisions are left open for a human: substring vs token matching locally,
where viewer policy applies, and whether relevance ordering needs a score out of
the matcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-07 23:39:11 +00:00
Claude 762018c0e3 feat(ratings): rebuild the rating card around cover art and accessible stars
The first pass was functional but plain: it parsed PublicationIndexEvent.image()
and .summary() and then used neither, so a book review card had no book cover —
just a generic MenuBook glyph sitting in the slot where the artwork belongs.

- Cover-led card. MyAsyncImage in a 2:3 portrait thumbnail (a book cover and a
  film poster share that ratio), with the repo's existing loading/error
  fallbacks, falling back to a tinted plate carrying the mark's icon.
- The icon now follows the mark rather than always being a book: movies, a
  profile, a relay and a hashtag each get their own, with Article as the
  generic. Adds MaterialSymbols.Movie (U+E404) and regenerates the subset font.
- Subtitle line shows the author, or the summary when there is no author.
- One RatedTargetCard for both the resolvable and unresolvable target, so the
  two cannot drift apart visually. The cover is a slot rather than three
  nullable image parameters.
- The star row was invisible to screen readers: five icons with a null
  contentDescription announce nothing. It now carries one merged
  contentDescription ("Rated 4.5 out of 5").
- The numeral appears only for a fractional score. It was redundant next to
  five filled stars, and it was previously shown only for exact halves, which
  silently dropped the .2 of a 4.2.
- Empty stars are tinted at 0.4 alpha so the filled ones carry the eye.
- Theme size tokens instead of hardcoded dp.
- Second @Preview for the whole card, including the long-title and
  no-cover cases.

Not a <plurals>: the score is fractional, and plurals need an integer quantity.
The string avoids a counted noun so no locale has to decline it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-07 23:31:22 +00:00
Claude 4c68d2202c feat(ratings): parse kind-34259 entity ratings and show them in the Home feed
Kind 34259 was dropped at LocalCache's "Event Not Supported" fallback, so it
never became a Note and nothing could render it. Same for kind 30040, the
publication these ratings point at.

The upstream spec (XYZ.md in abh3po/nostr-polls, "Pollerama") is a generic
"rate anything" addressable kind with three tags — d, m, rating — so
EntityRatingEvent is generic too; books is just the first `m` mark rendered
richly. The a/A/e/k/p/s/c tags publishers add on top are parsed as extensions.

Two parsing traps drove the design:

- `["rating", "1"]` is ambiguous: 1.0-of-1 to a spec publisher, 1-of-5 to a
  raw-scale one. stars() resolves it with a four-step ladder — an `s` tag
  inside 1..5 wins outright, then a 0..1 fraction (closed interval, because a
  full score really is published as "1.000" despite the spec's "less than 1"),
  then a raw 1..5 count, then null. Never zero, which would misreport the
  author.
- `d` carries a `<mark>:` prefix, so the coordinate has to be stripped back
  out before it parses as an Address. `a`/`A` are preferred when present.

Feed visibility needs three gates opened, all of them: the REQ kind list, a
HomeFeedType group (which drives both the REQ strip and the DAL), and
HomeNewThreadFeedFilter's ADDRESSABLE_KINDS — the last is required because
feed() only scans LocalCache.notes for kind < 10000.

No computeReplyTo branch: the a/e tags name what is rated, not a parent, and
populating replyTo would flip isNewThread() and silently drop the card out of
the New Threads tab. A test pins that.

PublicationIndexEvent (30040, NKBIP-01) is parsed so the rated work resolves
to a real title; the reader and kind 30041 sections are deliberately not
implemented.

Adds MaterialSymbols.StarHalf (U+E839, its own glyph) and regenerates the
subset font. Note that star/star_border/star_outline sharing U+F09A is NOT a
bug — Material Symbols expresses fill through the FILL variable axis — so
filled and empty stars are one glyph at two tints.

Tests: 44 new, full suites green (quartz 4491, commons 1788, amethyst 1415).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-07 22:09:12 +00:00
Claude 3be8bbea2a refactor(search): drive the search field's people picker from the composer's own
The search field shipped with its own people lookup — a `mentionQuery` flow over
`LocalCache.search.findUsersStartingWith` and a hand-rolled row list. Amethyst
already had a better one: `UserSuggestionState` + `ShowUserSuggestionList`, the
@-mention picker behind the post composer, the channel and Concord composers,
badge awards and the group member screens.

That duplicate was not merely redundant, it was worse. It resolved no NIP-05, so
`from:vitor@nostr.com` found nobody while `@vitor@nostr.com` in a post found the
right person; it never asked the search or indexer relays, only the local cache;
and it ranked nothing, so follows did not come first.

`commons` cannot call that stack — it is built on `Account` and
`AccountViewModel` — so `TokenizedSearchField` takes a `peoplePicker` slot and
keeps only what is genuinely shared: when the picker opens, and what a pick
splices into the text. Android fills the slot with the composer's list. Desktop
keeps its `UserSearchEngine` path through the built-in, keyboard-walkable list,
which the slot deliberately does not replace: a slot owns its own selection
affordance, so the arrow keys stay with the caret while one is up.

Also fixes an inconsistency in the desktop advanced panel: its "Until" box
resolved to the *start* of the named day, so a day typed there excluded almost
all of itself while the same day written as an `until:` token included it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-07 21:43:01 +00:00
Claude 47db7e242f feat(search): port the vespa-relay search field's token language to Amethyst
The relay's web client parses its search box into NIP-01 filter fields —
`from:`/`to:`, `since:`/`until:`, `#tag`, `label:`, `group:` and the NIP-73
scopes — rather than handing everything to NIP-50. Filter fields are index
lookups, so a chip narrows a search without competing with the relay's own
relevance ranking. This brings that language, and the field that draws it, to
Amethyst.

The scanner is the single source of both the chips and the REQ, so a chip can
never claim a filter the query does not send:

- `SearchTokenizer` scans the typed string into segments that cover it exactly,
  each carrying its raw text so the field can draw over precisely the
  characters it stands for. A corrupt value — a failed bech32 checksum, a day
  that does not exist, a scope that asks nothing — stays plain text.
- `SearchFilterBuilder` turns a query into the filters it sends. A hashtag is
  three questions (`#t` on the event, `#l` on a label, `#i`/`#I` on comments
  written about it) and one filter ANDs its tag fields, so those fan out into a
  union with a smaller limit on the secondary arms.
- `PartialTokens` says which half-written token the caret is in, and therefore
  which picker belongs under the field. Derived from the text and the caret, so
  a blurred field picks up where it left off.
- Dates are the reader's own local day, inclusive at both ends: a search saved
  in one timezone and reopened in another still names the same day. `LocalClock`
  is the only platform surface; the calendar arithmetic is pure and tested.

`QueryParser` now runs on top of the scanner and keeps its looser second pass
(`kind:`, `lang:`, `domain:`, `OR`, `-exclusions`, quoted phrases) over what is
left, so nothing that parsed before stops parsing.

On the UI side `TokenizedSearchField` draws tokens as chips over a plain text
value — the value is never rewritten, so undo, IME, selection and copy-paste
keep working and a query survives being shared as text. Wired into the Android
search bar, the desktop search screen and the desktop spotlight; the desktop
`SearchFilterFactory` now delegates to the shared builder and keeps only its
kind window.

Adds 68 tests, including every offset of every fixture through the field's
offset mapping in both directions — Compose crashes on an out-of-bounds answer.

Not verified: the iOS `LocalClock` actual. The Kotlin/Native toolchain cannot be
fetched in this environment, so that file is written against Foundation APIs the
repo already exercises and is unbuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
2026-09-07 20:38:32 +00:00
Claude 089b28fc9a docs(amethyst): plan for kind-34259 entity ratings in the Home feed
Kind 34259 has no parser, no LocalCache branch and no renderer today, so
the events are dropped at LocalCache's "Event Not Supported" fallback.

The plan covers: a generic EntityRatingEvent in quartz/experimental/ratings
(the upstream Pollerama spec is entity-agnostic — books is only one `m`
mark), the two parsing traps (`rating` is ambiguous at exactly 1 unless the
`s` tag disambiguates; `d` carries an `m:` prefix), the LocalCache
addressable branch, and the three separate gates that must all open for a
kind to reach the Home feed (the REQ kind lists, a HomeFeedType group, and
HomeNewThreadFeedFilter's ADDRESSABLE_KINDS + acceptableEvent).

Also records why v1 skips computeReplyTo (it would flip isNewThread and
drop the card out of the New Threads tab), the kind-30040 dependency for
resolving the rated publication's title, and that MaterialSymbols.Star and
StarBorder currently share one codepoint so a star row needs the subset
font regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NSumuEaxE4D1jvStUFw7M
2026-09-07 19:35:21 +00:00
Claude 0abcf02384 perf(images): rate-limit the cache reconciler to once a day
The reconciler ran on every AppModules.initiate(), which is every process
start — including the WorkManager wake-ups that cold-start the whole graph, and
which the ledger already counts because that churn is a known problem. Even the
healthy pass is a readdir plus a stat per file: on a full 1 GB cache that is
tens of thousands of syscalls, paid on every start, to look for drift that
accrues only when a process dies with unlinks still queued.

reconcileIfDue() gates the walk on the mtime of an empty `.reconciled` marker in
the cache directory, so a start inside the interval costs one stat instead. The
marker lives with the thing it describes: clearing the app's cache from Settings
takes it too, and the next start reconciles a directory whose history we no
longer know.

A marker dated in the future — a clock that jumped back, or a restored backup —
counts as due, so it cannot park the check until real time catches up.

The marker is a plain file in the swept directory, so it joins the journal files
in the preserved set. reconcileIfDue() rewrites it after a pass anyway, which is
exactly why theMarkerSurvivesAWipe drives reconcile() directly — through
reconcileIfDue() the rewrite masks the deletion and the test guards nothing.
Verified by mutation: dropping the marker from the preserved set fails that
test, and short-circuiting isDue() fails both interval tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RYCgbvhtBCBNVLMxSWoCJ
2026-09-07 15:41:29 +00:00
Claude 8ded3c13c5 fix(images): reclaim image-cache files orphaned by a process death
DeferredDeleteFileSystem moves Coil's eviction unlink() off the DiskLruCache
lock by queueing it in memory. Anything still queued when the process dies is
never unlinked — and Coil cannot recover it: DiskLruCache.processJournal()
derives `size` purely from the journal's recorded lengths and never scans the
directory for files it does not know about. The orphan counts toward neither
`size` nor eviction, so the cache directory keeps the residue of every killed
process, forever, drifting past its own cap.

Measured against the real Coil DiskCache: 48 unlinks lost to one process death
left Coil reporting 16 KB against a 16 KB budget while the directory actually
held 48 KB, and reopening as a fresh process reclaimed none of it. With
maxSizePercent(0.2) capped at 1 GB, that drift is unbounded over the app's life.

ImageDiskCacheReconciler runs once at startup on the IO scope: it walks the
cache directory and, only if it holds more than its budget plus slack, empties
it. Two steps, because DiskCache.clear() alone is not enough — evictAll() walks
lruEntries, exactly the set of files the journal knows about, so it goes right
past the orphans. So: clear() to make the journal's truth empty, then unlink
every non-journal file left in the directory, which is by then unreferenced by
definition. The unlinks go through the same deferred file system as any other
eviction; the pending set dedupes the paths clear() already queued.

It is deliberately blunt — it costs the whole cache — so the trigger sits well
past what normal operation needs: budget + max(25%, 4 MiB). Async eviction and
the dirty files of writes in flight both put a healthy directory a little over
budget; only real drift is wiped. On a healthy cache the pass is one directory
walk and nothing else, and it never reads DiskCache.size, so it does not force
the journal parse on the happy path or couple to Coil's on-disk format.

The startup call is also the one place that forces the `diskCache` lazy, so its
build (a statvfs for the size budget) and this walk both land on IO rather than
on whichever thread happens to load the first image.

Tests cover the end-to-end leak (inert drainer stands in for the dead process,
then a fresh cache over the same directory reconciles it back under budget), the
healthy no-op, drift inside the slack, a missing directory, and the shipped
ceiling arithmetic. clearAlone_wouldNotHaveReclaimedThem pins the reason this
class exists — if Coil ever learns to sweep, it fails and the class can go.
Verified by mutation: dropping the orphan sweep fails the leak test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RYCgbvhtBCBNVLMxSWoCJ
2026-09-07 15:15:07 +00:00
Claude 8b594981e9 fix(images): restore the ImageDecoder path for still images too
The identity check that cost animated images their file source cost still
images a decoder outright: `StaticImageDecoder.Factory` returns null when it
cannot get an `ImageDecoder.Source`, so with our `DeferredDeleteFileSystem`
on the source every network still image fell through to
`BitmapFactoryDecoder`.

Move the re-home from the animated decoder to the fetcher, where it fixes
both at one seam. `SystemFileSystemFetcher` wraps the three network-backed
fetchers the app builds (`OkHttpFactory`, `BlossomFetcher`,
`ProfilePictureFetcher`) and re-points a disk-cache-backed result at
`FileSystem.SYSTEM` before any decoder sees it.

Doing it here rather than at each decoder:

- One wrapper covers every decoder, including Coil's own registered
  `StaticImageDecoder.Factory`. Adding a second static factory would instead
  have introduced a second decode-parallelism semaphore alongside the one
  Coil's bitmap decoders share, and put us in charge of registry ordering
  against SVG and video frame decoding.
- It is the only place that still knows the disk cache key.
  `FileImageSource.diskCacheKey` is internal to Coil and cannot be copied off
  an existing source, but `NetworkFetcher` derives it as
  `options.diskCacheKey ?: url` — so the wrapper is handed the same value and
  `SuccessResult.diskCacheKey` survives the swap.

The re-homed source takes ownership of the one it replaces (`closeable =
this`), since the engine closes exactly one source per fetch and that is now
the replacement — without it the disk-cache snapshot would leak.

`AnimatedImageDecoderFactory` keeps only the frame-delay scan, which is a
separate matter: Coil's sub-threshold rewrite is stream-backed and would
forfeit the file source again for GIFs on API < 34.

Tests: `SystemFileSystemFetcherTest` covers the swap, the pass-throughs
(already on SYSTEM, unknown wrapper, stream-backed, non-source result,
declining fetcher), the ownership transfer and the carried key — the last
two verified by mutation. `SystemFileSystemImageDecoderInstrumentedTest`
pins the platform half the JVM tests cannot reach: that
`toImageDecoderSourceOrNull` really does return null on our disk cache's
file system and non-null once re-homed, and that `StaticImageDecoder.Factory`
declines the former and accepts the latter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RYCgbvhtBCBNVLMxSWoCJ
2026-09-06 22:27:35 +00:00
Claude 95f70206aa fix(images): stop copying whole GIFs into RAM before decoding them
A 69.8 MB, 1138x640, 201-frame GIF from a Ditto note froze the feed. The
media is pathological, but the app made it far worse than it had to be.

Coil hands `android.graphics.ImageDecoder` a *file* only when the image
source's file system is **referentially** `FileSystem.SYSTEM`
(`ImageSource.toImageDecoderSourceOrNull`, Coil 3.5.0):

    if (fileSystem === FileSystem.SYSTEM) {
        val file = fileOrNull()
        if (file != null) return ImageDecoder.createSource(file.toFile())
    }

`NetworkFetcher` stamps the source with `diskCache.fileSystem`, and ours is
a `DeferredDeleteFileSystem` wrapper, so that identity check fails for every
image we fetch from the network. For an animated image the fallback is
`ImageDecoder.createSource(source.squashToDirectByteBuffer())`: the entire
encoded animation is pulled onto the heap and then copied into an equally
large direct `ByteBuffer` that stays alive for as long as the
`AnimatedImageDrawable` does. Replaying that path over the reported GIF
measured 66 MB of heap plus 66 MB of native memory, and ~700 ms of pure
copying on desktop x86. Animated results are never memory-cached
(`DrawableImage.shareable` is false), so the feed paid it again on every
scroll back into view.

Coil then compounds it below API 34: it wraps every GIF in a stream-backed
`FrameDelayRewritingSource` to clamp sub-threshold frame delays, which
forfeits the file fast path even when the identity check would have passed.

So:

- `onSystemFileSystem()` re-points a disk-cache-backed source at the real
  `FileSystem.SYSTEM` before it reaches a decoder. Only deletes are deferred
  by the wrapper; reads already go straight through.
- `hasSubThresholdGifFrameDelay()` streams a 64 KiB window over the file and
  asks for the rewrite only when a graphics control block really declares a
  delay below 2/100 s. The reported GIF declares 5 on all 201 frames, as do
  the overwhelming majority of GIFs, so they now decode from the file with
  no heap copy at all. Files that do need the clamp keep Coil's behaviour.
- `AnimatedImageDecoderFactory` replaces Coil's factory with the same sniff
  and those two changes; `AvifAnimatedDecoderFactory` shares it.

Still images take a similar hit from the same identity check (they fall back
from `StaticImageDecoder` to `BitmapFactoryDecoder`) — left alone here since
it changes the decode path for every image in the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RYCgbvhtBCBNVLMxSWoCJ
2026-09-06 21:09:12 +00:00
Vitor PamplonaandGitHub 33ef4f6305 Merge pull request #4060 from vitorpamplona/claude/amethyst-commons-migration-ua8ma8
Move the thread, badges, app-recommendations, connected-apps and home datasources to commons/relayClient
2026-09-06 13:44:03 -04:00
Claude c5bd671c5c test(commons): drop commas from three commonTest names that break the iOS test compile
Kotlin/Native rejects backtick identifiers containing "," ("Name contains
illegal characters"), so `:commons:compileTestKotlinIosSimulatorArm64` has
failed since #4059 moved these relay-group tests from the app's JVM-only
test set into commonTest. JVM never minded, which is why the pre-push hook
stayed green. Reworded the three names; behaviour unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
2026-09-06 00:02:58 +00:00
Claude fe81faef7e docs(commons): repoint KDoc links that still named app-module classes
The relay-group, connected-apps and poll-responses files moved into commons
still linked to classes by their old amethyst paths (or to app-only screens
commons cannot see). Point them at the commons class where one exists and
use plain text where the target stays in the app.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
2026-09-05 22:52:13 +00:00
Claude 58ada4e32e refactor(commons): move the thread, badges, app-recommendations, connected-apps and home datasources to commons
The four families that read one relay-set state off `Account` now carry that
set as a `StateFlow` on their key, the way `SearchQueryState` does:
`ProfileAppRecommendationsQueryState(outboxRelays, defaultGlobalRelays)`,
`ProfileBadgesQueryState(notificationRelays)`,
`ConnectedAppsQueryState(homeRelays)` and `ThreadQueryState(defaultRelays)`.
Thread also takes the cache: `ThreadFilterAssembler(cache, client)`, and its
two sub-assemblers run `ThreadAssembler(cache)`.

The home feed moves whole. Its `FilterHomePosts*` dispatchers were already
import-clean; `HomeOutboxEventsEoseManager` becomes a
`TopNavFeedSubAssembler<HomeQueryState>` whose key carries the new-threads
and replies floors plus `enabledHomeFeedTypes`. The Settings › Home toggle
stays an unsampled invalidator (still `drop(1)`) via `extraInvalidators`,
and the disabled-kinds stripping is unchanged. Same deltas as the other
feeds: watchers on the screen scope, follows sampler 500 ms (was 1000 ms).
The commented-out alternative sub-assemblers in `HomeFilterAssembler` are
dropped.

The app keeps one `*Subscription.kt` per feature, building the key from
`AccountViewModel`. The algo-feed filter test moves to commons commonTest
on `kotlin.test`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
2026-09-05 21:32:57 +00:00
Vitor PamplonaandGitHub 15b6aace52 Merge pull request #4059 from vitorpamplona/claude/amethyst-commons-migration-ua8ma8
Move the relay-filter subassemblies and datasource assemblers to commons/relayClient
2026-09-05 17:13:19 -04:00
Claude 1e04f7b06e fix(commons): audit follow-ups for the shared relayClient layer
Review findings on the three migration commits, all verified:

- The shared feed-floor watcher sampled at 5 s for every feed, but the
  video swipe feed used 1 s before the refactor, so paging older videos
  waited up to five times longer. The interval is now a `floorSampleMs`
  override and Video keeps its 1 s cadence.
- `endSub` cancelled the watcher jobs but never dropped the `userJobMap`
  entry, leaving a cancelled job list per account ever subscribed on every
  app-lifetime assembler. It now removes the entry.
- `DesktopLocalCache.consume(nip19)` was a copy of the Android body. The
  NIP-19 seeding is now one default on `ICacheProvider`, built only from
  interface members, with a single `consumeEmbedded(event)` hook for the
  `nembed` branch (Android verifies via `justConsume`, Desktop via its
  `consume(event, relay)`). The default uses `checkGetOrCreateNote`, so an
  invalid id yields no placeholder instead of an exception.
- `allRelayGroupChannels()` dumped the group cache through a predicate that
  always returned true; it now snapshots `values()`.
- The music/podcast kind lists go back to `internal`: their only callers
  moved into commons with the dispatchers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
2026-09-05 20:59:34 +00:00
Claude eb76907720 refactor(commons): move the import-clean datasource families to commons/relayClient
The datasource families whose keys are a User, an AddressableNote, a string,
or an IAccount-compatible account — chess, relay feed, NIP-66 relay info,
url, geohash, the channel assembler, communities, git repo, profile, one/my
podcast, onchain zaps and poll responses — move wholesale into
`commons/relayClient/<feature>/`, along with the pure thread filter
functions and `FilterPostsByScopes.kt` (`CommentKinds`), which those filters
share with the home feed. Three Subscription composables that never touched
`AccountViewModel` (community, repository, profile) come along; the ones
that do stay in the app.

The only seam was `LocalCache`, read for `relayHints` and
`checkGetOrCreateUser`, both already on `ICacheProvider`. The affected
assemblers take `cache: ICacheProvider` first, thread it into their
sub-assemblers, and the filter functions that need it take it as their first
parameter; `RelaySubscriptionsCoordinator` passes the cache it already holds.
`PollResponsesQueryState` is typed on `IAccount`.

The URL filter test moves to commons commonTest on `kotlin.test`. The plan
doc records what stays behind and why.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
2026-09-05 20:00:10 +00:00
Claude 693594a44c refactor(commons): share the top-nav feed datasource layer in commons/relayClient
The 26 datasource families that dispatch to the Batch-2 filters — an
`XFilterAssembler`, an `XSubAssembler` and an `Account`-typed `XQueryState`
per top-nav feed — were 26 copies of the same ~90 lines, differing only in
the `makeXFilter` dispatcher and which feed floor bounds `since`. They now
sit on three shared classes in `commons/relayClient/topNavFeeds/`:

- `TopNavFeedQueryState(account: IAccount, listName, followsPerRelay, scope,
  feeds)` is the one key type. Discovery and SoftwareApps subclass it for
  their seven named tabs and the blocked-relay set respectively.
- `TopNavFeedSubAssembler<K>` carries the shared EOSE-manager wiring (list
  watcher, per-relay follows sampler, combined feed-floor sampler, plus
  `onListChanged` / `extraInvalidators` hooks); `SingleTopNavFeedSubAssembler`
  is the concrete one every single-dispatcher feed uses, with the calendars'
  EOSE reset as a flag.
- `TopNavFeedFilterAssembler<K>` owns the sub-assemblers, so each feature's
  assembler is a one-liner naming its dispatcher and the coordinator and the
  `*Subscription` composables keep their types.

The pure `makeXFilter` dispatchers moved next to their filters. The app keeps
only the Compose glue: each `XFilterAssemblerSubscription` builds the key via
`AccountViewModel.topNavFeedQueryState(...)`, which is where the `Account`,
`AccountSettings` and `AccountFeedContentStates` reads now live. Video is
refit onto the shared key, replacing the `VideoQueryState` from the previous
batch, with its inline dispatch extracted to `makePictureAndVideoFilter`.

Small deliberate deltas: all watchers run on the screen scope (the floor
watcher used `account.scope`), the follows sampler is 500 ms everywhere
(two feeds had 1000 ms), and Video gained the list-name watcher.

The plan doc records this batch and the desktop `FilterBuilders.kt`
investigation: it is a plain-`Filter` broadcast factory, not a copy of the
subassemblies, and the three ways to reconcile the two models are written
up for a decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
2026-09-05 19:13:27 +00:00
Claude 5fbd1b7f96 refactor(commons): move the relay-filter subassemblies to commons/relayClient
The audit's Batch 2 — the 161 `ui/screen/**/subassemblies/` filter functions
that turn a top-nav selection into per-relay REQs — was never executed by the
earlier waves. They are import-clean (no Android, no R, no Account), so they
move mechanically into `commons/relayClient/<feature>/…`, dropping the
`subassemblies` path segment to match how Batch 1 landed.

Pure companions the 161 needed came along: the picture/video kind lists
(`FeedBasis.kt`), `RelayGroupFilterBuilders.kt` with its test, and the two
`*_PAGE_LIMIT` constants. The three query-state keys the sub-assemblers are
typed on now live in commons on `IAccount`: `ChannelQueryState`,
`SearchQueryState` (carries the search / indexer / follow-plus-mine relay
sets as flows) and `VideoQueryState` (carries the list name, per-relay
follows and feed-floor flows), so nothing in commons reaches into `Account`
or `AccountFeedContentStates`.

`ICacheProvider` grows the seams the six `LocalCache`-coupled files needed:
`consume(nip19)` for search-query seeding, plus `allRelayGroupChannels()` /
`getRelayGroupChannelsOnRelay()` for the relay-group discovery back-fill.
`DesktopLocalCache` implements `consume`; the relay-group lookups default to
empty for caches without NIP-29 support. The search `filterBy*` and the
set-level relay-group filters take the cache as a parameter instead of the
`LocalCache` singleton.

The four tests moved to `commons/src/commonTest` on `kotlin.test`, and the
misnamed `shorts/FilterPollsByAllCommunities.kt` is now
`FilterShortsByAllCommunities.kt`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J836W9ZjSUJUQ1d23TpiJo
2026-09-05 17:33:40 +00:00
Vitor Pamplona a22bc0db14 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-09-04 11:50:33 -04:00
Vitor Pamplona a31364b999 Fixes lilked and reposted colors 2026-09-04 11:48:56 -04:00
David KasparandGitHub c4601decaf Merge pull request #4058 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-04 15:47:06 +02:00
vitorpamplonaandgithub-actions[bot] 2ccdd08bae chore: sync Crowdin translations and seed translator npub placeholders 2026-09-04 12:41:45 +00:00
Vitor Pamplona 9b589b4d66 Updates AGP to 9.4.0 2026-09-03 21:57:02 -04:00
Vitor PamplonaandGitHub f5e2f2bb59 Merge pull request #4057 from vitorpamplona/claude/amythyst-blue-background-e3slhf
Sync in-app theme to system per-app night mode for splash screen
2026-09-03 18:22:04 -04:00
Claude a6d26edf79 refactor(theme): drop the applied-night-mode bookkeeping key
UI_APPLIED_NIGHT_MODE existed to skip a repeat setApplicationNightMode call
on launches where the theme had not changed. Reading the platform, that
saves much less than it looked like, and costs more.

What the call actually does when the mode is unchanged: nothing expensive.
PackageConfigPersister.updateFromImpl compares the new mode against the
stored record and returns early without writing when it matches, and
ActivityRecord.applyAppSpecificConfig gates the activity reconfiguration on
having actually changed. What remains per launch is one Binder round trip,
already off the main thread via flowOn(Dispatchers.IO).

Against that, the key was a private copy of state the app does not own.
There is no public getter for the per-application override, so it could
only ever be a shadow -- and a shadow that drifts (anything resetting the
override out from under us) makes the app skip the call precisely when it
is needed, leaving the splash silently wrong with no way to recover.
Re-sending the value every launch converges instead.

This is also why the deduplication in applyLanguage does not generalise to
here, though the two look alike: that one compares against
getApplicationLocales(), the authoritative value, and was justified by a
measured ~220ms main-thread cost with a StrictMode violation behind it.
Neither property holds for night mode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDAVkn1krKjijoBdja1Ruo
2026-09-03 22:18:49 +00:00
Claude d146391bdf feat(theme): carry a pinned light/dark choice into the launch splash
The previous commit made the splash follow the phone's light/dark setting,
which is correct for the default ThemeType.SYSTEM but still wrong for
someone who pins LIGHT or DARK against their phone: the system composites
the splash from the manifest theme before the process starts, resolving it
against the app's configuration, and the in-app preference lives in a
DataStore the system knows nothing about.

UiModeManager.setApplicationNightMode is the mechanism that closes this.
It commits a persisted *per-package* configuration override — the service
hands the mode to ActivityTaskManagerInternal.PackageConfigurationUpdater —
which the system then applies when it launches the app, splash included.

This is the per-application setter, not the device-wide setNightMode that
726f3e39 removed. That removal was right: setNightMode changes every app
on the device and is gated behind MODIFY_DAY_NIGHT_MODE, which Amethyst
does not hold, so it was a silent no-op. The per-application setter is the
alternative the framework docs point app developers at, and it is not
permission-checked — UiModeManagerService.setApplicationNightMode only
validates the mode argument before committing.

Mapping, per the same service method: DARK -> MODE_NIGHT_YES, LIGHT ->
MODE_NIGHT_NO, SYSTEM -> MODE_NIGHT_AUTO. AUTO is not "auto" here; the
service maps everything other than YES/NO onto UI_MODE_NIGHT_UNDEFINED,
which clears the override so the app falls back to the device config —
exactly what SYSTEM wants when a user un-pins.

Wired next to applyLanguage in UiSharedPreferences, which already does the
same shape of work for AppCompat locales: an eagerly-started flow on the
preference, off the main thread, deduplicated so an unchanged value costs
nothing. Deduplication matters because the call is a Binder round trip that
pushes a configuration change into every running activity of the package.
The applied mode is recorded only after the call returns, so a failure is
retried next launch rather than remembered as done, and it is kept out of
UiSettings because it is bookkeeping rather than a user setting.

Two properties that make this safe, both checked rather than assumed:
MainActivity already declares `uiMode` in its configChanges, so the
resulting configuration change goes to onConfigurationChanged instead of
recreating the activity; and the write is guarded to API 31+, where
setApplicationNightMode exists (verified present in android-37).

The effect lands on the next cold start — the current launch's splash is
already painted by the time any app code runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDAVkn1krKjijoBdja1Ruo
2026-09-03 21:52:46 +00:00
Claude 32f5647461 fix(theme): let the launch splash follow the phone's light/dark setting
726f3e39 pinned `windowSplashScreenBackground` to `@color/purple_700`
(#3700B3) in both `values/` and `values-night/`. That fixed a real bug —
the splash used to inherit the AppCompat window background and could flash
the wrong colour — but by writing the same value into both qualifier
buckets it also removed the system's ability to pick one, so every launch
showed the same deep indigo regardless of the phone's theme. #3700B3 is
also a leftover from the Android Studio Material 2 template: it appears
nowhere in the Compose colour schemes, whose brand purple is #9A82DB.

The system composites the splash from this theme before the process
starts, resolving it against the phone's UI-mode configuration, so the
day/night resource qualifiers are already the mechanism for following the
phone. Restore that by giving each bucket its own colour, and point both
at the value MaterialTheme itself uses for `background`:

  values/       splash_background = #FDFDFD  (lightColors.background)
  values-night/ splash_background = #000000  (darkColors.background)

Matching the scheme rather than a brand colour means the splash hands off
to the first composed frame with no visible step.

`windowBackground` is set to the same colour in both buckets — it is what
paints the splash below API 31 (minSdk is 26), so pre-31 devices now track
the phone's theme too. It stays an opaque colour, so the window remains
opaque and SurfaceFlinger can still skip the layers beneath it; that was
the ~17% frame-P90 regression 726f3e39 measured when the background was
cleared, and it is unaffected here.

Verified by dumping the linked resource table with aapt2: color/
splash_background resolves to #fffdfdfd by default and #ff000000 under
(night), and Theme.Amethyst carries the attribute in both the () and
(night) variants.

Note this follows the *phone's* setting, which is correct for the default
ThemeType.SYSTEM. A user who pins LIGHT or DARK against their phone still
gets a splash matching the phone, because the in-app preference is not
visible to the system at launch time. Making the splash follow the pinned
preference needs UiModeManager.setApplicationNightMode, which is a
behavioural change and is left for a separate commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDAVkn1krKjijoBdja1Ruo
2026-09-03 21:19:00 +00:00
Vitor PamplonaandGitHub fe4a982b9f Merge pull request #4056 from vitorpamplona/claude/compose-escaping-fix-f7g5em
ci(crowdin): convert Android escaping to Compose escaping during the sync
2026-09-03 16:45:25 -04:00
Claude b667fb0f4a ci(crowdin): convert Android escaping to Compose escaping during the sync
Both entries in crowdin.yml are declared `type: android`, so Crowdin's
Android serializer escapes apostrophes on the way down: `l'URL` comes
back as `l\'URL`. That is right for amethyst/src/main/res/, which aapt
un-escapes at build time, and wrong for commons/.../composeResources/,
where Compose resolves only \uXXXX, \n and \t and leaves \' \" \? \@
alone -- so the backslash reaches the screen.

Nothing prevented this, so every sync reopened the same regression and
CI's compose_escaping_check.py failed on the bot's own PR. It happened
three times (f9baab0e, 1685d7c0, e223d505), most recently 2,888
occurrences across 40 locale files, each time repaired by hand after the
fact. Convert on the way in instead, so the PR is born clean.

Two steps, placed after the ownership fix (the Crowdin container writes
as root, so the tree is not writable before it) and before the PR is
opened:

- Convert: runs the documented repair over the Compose catalog only, so
  the Android res tree keeps the escaping it needs. --no-unwrap-quotes
  is mandatory -- escape conversion is idempotent, quote-unwrapping is
  not, and a second unwrap would strip the real display quotes from
  values like import_follows_tips.
- Verify: re-runs the check that guards main, so a case the converter
  cannot repair fails the sync loudly here instead of opening a red PR.

Verified by replaying both steps against the real Crowdin output on
l10n_crowdin_translations (df930817): the check reproduces the failure
at 2,888 occurrences, the convert step fixes 1,996 entries across 40
files, the verify step then exits 0, amethyst/src/main/res/ is left
untouched, and the resulting catalog is byte-identical to main -- so
with this in place that sync would have carried no string changes at
all.

Known gap, documented inline on the verify step: fix_escapes.py only
rewrites text inside <string>/<item> elements while the check scans the
whole file, so an escape in an XML comment (comments do propagate into
the locale files) would fail the gate without the converter being able
to repair it. No such comment exists today; it has to be fixed at the
source string by hand if one ever appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVrW3p8NGWHgLbN673Fet2
2026-09-03 20:36:05 +00:00
Vitor PamplonaandGitHub d3fd42e74f Merge pull request #4054 from vitorpamplona/claude/compose-escaping-fix-f7g5em
Fix smart quotes in localization strings across all languages
2026-09-03 16:25:07 -04:00
Claude e223d50527 fix(strings): re-apply escape conversion after the Crowdin sync
The Crowdin sync in b0ff30b0 reintroduced Android-only escaping into the
Compose resource catalog for the third time: 2,888 occurrences across 40
locale files (\' x2,832, \" x~470, plus a handful of \? and \@).

Compose's handleSpecialCharacters resolves only \uXXXX, \n and \t, and
collapses \\. It leaves \' \" \? \@ untouched, so these render with a
visible backslash -- "Utiliser l\'URL directe" instead of "Utiliser
l'URL directe". The apostrophe-heavy locales are hit hardest: uz-rUZ
(949), fr-rFR (341), fr-rCA (326), tr-rTR (189).

Repaired with the documented command:

    python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \
        commons/src/commonMain/composeResources

--no-unwrap-quotes is required here: quote-unwrapping is not idempotent,
and the two remaining quote-wrapped values (import_follows_tips in
values-es, messages_new_message_to_caption in values-tr) carry real
display quotes that a second unwrap would strip.

Verified: the diff is exactly the escape removal and nothing else (each
old file, with unescaped \' \" \? \@ backslashes stripped, is byte-equal
to its new version), all 40 files still parse as well-formed XML, no file
outside composeResources is touched, and both resource CI checks --
compose_escaping_check.py and orphan_strings_check.py -- now exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVrW3p8NGWHgLbN673Fet2
2026-09-03 20:10:28 +00:00
Vitor PamplonaandGitHub 161a771b23 Merge pull request #4053 from vitorpamplona/claude/text-input-string-bounds-d7z8ww
Fix TextFieldState races by dispatching UI-thread writes to Main
2026-09-03 16:06:27 -04:00
Vitor PamplonaandGitHub 48efe1adb7 Merge pull request #4047 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-03 15:58:31 -04:00
Claude 818ca1e072 fix(composer): deliver invoice callbacks on the UI thread, keep voice deletes off it
Follow-up audit of the previous commit found one missed instance of the same
crash and one regression it introduced.

`AccountViewModel.sendSats` invokes `onNewInvoice` from inside
`viewModelScope.launch(Dispatchers.IO)`, and every composer's callback writes
the invoice into its message field (`insertAtCursor` -> `insertUrlAtCursor`,
or a direct `setTextAndPlaceCursorAtEnd` in EditPostView). That is the same
off-main `TextFieldState` write the previous commit fixed elsewhere, reachable
from "Create and add invoice" in ShortNote, GenericComment, LongForm,
NewProduct, NewGroupDM, NewPublicMessage and EditPost. Fixed at the source so
all seven call sites are covered; `SendPaymentScreen.payBolt11` already
worked around the IO delivery locally, so its comment is updated rather than
its `scope.launch`, which still serves other callers.

`ShortNotePostViewModel.cancel()` mixes UI-thread-only work (the field writes)
with work that must not be on the UI thread: `voiceAnonymization.clear()` and
`deleteVoiceLocalFile()` do `File.exists`/`File.delete`. Wrapping the whole of
`cancel()` in `onUiThread` moved those deletes onto the main thread on every
send and back-out, tripping the app's own StrictMode `detectAll()`
DiskWriteViolation. The voice cleanup now runs on `Dispatchers.IO`, with the
file captured before `voiceLocalFile` is cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXsnR3aocetEWRf9hwoGRk
2026-09-03 19:58:31 +00:00
vitorpamplonaandgithub-actions[bot] b0ff30b000 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-03 19:57:02 +00:00
Vitor PamplonaandGitHub 0385592c68 Merge pull request #4048 from vitorpamplona/claude/concord-relay-limit-error-1zk89e
Remove 3-relay cap from invite links; support up to 255
2026-09-03 15:55:53 -04:00
Vitor PamplonaandGitHub 8fa40c1475 Merge pull request #4049 from vitorpamplona/claude/amethyst-navigation-crash-ulffj5
Cap route text arguments to prevent navigation matching failures
2026-09-03 15:55:43 -04:00
Vitor PamplonaandGitHub 0a735d0359 Merge pull request #4050 from vitorpamplona/claude/recycled-bitmap-mediasession-wzgfil
Fix MediaSession artwork scaling crash on some OEM ROMs
2026-09-03 15:54:32 -04:00
Vitor PamplonaandGitHub e6e05c5915 Merge pull request #4051 from vitorpamplona/claude/mlkit-genai-nullpointer-nge2pm
Fix ML Kit GenAI crash on future cancellation
2026-09-03 15:54:08 -04:00
Claude 8b47588d37 perf(ai): bound in-flight GenAI work and stop blocking IO threads on it
Follow-up to the crash fix, which changed what cancellation costs.

Inferences can no longer be recalled: awaitDetached() detaches instead of
cancelling, because cancelling the future is what killed the process. So
abandoning a running batch no longer stops anything — it leaves seven rewrites
burning on-device compute for text the user has already moved past, and since
precomputeAiResults() runs on every keystroke, each later pause stacked seven
more on top with nothing bounding the pile.

The composer now coalesces instead of abandoning. The debounce window stays
freely cancellable (nothing has reached the model yet), but once a batch's
inferences are under way it is left to finish and the newest draft text is
stashed in aiPendingText, picked up when that batch ends. In-flight work is
bounded at one batch however fast the user types, and per-batch latency is
untouched — the seven tones still run concurrently.

MLKitImageLabelService moves off ListenableFuture.get() onto awaitDetached().
Describing an image takes seconds, and get() held an IO thread for all of it
uninterruptibly, so backing out of the composer left the thread pinned until
AICore answered. This needs a CancellationException rethrow ahead of the
existing catch-all: now that the awaits suspend, a cancelled caller lands there
and must not be swallowed as "no suggestion".

Also drops MIN_CONFIDENCE/MAX_LABELS, which nothing has referenced since the
keyword image-labeling path was removed, and corrects the class KDoc that still
described that fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mgCe29aaWUwGmGHKnFKo
2026-09-03 19:45:21 +00:00
Claude 08b64fb4f2 fix(ai): stop cancelling ML Kit GenAI futures, which crashes the app
Cancelling a `genai-rewriting` 1.0.0-beta1 inference future kills the process
from a thread we don't own:

    Thread: AiCoreClientWorker-thread-5
    java.lang.NullPointerException: Attempt to invoke interface method
      'void com...mlkit_genai_rewriting.zzp.zzd()' on a null object reference
        at com...mlkit_genai_rewriting.zzby.zzk
        at com...mlkit_genai_rewriting.zzbt.run
        at java.util.concurrent.ThreadPoolExecutor.runWorker

Disassembling the library pins it down exactly. `zzw.zzf` — the
`IMagicRewriteService` AIDL proxy — reads the returned `ICancellationCallback`
with `Parcel.readStrongBinder()`, which yields null when AiCore answers without
one, and passes that null on. `zzbh.attachCompleter` then registers it as the
future's cancellation listener with no null check
(`addCancellationListener(new zzbt(handle), ...)`), so cancelling the future
runs `zzby.zzk(null)` → `null.zzd()`. `zzk` catches only `RemoteException`, and
it all happens on ML Kit's own worker pool, so nothing we wrap can see it: the
NPE reaches the default uncaught handler and takes the app down.

The composer cancelled these routinely — a keystroke replaces the in-flight
batch of seven tones via `aiComputeJob.cancel()`, and leaving the composer
cancels `viewModelScope` — which turned a beta-library race into a routine
crash.

There is nothing to upgrade to: genai-rewriting, genai-proofreading and
genai-image-description have each published exactly one version. So the future
bridge now detaches instead of cancelling — `awaitDetached()` drops
`invokeOnCancellation { cancel(true) }` and skips reading the result once the
caller is gone. A cancelled batch's inferences finish with nobody listening,
which spends a little on-device compute where cancelling spent the process; the
composer's 1s debounce already keeps most stale batches from starting.

MLKitImageLabelService blocks on `.get()` and never cancels, so it is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1mgCe29aaWUwGmGHKnFKo
2026-09-03 19:45:19 +00:00
Claude da69623cb7 fix(playback): pin the session context and free the pre-scale artwork
Two follow-ups from reviewing the artwork cap:

The platform MediaSession reads its metadata bitmap ceiling from the resources
of whatever context builds it, while the cap is measured against the app
context. PlaybackService happens to pass applicationContext today, so the two
agree; building the session with appContext directly makes that structural
rather than incidental, and stops a pooled session from holding an Activity.

Decoding to just under 2x the ceiling leaves the pre-scale bitmap holding up to
4x the pixels of the copy that is kept. It is exclusively ours — the delegate
decodes a fresh bitmap per request, and media3's caching wrapper sits above
this loader — so it is recycled as soon as the scaled copy exists instead of
waiting for the collector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2qse1pZsCAsQ5XASgeqSK
2026-09-03 18:58:49 +00:00
Claude 5f88e0e971 fix: resolve a Concord invite coordinate newest-wins on the live branch too
`ConcordInviteBundle.classify` resolved a revocation tombstone by `createdAt`
(newest wins, per CORD-05 §2 replaceable semantics) but picked the live bundle
with `firstNotNullOfOrNull` — i.e. whichever copy happened to decrypt first in
relay arrival order. `wraps` comes straight off `fetchAll` at every call site
(`joinConcordViaInvite`, `amy concord join`, `refreshConcordInviteLinks`), and
`fetchAll` gives no ordering guarantee.

A Refounding re-mints every live link at its OWN coordinate carrying the new
epoch's root. Until now, a relay still serving the pre-Refounding bundle could
hand a joiner the root of the epoch the community had just left: they would
join, post into planes nobody reads, and see no error explaining why. The file's
own KDoc and the CLI's redeem comment both already claimed newest-wins here.

Sorting newest-first also decrypts fewer bundles in the common case, since the
current edition is now tried first.

Regression test covers both fetch orders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcaCNiqN8T9izve4AXcake
2026-09-03 18:55:32 +00:00
Claude a2cf65dc79 perf(nav): bound the route-text scan by the budget, not the input
Audit of the previous commit. limitToRouteTextArg measured the whole
value and then walked it again to find the cut, so a five-megabyte share
payload was scanned twice on the main thread to produce twenty kilobytes
— O(input) work in the one function whose job is to defend against
unbounded input. One pass now, returning the moment the budget is blown,
which costs the same for a megabyte as for a value at the limit.

Three smaller things from the same pass:

- A budget too small to hold the truncation marker returned the bare
  marker, which is longer than the limit the function promises. It now
  yields nothing.
- The measurement-free fast path divided by twelve, the cost of a
  supplementary code point — but that cost covers two chars, so the real
  worst case per char is nine. Drafts of 1,667 to 2,222 characters were
  being measured for nothing.
- limitToRouteTextArgOrNull is gone: "OrNull" reads as "returns null on
  failure", which it doesn't, and `?.` at the one call site says it
  better. The marker's own cost is hoisted out of the call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCs4kkfMP7vFPGRwHg65p
2026-09-03 18:54:02 +00:00
Claude e7c142a27e fix(composer): keep TextFieldState writes on the UI thread
StringIndexOutOfBoundsException: begin 3, end 4, length 0 was crashing the
app from inside Compose's undo bookkeeping, on the IME's own callback:

    TextFieldCharSequence.subSequence
    TextUndoManager.recordChanges
    TextFieldState.commitEditAsUser
    DefaultImeEditCommandScope.endBatchEdit
    StatelessInputConnection.endBatchEdit

`TextFieldState` is UI-thread confined. Only its `value` is snapshot state;
`mainBuffer` -- the buffer the platform input connection edits while the soft
keyboard holds a batch open -- is a plain field that nothing publishes. Every
composer writes to its fields from `Dispatchers.IO`: `cancel()` runs inside
`accountViewModel.launchSigner {}` on send and on back-press, `loadFromDraft`
runs inside `viewModelScope.launch(Dispatchers.IO)`, and the upload path calls
`insertUrlAtCursor` from the same IO block. When one of those lands while the
user is still typing, the cleared `value` reaches the main thread but the new
buffer may not, and `recordChanges` then maps the keystroke's edit range onto
text that no longer has those offsets.

Reproduced against foundation 1.12 by clearing the field, restoring the stale
buffer reference and letting the batch commit: `Range [3, 4) out of bounds for
length 0`, thrown from the same `recordChanges` frame.

Adds `onUiThread { }` (a `withContext(Dispatchers.Main.immediate)` hop, so
callers already on the UI thread still run inline and keep their ordering) and
routes every off-main field write in the composers through it: the `cancel()`
calls in `launchSigner` blocks and in `sendPostSync`, the draft loads, the
post-upload URL insertion, and the message clears in the Marmot and Concord
`sendPost`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXsnR3aocetEWRf9hwoGRk
2026-09-03 18:40:06 +00:00
Claude ce5d7f6bc6 fix(nav): cap text arguments so a report can still open its chat
Sending a crash or resource-usage report to the dev crashed the app:

    IllegalArgumentException: Navigation destination that matches route
    …Route.Room/<pubkey>?message=<the whole report>… cannot be found in
    the navigation graph

The destination is in the graph — it just cannot be matched. androidx
finds a destination by regex-matching the generated route string, and
NavDeepLink ends every path pattern with `($|(\?(.)*)|(#(.)*))`: a
capturing group inside a `*` loop, so the engine pushes one backtracking
frame per character of the query. On Android `java.util.regex` is
ICU-backed (com.android.icu.util.regex.MatcherNative), ICU caps that
stack at 8 MB, and on overflow it reports *no match* rather than an
error. Run against ICU with the Route.Room pattern, matching flips at
about 100,000 encoded characters:

    uriLen  100091 -> match=1 status=U_ZERO_ERROR
    uriLen  100191 -> match=0 status=U_REGEX_STACK_OVERFLOW

The same route matches fine on the JVM at 13 MB, which is why this only
shows up on device. The reports are exactly that size, and it compounds:
a crash report quotes the route it failed on, and re-encoding turns each
`%` into `%25`, so every round through the dialog roughly triples.

So keep oversized text out of the route in the first place:

- limitToRouteTextArg cuts a value down to a fifth of the measured
  ceiling, measured in encoded characters (a space costs three, an emoji
  twelve) and never through a surrogate pair.
- routeToMessage caps the chat draft — the funnel every prefilled Room
  route goes through: crash and resource-usage reports, error toasts,
  shares.
- Intent.sharedText caps what another app hands us, which is bounded
  only by Binder and feeds all six share targets.
- ReportAssembler caps the throwable headline at 1,000 chars, which is
  what breaks the compounding; the stack trace, the useful part, stays
  whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014gCs4kkfMP7vFPGRwHg65p
2026-09-03 18:29:41 +00:00
Claude e3818539f5 fix(playback): cap session artwork so the platform never re-scales it
MediaSession.setMetadata() re-builds metadata through
MediaMetadata.Builder.build(), which scales every bitmap-valued key larger
than config_mediaMetadataBitmapMaxSize. media3 stores the *same* Bitmap
instance under both METADATA_KEY_DISPLAY_ICON and METADATA_KEY_ALBUM_ART, so
build() scales that one instance twice. AOSP leaves the source untouched, but
on ROMs that recycle it while scaling the second pass throws

    IllegalArgumentException: cannot use a recycled source in createBitmap

on the main thread, inside a Guava future callback the app cannot intercept.

Artwork comes from arbitrary nostr imeta URLs, so it routinely arrives well
above the ceiling. The previous fix capped the decode at that ceiling, but
DataSourceBitmapLoader only subsamples by powers of two, so a 1080px image
under a 900px ceiling decoded to 540px — correct, but half the resolution the
session would have accepted. Decode to just under 2x and scale precisely
afterwards instead (the same recipe media3 uses for its own default loader),
via a small BitmapLoader wrapper that mirrors the platform's own scaling math.

The ceiling is also re-read per load rather than memoized once: it is a dp
value, and the app survives display-size changes without restarting, so a
density drop would otherwise leave the cap stale and too large.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2qse1pZsCAsQ5XASgeqSK
2026-09-03 18:27:58 +00:00
Claude 401f6246ab fix: don't cap Concord invite links at 3 bootstrap relays
`ConcordInviteLink.encodeFragment` refused to encode more than
`MAX_RELAYS = 3` bootstrap relays, but both mint call sites —
`AccountConcordActions.mintConcordInvite` and `amy concord invite` — hand it
the community's full relay list. A community with more than three relays
therefore blew up the invite button with
`IllegalArgumentException: at most 3 relays, was 5`.

The cap was self-imposed: nothing in the fragment format needs it. The layout
is `[version][flags][count][relays...][token:16]` and the relay count is a
whole byte, so the format's own ceiling is 255. `MAX_RELAYS` is deleted and
the only remaining guard is that ceiling — without it a 256-relay list would
wrap the count byte to 0 and silently strand every relay in the fragment.

The stock set still collapses to flag `0x01` and zero relay bytes, so the
common invite is unchanged in length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcaCNiqN8T9izve4AXcake
2026-09-03 18:25:22 +00:00
Vitor PamplonaandClaude Opus 5 9585e66e60 ci(strings): gate the Compose catalog escaping check
Wires compose_escaping_check.py into the two layers that already police the
identical orphan-strings desync, mirroring pre-push-orphan-strings.sh:

  - the fast `lint` job in build.yml, next to the orphan check
  - a PreToolUse hook, so a push or PR from an agent session is gated too

CI is the layer that matters here. As amethyst/src/main/res/CLAUDE.md records for
the orphan desync, these arrive through bot-authored PRs -- the Crowdin sync
reintroduced 2,068 escaped apostrophes across 40 locales twice in two days, with
no local session anywhere in the path.

Verified end to end: a payload with no push exits 0 without spawning python; a
push against a clean tree exits 0; a push with values-tr-rTR restored to its
pre-repair state exits 1 and names the file with a per-escape breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-03 14:19:04 -04:00
Vitor PamplonaandClaude Opus 5 f9baab0e92 fix(strings): re-apply escape conversion, and add a check so it stops recurring
The Crowdin sync reintroduced Android escaping into the Compose catalog a second
time (PR #4046, commit 2129e16044): the same 2,068 escaped apostrophes and 749
escaped quotes across 40 locale files, always the apostrophe-heavy regional
variants -- uz-rUZ 949, fr-rFR 341, fr-rCA 326, tr-rTR 189. Compose resolves
neither, so those strings render with a literal backslash.

Repairing after each sync is not a fix: Crowdin holds the Android-escaped source,
so every import brings it back. Add .claude/hooks/compose_escaping_check.py, a
sub-second scan of the Compose catalog for \' \" \? \@ and tools: attributes,
mirroring orphan_strings_check.py -- same shape of bug, same fix. \n, \t, \uXXXX
and \\ are left alone because Compose resolves those itself, and Android res trees
are not scanned because there the escaping is correct.

Verified in both directions: clean tree exits 0; restoring the regressed
values-fr-rFR makes it exit 1 and name the file with a per-escape breakdown.

Not yet wired into CI or the pre-push hook -- that is a maintainer call about
where the gate lives. As amethyst/src/main/res/CLAUDE.md notes for the identical
orphan-strings desync, the CI lint job is the layer that matters: these arrive
through bot-authored PRs with no local session in the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-03 14:07:48 -04:00
Vitor PamplonaandGitHub 371400e110 Merge pull request #4046 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-03 14:02:35 -04:00
vitorpamplonaandgithub-actions[bot] 2129e16044 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-03 17:59:06 +00:00
Vitor PamplonaandGitHub 536e27dc8f Merge pull request #4041 from vitorpamplona/claude/payment-targets-zap-integration-q52kn6
NIP-A3 payment targets in zap picker — v1
2026-09-03 13:55:58 -04:00
Claude 12c6b7b08d feat(zap): gate the pay-to chip on discovery alone, default it on
The chip required the sender and the author to publish the same protocol,
capped the row at two, and shipped opt-out. All three go.

Symmetry was a proxy for "I can actually pay this way", and it is the wrong
proxy: paying a Monero address needs a wallet, not a published address of one.
What the sender happens to say about themselves never determined whether the
hand-off would work — the installed-app probe does, and it was already running.
So `PayToRailMatcher.match` no longer takes the sender's list, `selectFor`
drops the `senderTargets` gate, and `canOpen` becomes the substantive filter
with the rest as preconditions.

Dropping symmetry moves the probe set. It used to be the sender's own target
list, which is why `warm()` could replace the cache wholesale; it is now the
targets of whichever author's picker is open. So `warm()` merges instead of
replacing — replacing would evict what was learned about every other author the
moment a second picker opened — and the `LaunchedEffect` keys on the author's
observed kind:10133 rather than on `paymentTargetsState`.

MAX_CHIPS existed because symmetry could pass several protocols at once with
nothing else narrowing them. Discovery narrows them: a target with no installed
app never reaches the picker, so the cap was bounding a row that discovery
already bounds, and an arbitrary two-chip truncation would now hide a target
the user can genuinely pay.

`showPayToZapChip` defaults on for the same reason. The opt-out was justified
by fiat handles carrying legal names, but the chip only ever surfaces a target
its author chose to publish, to a device that can already open it.

The setting's copy said "when you and the author both publish the same payment
method" and the toggle read "Offer shared payment methods" — both described the
gate that no longer exists, so both are rewritten.

Tests follow the contract rather than the old shape: symmetry cases become
capability cases, `everyOpenableTargetIsOfferedWithNoCap` replaces the cap
assertion, and one new case pins the inverse of the rule that was removed — a
target the sender does not publish is still offered. The lazy-read test keeps
its guarantee, minus the sender-empty branch that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-03 16:50:04 +00:00
David KasparandGitHub abbe98119e Merge pull request #4045 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-03 17:09:16 +02:00
vitorpamplonaandgithub-actions[bot] 63c3f55878 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-02 22:57:12 +00:00
Claude 5c661e046c Merge remote-tracking branch 'origin/main' into claude/payment-targets-zap-integration-q52kn6
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
2026-09-02 22:43:38 +00:00
Vitor PamplonaandClaude Opus 5 1685d7c0bf fix(strings): re-apply escape conversion after the Crowdin sync
The Crowdin sync (a7e7ace985) reintroduced Android escaping into composeResources:
2,068 escaped apostrophes and 749 escaped quotes across 20 locale files, every one
of which was clean at f3a72e26e0. Compose does not resolve \' or \", so those
strings render with a literal backslash.

The affected locales are the apostrophe-heavy regional variants -- uz-rUZ 949,
fr-rFR 341, fr-rCA 326, tr-rTR 189 -- while their base locales stayed clean.

Re-applies the conversion with --no-unwrap-quotes, since these files are already
migrated: escape conversion is idempotent, quote-unwrapping is not, and a second
unwrap would strip the real display quotes from strings like import_follows_tips.
Diff verified as pure escape conversion: 2,002 lines changed, none unexplained.

This will recur on every sync until the conversion moves into the Crowdin
pipeline. See tools/strings-migrate/fix_escapes.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 18:25:20 -04:00
Vitor PamplonaandGitHub 4daff2a03b Merge pull request #4042 from vitorpamplona/claude/zap-onchain-balance-check-gyc19z
Gate on-chain zaps on wallet balance and fee estimates
2026-09-02 18:20:05 -04:00
David KasparandGitHub eca09480c7 Merge pull request #4044 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-02 19:46:33 +02:00
vitorpamplonaandgithub-actions[bot] a7e7ace985 chore: sync Crowdin translations and seed translator npub placeholders 2026-09-02 16:45:36 +00:00
Vitor Pamplona a602697afb Merge remote-tracking branch 'origin/main' into claude/zap-onchain-balance-check-gyc19z
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt
2026-09-02 16:39:36 +00:00
Vitor PamplonaandClaude Opus 5 f3a72e26e0 fix(strings): drop tools:ignore from compose resources
`tools:` attributes are an Android-lint construct. They arrived with strings moved
out of amethyst/src/main/res/, whose <resources> root declares xmlns:tools --
composeResources roots do not, so the prefix was unbound and the XML malformed:
97 occurrences across 49 locale files, none of them declaring the namespace.

Nothing was visibly broken, because Compose parses namespace-unaware and drops the
unknown attribute (no .cvr contains it). But nothing should rely on that, and
Android lint never runs on composeResources, so the attribute carried no meaning
there either.

migrate.py now strips tools: attributes as it moves each element, so the remaining
migration waves cannot reintroduce them.

Also fixes a hazard in fix_escapes.py found while doing this: quote-unwrapping is
NOT idempotent. Android wraps a value in quotes to protect whitespace, but once
\" has been converted to ", a legitimately quoted value is indistinguishable from
a wrapped one, and a second pass strips the real quotes -- it silently damaged 10
`import_follows_tips` translations before this was caught. Unwrapping is now
opt-out via --no-unwrap-quotes for repair runs over already-migrated files, and
documented as run-exactly-once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 12:27:44 -04:00
Claude 3a5688fc2e Merge remote-tracking branch 'origin/main' into claude/payment-targets-zap-integration-q52kn6
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt
2026-09-02 16:24:50 +00:00
Vitor PamplonaandGitHub efbfc85c50 Merge pull request #4025 from vitorpamplona/claude/amethyst-commons-migration-hm8vgm
Migrate model classes to commons module
2026-09-02 12:04:10 -04:00
Vitor PamplonaandClaude Opus 5 810e342bc1 fix(strings): convert Android escaping when moving strings to compose resources
Strings moved from res/values/ into composeResources/values/ kept Android's
escaping, which Compose does not interpret the same way, so the login screen
rendered `Don\'t have a Nostr account?` with a literal backslash and the terms
line showed stray quotes.

Compose 1.11.1 handleSpecialCharacters resolves only \uXXXX, \n and \t (and
collapses \\). It leaves \' \" \? \@ alone, and renders Android's quote-wrapping
-- used to preserve leading/trailing spaces, e.g. " Following" -- literally.

Convert those four escapes and unwrap the quotes, leaving \n, \t, \uXXXX and \\
untouched so Compose still resolves them. 3,717 entries across 56 locale files;
translations were hit far harder than English (Uzbek 964, French ~340 per
variant, Turkish ~208) because those languages use apostrophes heavily.

migrate.py now applies the same conversion as it moves each element, so the next
wave cannot reintroduce this; fix_escapes.py repairs what is already migrated and
is idempotent.

Verified on a Pixel 9 emulator: "Event is loading or can't be found in your relay
list" now renders with a real apostrophe, and no visible text node contains a
literal backslash escape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 11:46:35 -04:00
Claude 8e833d9b9e Merge origin/main (Amethyst icon font)
Icons.kt conflicts: kept main's AmethystIconGlyph calls with this
branch's migrated Res.string content descriptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-02 15:18:38 +00:00
Claude 1db2eeffaf Merge remote-tracking branch 'origin/main' into claude/zap-onchain-balance-check-gyc19z 2026-09-02 15:10:10 +00:00
Claude f51d5ced1c Merge remote-tracking branch 'origin/main' into claude/payment-targets-zap-integration-q52kn6 2026-09-02 15:10:04 +00:00
Vitor PamplonaandGitHub e1743f46d3 Merge pull request #4043 from vitorpamplona/perf/amethyst-icon-font
perf(icons): draw Amethyst's own icons from a generated icon font
2026-09-02 10:49:22 -04:00
Vitor PamplonaandClaude Opus 5 f54194f70d perf(icons): draw Amethyst's own icons from a generated icon font
Icon(imageVector = …) calls rememberVectorPainter, and a VectorPainter rasterises
its paths into a cached graphics layer per instance, so the feed re-rasterised the
same glyphs once per card. A font glyph is a blit from the shared text atlas,
shared across every call site for free.

tools/icon-font/build_icon_font.py converts the Kotlin ImageVector DSL to SVG paths
and builds a TTF with fontTools. Font metrics mirror the bundled Material Symbols
font (upem 960, ascent 1056, descent -96, advance 960) so glyphs align with existing
call sites; generated outlines land within a few units of Google's own.

Measured on the uniform-corpus feed benchmark (SM-T220, three arms A/B/A, 0.2%
identical-arm noise floor, gate 18/18/18 cards):

  frame duration P90   -10.7%
  frame overrun  P90   -17.4%
  DrawReactions        114.8 -> 76.7 ms/iteration

For reference, ablating the reaction icons entirely gives frame P90 -13.5%, so this
captures ~84% of the available headroom. It supersedes the shared-VectorPainter
approach (-8.2%), which needed CompositionLocal plumbing and hand-scoping to avoid
cross-size cache thrashing; glyphs are atlas-shared automatically.

Artwork is unchanged: this converts Amethyst's existing vectors rather than
substituting Google's glyphs. Verified on device by pixel comparison -- unconverted
icons are 0-diff, and the converted ones differ only by sub-pixel antialiasing
between the text and vector rasterisers.

Stroked icons are deliberately NOT converted. A glyph outline can only be filled, so
converting Zap (strokeLineWidth 1.2) turned a thin outline bolt into a solid one; the
build script now detects a stroke and skips the icon, leaving Following, Zap and
ZapSplit on their ImageVectors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 10:21:59 -04:00
Claude e9bc618f93 Merge branch 'main' into claude/payment-targets-zap-integration-q52kn6
Conflict in DisplayPaymentTargets.kt, where #4040 and this branch changed the
same hand-off path from opposite ends and converged on the same idea.

main extracted a shared PaymentTargetPill and routed every hand-off through one
new paymentTargetUri(target), still backed by the uriFor lambda on
PaymentTargetStyle. This branch had deleted that lambda, moving the scheme
table to commons so the zap picker and the installed-app probe could share it.

Kept main's structure — the pill and paymentTargetUri are the better shape, and
PaymentButton already calls the latter — and backed paymentTargetUri with
PaymentTargetTypes.uriFor. One hand-off entry point, one scheme table, no
behaviour change on either side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 01:39:31 +00:00
Claude ddef45304c fix: import NotifyRequest/NotifyRequestsCache from commons in main's new test
Main's NotifyRequestsCacheTest resolved both by same-package; on this
branch they live in commons.relayClient.notify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-02 01:37:30 +00:00
Vitor PamplonaandClaude Opus 5 f1b30ea26c refactor: fold the wallet card's balance fetch into OnchainWalletState
OnchainSection kept its own composable-local UTXO fetch and sum, so after
the zap picker gained a cached balance there were two paths to the same
number. Point the card at the shared account state instead.

- OnchainWalletState gains a `status` flow (UNAVAILABLE / LOADING / READY
  / ERROR) so the card keeps its four display states, and a `totalSats`
  for the figure it shows (settled + mempool, matching what it summed
  before). ERROR is reported only when there is no snapshot at all: once
  a balance is known, a failed refresh keeps the last good number on
  screen rather than blanking it.
- The card's private BalanceState enum is gone; it renders the model's
  status directly.

Opening the wallet screen now warms the balance the zap chips read, and
a send invalidates the snapshot for both surfaces at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jsupTzY576d2iWbqbzH4k
2026-09-02 01:35:29 +00:00
Claude 2438ab16c6 fix(zap): make the pay-to setting findable, and preview the chip
Two gaps from moving the toggle to the Zaps screen.

Settings search indexes the catalog entry's keywords, not the screen's
contents, so after the move nothing matched "venmo", "payto" or "paypal" — the
words someone would actually type to find this. Widened zaps_search_keywords.

ZapAmountChoicePopupPreview exercises four rail combinations but never
payToTargets, so the new chip had no preview at all in a file that otherwise
covers this component carefully. Adds a row with two hand-offs; since no app
resolves in a preview it also exercises the brand-coloured glyph fallback,
which is what a device without the app installed shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 01:32:30 +00:00
Claude 88ad91459b Merge origin/main (notify block-relay button, payment-target dialog)
Conflicts:
- DisplayPaymentTargets.kt: main's model.User import superseded by the
  commons User import; setText import dropped (main's rewrite no longer
  uses it).
- strings.xml: kept only main's genuinely new notify_block_relay key;
  thread_title and send_the_seller_a_message already migrated to commons.
Also re-added the R import in NotifyRequestDialog.kt for the new
R.string.notify_block_relay usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-02 01:30:18 +00:00
Claude 34e931a9b0 refactor(settings): move the pay-to toggle to the Zaps screen
It was on Profile settings, under a section literally titled "profile
sections" (badges, app recommendations, zap-received feed, followers feed).
The toggle has no profile-visible effect at all — it decides whether a chip
appears in the zap picker — so it was filed by association with
showOnchainWallet, which sits there for the same weak reason but at least puts
a chip on profiles.

The Zaps screen is where it belongs: it is the zap picker's configuration
surface, reached from the picker's own "change amount" action, and it already
renders previewRailsFor for the very chip row this setting adds to.

Wired through UpdateZapAmountViewModel rather than applied instantly, because
that screen is a Save/Cancel form: load() reads it, hasChanged() reports it,
sendPost() commits it and cancel() reverts it. An instant-apply switch on a
form with a Cancel button that did not revert it would read as a bug.

Strings move out of the profile_ui_ namespace to zap_payto_*, and the
explainer now states the two things the chip does not do: the other app asks
for the amount, and nothing is published, so the note's zap count is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 01:15:57 +00:00
Vitor PamplonaandGitHub 2df6221cd0 Merge pull request #4039 from vitorpamplona/claude/notify-block-relay-button-e6amne
Add relay blocking to NOTIFY payment prompts
2026-09-01 20:56:48 -04:00
Vitor PamplonaandGitHub 99d85a51b7 Merge pull request #4040 from vitorpamplona/claude/payment-target-dialog-format-yvfojv
Unify payment target UI across profile and dialog
2026-09-01 20:56:21 -04:00
Claude 6faa6556dd fix: keep the payment-target address visible and unify the wallet handoff
Audit follow-ups to the pill format in the payment-targets dialog:

- The row's three icon buttons left the pill 112dp on a 320dp dialog, which
  is exactly the width of the icon + type label: the shortened address was
  measured at 0dp and never drawn. The pill already pays on tap and copies
  on long-press (same as the profile), so the redundant bolt button goes and
  the address gets 45dp on a 320dp dialog, 97dp on a 372dp one.
- Cap the chip label at one line: a long type ("BITCOINCASH") wrapped the
  pill to two lines in narrow hosts.
- The dialog handed off to "payto://<type>/<authority>" for every type while
  the identical pill on the profile uses the type's own scheme, so the same
  pill reached a different app depending on where it was tapped. Both now go
  through paymentTargetUri(), which keeps payto:// as the unknown-type
  fallback.
- Drop FLAG_ACTIVITY_NEW_TASK|CLEAR_TASK from that handoff: CLEAR_TASK wiped
  whatever the wallet app already had open, and the dialog runs from an
  activity context that needs neither flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We35qZJEhp8bSbPUHo6Koc
2026-09-02 00:33:35 +00:00
Claude 77b96c4ab7 fix(zap): correct the pay-to probe, and stop redoing its expensive half
Audit of the previous commit. Four findings, all reachable in normal use.

The probe was stricter than the hand-off it predicts. It carried
CATEGORY_BROWSABLE and queried with flags=0, while the hand-off goes through
startActivity, which implies CATEGORY_DEFAULT and nothing else.
IntentFilter.matchCategories returns the first category on the *intent* the
filter lacks, so each category added to a query narrows the match: any app
declaring only DEFAULT was invisible to the probe and its chip was hidden even
though tapping it would have worked. The probe now carries no category and uses
MATCH_DEFAULT_ONLY, resolving exactly the set startActivity would. The
<queries> entries lose the category for the same reason — there it narrows
package visibility itself.

The chip snapshotted the probe result with remember(target.type), so it never
saw the probe finish. A web target is offered before any probe runs, since any
browser opens https, so that snapshot pinned the fallback glyph and the real
app icon could not appear until the picker was closed and reopened — the Venmo
and PayPal case the icon exists for. It now collects the availability flow.

peek() built the recipient's target list eagerly, walking the kind:10133 tag
array on every call, including the one-tap zap path with the feature switched
off. selectFor now takes it as a lambda behind the cheap gates, pinned by a
test that counts reads.

warm() runs on each picker open so resolution stays fresh when the user
installs an app and comes back, but it also re-read each APK's resources and
re-rasterised its icon to answer the same question. Decoded icons are now kept
across warms, keyed by package and size, and the browser control probe only
runs when a web target is actually present.

Also: the icon failure log kept its message but dropped the throwable; it now
passes it. Removes the unused clear().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 00:33:29 +00:00
Claude 5ba42b3439 test: verify the block actually mutates the kind-10006 correctly
The button had no test behind its central claim. NotifyRequestsCacheTest covered
the prompt bookkeeping and BlockedRelayFilteringClientTest the enforcement, but
nothing exercised BlockedRelayListState.addRelay — the step that decides what the
published block list contains.

That step is worth pinning because BlockedRelayListEvent.updateRelayList replaces
every relay tag with what it is handed, so addRelay must read the current list
before writing. Get it wrong and the second tap silently wipes the first block —
a data-loss bug the UI gives no sign of, since the dialog closes either way.

Drives the real thing: a real keypair, real NIP-51 encryption, LocalCache, and
the production decryption cache. AccountSettings is stubbed only because it reads
Resources.getSystem() for spoken languages, which is null outside an Android
runtime; Looper is mocked as the neighbouring LocalCache tests already do.

Covers: the list is created on the first block; the second block keeps the first;
re-blocking is idempotent; and the relays stay in encrypted private tags, never
public ones — a leak there would publish which paid relays the user walked away
from. Confirmed the wipe case fails when addRelay is reverted to writing only the
new relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-02 00:30:28 +00:00
Claude 6cb485cda8 feat(zap): offer a NIP-A3 pay-to hand-off in the zap picker
When the sender and the note's author both publish a payment target of the
same protocol, the zap picker now offers a chip that hands off to the app that
owns it. Gated on a new opt-in setting (default off), on the note carrying no
NIP-57 zap split, and on an installed app actually resolving the URI.

The chip carries no amount. Zap presets are sats and there is no rate anywhere
in the repo to turn them into a Venmo or IBAN figure, so no number is shown and
no RFC-8905 amount= is emitted; the receiving app asks. It ends in OpenInNew
rather than the send arrow every amount segment uses, and long-press copies the
authority instead of opening the sat-preset editor, which would mean nothing
here. Nothing is published, so the zap counter does not move and none of the
zap progress state is touched.

It renders beside the amount pills rather than inside each pill's rail toggle.
Carrying no amount, it would otherwise repeat identically once per preset, and
keeping it out of the toggle leaves ZapRail a plain enum instead of forcing it
into a data-carrying sealed interface.

Discovery needs the new <queries> entries: targetSdk is 37, so Android 11+
package visibility returns nothing from queryIntentActivities for an undeclared
scheme, and the chip would be invisible on every modern device. Unknown types
all fall back to payto://<type>/<authority>, so one payto entry covers the
open-ended tail of the vocabulary. Specific <intent> filters, never
QUERY_ALL_PACKAGES.

The mark is the resolved app's own icon, from the same ResolveInfo the probe
already holds, decoded once at the chip's size during the warm step and masked
round the way a launcher draws it. It falls back to the brand-coloured glyph
paymentTargetStyleFor already assigns when the hand-off would open a chooser or
merely a browser: https targets resolve to any browser, so a control probe
against an unownable host separates a real app handler from Chrome.

The availability cache is keyed on scheme plus host, not scheme, because an app
may declare host="iban" and a scheme-only hit would wrongly claim payto://upi
is handled. It is warmed from the sender's own target list when the picker
opens, so it is bounded by how many ways the user says they can be paid rather
than growing with the feed, and it is a StateFlow because a plain map write is
invisible to Compose.

Shared plumbing moves to commons: PaymentTargetTypes now owns the alias and
scheme tables that were duplicated inside the profile UI file, and
PayToRailMatcher holds the matching and the gate decision as pure functions,
free of Note, Context and the availability singleton so the gates are testable
on their own. RailCapability gains a defaulted payToTargets, and peek gains
defaulted parameters so zapClick's one-tap fast path stays Lightning-only.
PaymentTarget becomes a data class: without value equality it compares by
identity, which breaks list keys and dedupe.

25 new tests in commons; amethyst, commons and quartz suites all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-02 00:12:17 +00:00
Claude ea86c10adc refactor: wrap the Block Relay press in launchSigner at the call site
Replaces the onBlocked callback parameter added to AccountViewModel.blockRelay
with the pattern the rest of the app already uses for "sign, then clean up the
UI only if it worked" — accountViewModel.launchSigner { … } around both steps at
the call site, as in AwardBadgeScreen's launchSigner { sendPost(); popBack() }.
There are 187 such direct uses in ui/, so a bespoke callback parameter on the
ViewModel was the odd one out.

Behaviour is unchanged: blockRelay was itself defined as `= launchSigner { … }`,
so the press already ran inside one and the dismissal already waited on a
successful signature. This just drops a layer and the now-unused ViewModel
method rather than leaving dead API behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-01 23:48:46 +00:00
Vitor PamplonaandClaude Opus 5 3616a69656 feat: hide on-chain zap amounts the wallet can't fund
The zap picker offered the on-chain rail for every preset at or above
MIN_ONCHAIN_ZAP_SATS regardless of what our own Taproot address held, so
a user with an empty (or merely small) on-chain wallet was shown amounts
that could only end in an insufficient-funds failure at send time.

Gate the rail on the sender's balance:

- OnchainZapBuilder.maxSpendableSats() answers "what is the largest
  amount this UTXO set can actually pay?" against the exact greedy
  selection the builder uses — prefix sums of the value-descending list,
  plus the last-chance no-change branch — so an amount that clears it is
  one build() will not reject. Tests pin the boundary: max is buildable,
  max + 1 throws.
- OnchainWalletState caches that figure per account (one explorer round
  trip per minute at most, failures back off too, invalidated after a
  spend), computed at the fee rate the send dialog defaults to.
- RailCapability.canPayOnchain() folds the three gates — recipient
  payable, amount over the minimum, wallet can cover it — into one place
  the chip calls. An unknown balance stays optimistic: a flaky explorer
  should not silently remove a payment option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jsupTzY576d2iWbqbzH4k
2026-09-01 23:47:14 +00:00
Claude b8899ec7ff fix: block the relay before dismissing its prompts, and make the cache atomic
Follow-up to the Block Relay button, from a review of that change.

Dismiss-before-block. The button called blockRelay() fire-and-forget and then
dismissed every prompt from the relay. reportSignerErrors swallows a refused or
timed-out signature (ManuallyUnauthorizedException, TimedOutException,
CouldNotPerformException) with a log line and no toast, so rejecting the signer
prompt closed the dialog, left the relay unblocked, and gave the user nothing to
tell them so — and the prompts were in the dismissal set for good. The dismissal
now runs from a callback that only fires after account.blockRelay() returns;
leaving the prompt up is the feedback when it doesn't. This also shrinks the
race window, since sendMyPublicAndPrivateOutbox consumes the kind-10006 into
LocalCache synchronously before publishing.

Non-atomic cache mutations. NOTIFYs are filed from the relay's socket coroutine
while dismissals run from the UI, so addPaymentRequestIfNew's `value +=`
read-modify-write could drop one of two concurrent edits, and dismissAllFrom
read the pending set before updating it — a prompt arriving in between was
removed without ever being recorded as dismissed. Both now go through
update/getAndUpdate.

Also avoids a copy on a hot path in BlockedRelayFilteringClient: every REQ,
COUNT and publish went through filterKeys/minus whenever the block list was
non-empty, allocating a full copy of the targets just to reproduce them
unchanged. A blocked relay is by definition one the app has stopped aiming at,
so it now checks whether any target is actually blocked before copying. This
matters more now that blocking is one tap from the dialog rather than a trip to
the settings screen, so non-empty block lists become the norm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-01 23:42:43 +00:00
Claude 4e067a13d7 docs(amethyst): take the pay-to chip's mark from the installed app
Answers whether the chip can wear the icon of the app it hands off to: it can,
and the codebase already does it. ExternalSignerButton renders installed NIP-55
signers from loadLabel/loadIcon off getExternalSignersInstalled, which is the
same queryIntentActivities call discovery already makes, so the ResolveInfo we
keep to answer "can anything open this?" also carries the mark and the label.

Argues against bundling brand logos instead: a trademark question rather than a
licence one, an unbounded free-text type space no bundled set can cover, and a
call the codebase already made by pairing brand colours with a generic wallet
glyph. Keeps that pairing as the fallback.

Records four things the existing precedent gets away with and this would not:
loadIcon is I/O and belongs in the off-main warm step caching an ImageBitmap
rather than in a recomposing item; adaptive icons need sizing and a round mask
or the logo floats in launcher bleed at 18dp; a multi-handler URI resolves to
ResolverActivity and has no single app to name; and a full-colour raster cannot
join the tinted glyph scheme.

Promotes the https control probe from a later refinement into v1: it never
gated the chip, but without it a browser-only Venmo target resolves to Chrome,
and a Chrome icon on a Venmo chip is worse than no icon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-01 23:34:09 +00:00
Claude bb32822ab9 feat: add a Block Relay button to the relay NOTIFY dialog
A paid relay answers a rejected AUTH with a NOTIFY asking for payment, and
until now the only thing the prompt offered was "OK" — which dismisses it and
lets the same relay ask again on the next AUTH. The user's actual intent
("stop talking to this relay") had to be carried out by hand on the Blocked
Relays screen.

Adds a "Block Relay" action to the dialog that publishes the relay into the
account's NIP-51 kind:10006 blocked list. Enforcement is the existing one:
BlockedRelayFilteringClient strips blocked relays from every REQ, COUNT and
publish, so the pool drops the socket once the subscriptions that wanted the
relay are recomputed.

- BlockedRelayListState.addRelay / Account.blockRelay add one relay without
  rebuilding the list from a caller-held snapshot — the kind-10006 list is
  shared across clients and may have grown since.
- NotifyRequestsCache.dismissAllFrom drops every queued prompt from the
  blocked relay, not just the one on screen: a paid relay files one NOTIFY per
  rejected AUTH, so dismissing them singly would immediately re-open the dialog.
- NotifyCoordinator drops NOTIFYs from an already-blocked relay, closing the
  window where frames still in flight could re-open the prompt.
- The button is hidden for read-only accounts, which cannot sign the list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2GsUAheZmAXv6vk4m7m9T
2026-09-01 23:07:18 +00:00
Claude 7121b89e71 feat: show payment-target pills in the payment targets dialog
The dialog behind the payment-target button listed each target as a
titlecased type over the full wallet id on a second line. It now renders
the same pill the profile page uses — type icon, tinted type label and
the shortened authority, with long-press to copy the full value.

Extracts that pill as PaymentTargetPill and rebuilds PaymentTargetChip on
top of ProfilePaymentChip, so the profile rail and the dialog (both from
the profile button and from ReactionsRow) share one implementation
instead of duplicating the Surface/Row layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01We35qZJEhp8bSbPUHo6Koc
2026-09-01 23:05:48 +00:00
Claude 8a4e37d41d docs(amethyst): scope the payment-targets zap chip down to an amount-less v1
Drops amounts, in-app payment and receipts from the first cut. The chip
carries no number and hands the amount to the external app, because there is
no FX service in the repo to convert a sat preset into a fiat figure.

Adds Intent-based discovery so only protocols an installed app can actually
handle are offered. Records the constraint that decides it: targetSdk 37 means
Android 11+ package visibility returns nothing from queryIntentActivities
without a <queries> declaration, and the current block covers only nostrsigner,
TTS, Health Connect and Tor. One payto entry covers every generic type, since
unknown types all fall back to payto://<type>/<authority>; https targets are
exempt because a browser always resolves them.

Keys the discovery cache on scheme+host rather than scheme, and warms it from
the sender's own target list instead of lazily per post: the symmetry gate
means only protocols the sender declares can ever be shown, so the probe set is
a handful of entries and feed rendering never triggers one. The cache has to be
a StateFlow, not a plain map, or the chip stays invisible until an unrelated
recomposition.

Moves the chip beside the amount pills instead of inside the per-amount rail
toggle: an amount-less rail would repeat identically in every pill, and keeping
it out of the toggle leaves ZapRail a plain enum, deleting the sealed-interface
refactor and its recompose-key breakage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-01 22:56:03 +00:00
Claude af5f23109e docs(amethyst): plan NIP-A3 payment targets as a zap-chip rail
Design doc for surfacing a NIP-A3 payment target as a selectable segment in
the zap amount chip when the sender and recipient share a pay-to protocol and
the note carries no NIP-57 zap split.

Anchors the design on what is already in the tree: kind:10133 already rides
in UserMetadataForKeyKinds beside kind:0, so no new subscription is needed;
RailCapabilityResolver.peek already computes the zap splits the gate needs;
and UnifiedZapAmountChip is already a segmented rail toggle.

Calls out the constraints that shape it: there is no FX service in the repo,
so the handoff segment carries no sat amount and emits no RFC-8905 amount=;
lightning/bitcoin payto types must map onto the existing rails rather than
render a second Bolt icon; ZapRail has to become a sealed interface to carry
which target; and the handoff produces no kind:9735, so it must stay out of
the zap state machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
2026-09-01 22:46:46 +00:00
Vitor PamplonaandGitHub 9fa60eae10 Merge pull request #4031 from vitorpamplona/perf/feed-note-render
perf(feed): defer animation transitions until there is something to animate
2026-09-01 17:53:02 -04:00
Vitor PamplonaandGitHub 3598037ed4 Merge pull request #4038 from vitorpamplona/fix/splash-and-dead-nightmode
fix(theme): pin the launch splash colour and drop the no-op night-mode writes
2026-09-01 17:52:49 -04:00
Vitor Pamplona 726f3e39c3 fix(theme): pin the launch splash colour and drop the no-op night-mode writes
Two small independent fixes found while profiling the feed.

Splash colour
-------------
The system builds the launch splash from the manifest theme *before the process
starts*, resolving it against the system light/dark configuration. The in-app
ThemeType can be pinned to the opposite, so the splash flashed the wrong colour
before the UI appeared: white before a dark UI for someone who pins DARK on a
light system, black before a light UI for the reverse. No app-side code can fix
that first frame — it is painted before onCreate runs.

Pinning `windowSplashScreenBackground` to the brand colour already used for the
status bar makes the splash read as intentional in every combination. Only the
API 31+ splash attribute is set; `windowBackground` is deliberately left alone,
so the window stays opaque (clearing it measured ~17% worse at frame P90,
because a non-opaque window costs SurfaceFlinger the chance to skip the layers
beneath it) and pre-31 behaviour is unchanged.

Verified on an SM-T220 by recording a cold start and sampling frames: launcher
-> purple splash (63,12,181 ≈ #3700B3) -> dark UI, with no white frame.

Night-mode writes
-----------------
`AmethystTheme` set `UiModeManager.nightMode` to force the device night mode for
a pinned DARK/LIGHT theme. Changing it requires MODIFY_DAY_NIGHT_MODE, which the
manifest does not declare, so the call silently no-ops for a normal app — while
running a device-state write from inside composition on every recomposition of
the theme. The pinned choice already takes effect through the colour scheme
selected immediately below, which is what was actually doing the work.
2026-09-01 17:41:30 -04:00
Vitor PamplonaandGitHub f22c8908b6 Merge pull request #4036 from vitorpamplona/claude/linuxx64-localcache-alternative-kj1gzp
Replace copy-on-write LargeCache with striped-lock hash table
2026-09-01 17:04:04 -04:00
Claude 9e2859f719 fix(quartz): stripe from the bucket, not from unrelated hash bits
Audit finding, and a real defect in the striped table two commits back.

A striped hash table is only sound when the stripe is a function of the bucket.
lockFor picked bits 16-19 of the hash while the bucket index used the low bits,
so the 16 locks did not partition the table: two keys could share a bucket while
holding different locks, and two writers would then read the same chain head and
both publish over it. One insert silently disappears while entryCount counts
both. The same window loses an overwrite, and loses entries through remove's
chain rebuild. That is precisely the class of bug this work set out to remove
from the copy-on-write version it replaced.

Stripe now comes from `hash and (STRIPES - 1)`. Because STRIPES and every
capacity are powers of two with STRIPES <= capacity, those are exactly the low
bits of the bucket index, so same bucket implies same stripe at every size. It
stays derived from the hash rather than the capacity, so a key keeps its stripe
across a resize, which is what lets growTable exclude writers by taking all of
them. INITIAL_CAPACITY is now defined as STRIPES so raising one cannot silently
break the invariant.

That definition also fixes a memory regression the audit caught: the table
allocated 1024 slots eagerly, about 8 KB, per instance. LargeCache is not only
the one big LocalCache — EphemeralRoom, RelaySession, PoolRequests and others
build one per room, per connection and per subscription set, so a client holds
hundreds that stay nearly empty. An empty instance goes from ~8 KB to ~970
bytes. Growth is geometric, so a table that does fill to 100k pays the same ~2n
node rebuilds either way; re-measuring the shipped code confirms it (fill 16ms,
overwrite 4ms, reads 1ms, 20 scans 16ms, mixed 86ms, 1 GC — unchanged within
noise). The KDoc table is updated to those numbers.

Adds LargeCacheStripingTest, which builds keys that share a bucket while
differing in bits 16-19 and drives four workers at them behind a start barrier,
with few enough buckets that chains grow long and each insert holds its lock for
a while. It is documented for what it is: a stress test of the concurrent
same-bucket path, not a deterministic reproducer — it did not fail against the
broken striping in the runs attempted, which makes that race rare rather than
absent. The fix rests on reading the stripe selection against the bucket index,
not on a red test.

Remaining known cost, noted in the KDoc rather than changed here: those ~970
bytes are nearly all the 16 PlatformLocks, two objects each. Folding them into
one AtomicIntArray would reach ~250 bytes, but hand-rolling the spin wants its
own review rather than a change on the way to merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 21:01:16 +00:00
Vitor PamplonaandGitHub f4e583e4d9 Merge pull request #4037 from davotoula/fix/4024-portrait-sidebar-tier
fix: don't dock the sidebar on portrait tablets (#4024)
2026-09-01 16:47:44 -04:00
davotoula d9c10f4abb Code reviews:
- refactor(layout): one multi-pane shell, and name the panel predicate honestly
- fix(layout): don't dock the sidebar on portrait tablets (#4024)
2026-09-01 22:10:34 +02:00
davotoula ebcdd9d3d5 feat(layout): pure tier and panel decisions keyed on window shape
Introduces decideNavigationStyle/decideNotificationPanel with the shape
rule from #4024, plus unit tests for the whole behaviour table. Not wired
up yet - rememberScreenLayoutSpec is unchanged, so behaviour is identical.
2026-09-01 22:10:34 +02:00
Claude be4556eef1 test(quartz): take min-of-3 windows in the outbox scale assertion
PoolEventOutboxScaleTest tripped its 5x ratio on the macos-latest
runner: a single 2k-publish timing window is one GC pause away from a
false positive on a shared 3-core VM - the same GC-dominance reasoning
cd344ac0 used when it retired this assertion on Apple targets. Each
side now takes the minimum of three consecutive windows, which filters
stop-the-world pauses while keeping the intent: a real per-entry cost
slows every window, a pause only one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 20:05:54 +00:00
Vitor Pamplona d18b7f770f perf(feed): defer animation transitions until there is something to animate
`updateTransition` and `AnimatedContent` allocate a Transition, its animation
list and its seeking state on *first* composition — but first composition has
nothing to animate, because target and initial state are the same value. In a
feed that is waste: every card scrolled in built six of them, and during a scroll
essentially none ever ran, since reaction counts and icons do not change in the
second a card is on screen.

`DeferredCrossfade` and `DeferredAnimatedContent` render the plain content until
the target actually moves, then build the transition seeded at the *original*
value via `MutableTransitionState` and immediately re-target it — so the first
real change still animates exactly as before, and later changes animate through
the now-live transition normally. The existing `isPerformanceMode()` branch,
which genuinely drops the animation, is untouched and still takes precedence.

Measured on an SM-T220 against a frozen corpus served by a local relay (a real
capture: 105 notes, 68 profiles, 501 reactions, 75 boosts, 22 zaps), interleaved
with the unmodified build, two runs per arm:

  frame duration P90    27.53 -> 26.90   -2.3%   (baseline spread 0.1%)
  frame overrun  P90    21.65 -> 17.44  -19.4%   (baseline spread 6.3%)
  frame duration P50                     -1.1%   (inside a 1.7% spread)

Modest at the frame level by nature: on this device the main thread sits blocked
in `postAndWait` on the RenderThread for roughly two-thirds of every frame, so
composition savings largely do not surface. Removing 24 flow subscriptions per
card, every clickable, or every counter each moved `postAndWait` by only ~2%.

`DeferredAnimationTest` drives the clock manually and asserts the outgoing and
incoming content coexist mid-transition, which only a running animation does; a
regression turning the deferral into a snap fails it.
2026-09-01 15:41:05 -04:00
Vitor PamplonaandGitHub cecc3287b2 Merge pull request #4035 from vitorpamplona/claude/nip50-search-role-mapping-rt4lin
fix(quartz): route the roles the search extractor was stranding in the body tier
2026-09-01 15:40:06 -04:00
Claude fa3287f737 fix(quartz): match java.net.URLEncoder on native; drop the urlencoder dep
Both native targets delegated UrlEncoder to
net.thauvin.erik.urlencoder.UrlEncoderUtil, which implements RFC 3986
percent-encoding. The JVM/Android actual is java.net.URLEncoder/URLDecoder,
which implements application/x-www-form-urlencoded. Different specifications,
and the difference was observable:

                      JVM/Android    UrlEncoderUtil
  encode(" ")         "+"            "%20"
  encode("*")         "*"            "%2A"
  decode("a+b")       "a b"          "a+b"

This is not cosmetic. encode() builds strings that leave the device —
TorrentEvent puts it in magnet links, Nip54InlineMetadata in inline metadata,
Nip47DeepLink in the callback/appname/value parameters of NWC deep links — so
Android and iOS emitted different bytes for the same title. The decode row is
worse: a link written by Android carries '+' for its spaces, and reading it on
iOS or desktop-native gave back literal plus signs, silently, with no error.

Replaced with one UrlEncoder.native.kt in nativeMain, shared by linuxX64 and
every Apple target, matching URLEncoder/URLDecoder exactly — unreserved set is
alphanumerics plus -_.* (note '*' survives and '~' does not, the opposite of
RFC 3986), space to '+', uppercase %XX of UTF-8 bytes otherwise, and '+' back
to space on the way in. Escape runs are encoded and decoded as runs so surrogate
pairs and multi-byte sequences survive, and both directions short-circuit on a
string with nothing to change, as the java.net pair does.

UriParser.linux now delegates to UrlEncoder.decode rather than carrying its own
copy of the decoder added in the previous commit.

The new UrlEncoderTest lives in commonTest, so it pins every target against the
JVM's answers — it is what found all three rows above, by passing on jvmTest and
failing three of ten on linuxX64.

net.thauvin.erik:urlencoder-lib had no other user and is removed from both
source sets and the version catalog.

One deliberate edge difference from the JVM, documented at the call site: an
unpaired UTF-16 surrogate encodes as %EF%BF%BD rather than %3F.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 19:38:00 +00:00
Claude ed9a42f6ed fix(test): import the commons auth types in main's new relay-auth test
RelayAuthPolicyEverywhereTest (from #4033) resolved UserAuthChoice,
RelayAuthPermissionLedger, RelayAuthSessionGrants and
InMemoryRelayAuthPermissionStore via same-package visibility; those
classes moved to commons relayClient.auth on this branch, so the merged
tree needs explicit imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 19:31:07 +00:00
Claude c52fbc428e fix(quartz): stop dropping a bird sighting's alt text, and stop allocating per role
Two findings from an audit of this PR's own diff.

BUG. The kind 2473 branch dropped the `alt` tag whenever commonName()
parsed one out of it, on the reasoning that the alt is only Birdstar's
boilerplate wrapper around the two species names. But commonName()
matches a PREFIX and then cuts at the last " (", so a publisher can
write anything after the parenthetical and it parses just the same:
"Bird detection: Purple Gallinule (Porphyrio martinica) at Lake
Merritt, 7am" yielded "Purple Gallinule" and the tail reached NO role,
while indexableContent() still carried it. That is exactly the drift
against the flat form this PR exists to remove, introduced by the PR
itself. The alt now always reaches the summary tier: the duplicate it
repeats there lands in the weakest role, whereas the drop cost recall
outright.

PERFORMANCE. Extraction runs once per stored event and per full
reindex, and the funnel allocated a throwaway list per role whether or
not the role had anything in it. The single-value tiers() overload
wrapped each of its three values in a list only for cleanAll() to build
another; cleanAll() allocated even when every value was null; the
hashtag role called hashtags(), which allocates unconditionally, on
every event including the great majority carrying no `t` tag; and
locationValues() allocated a list per event to hold, almost always,
nothing.

Both overloads now end in one build() -- so hashtags and locations are
still filled in a single place no branch can forget -- and each
collector allocates lazily. Measured with getThreadAllocatedBytes over
1M extractions, JIT-warm:

  kind 1, no tags          160 -> 40 B/event
  kind 1, six tags         528 -> 168 B/event
  kind 30023 title+summary 272 -> 88 B/event

The hash of every extracted value is unchanged across the A/B, and the
guard added before hashtags() is HashtagTag.parse's own acceptance
test, so it cannot skip a tag the accessor would have returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GznRZiv3zS7V9c2QQ9aMk9
2026-09-01 19:29:38 +00:00
Claude 9134204164 fix(merge): reconcile the Crowdin sync and relay-auth relabel with Wave 3
The merge of main resurrected ~23.4k locale entries for migrated keys
(Crowdin's full sync rewrote regions git couldn't see as conflicting)
and re-added 14 migrated keys to the app default. Reconciled: every
locale entry whose key lives in commons moved there, keeping Crowdin's
fresher text (23,113 replacements); duplicate default keys removed from
app res, except podcast_value_for_value which legitimately lives in
both trees (a toastManager.toast(Int) call site needs the Android id).
RelayAuthPromptHost keeps main's relabeled-button behavior with mixed
addressing - new keys via R.string, migrated ones via Res.string - and
its RelayAuthPrompt/UserAuthChoice imports now point at the commons
relayClient.auth home. Orphan gate green, both apps compile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 19:27:37 +00:00
Claude 8cd6a8d3e9 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt
#	amethyst/src/main/res/values-cs/strings.xml
#	amethyst/src/main/res/values-de-rDE/strings.xml
#	amethyst/src/main/res/values-hi-rIN/strings.xml
#	amethyst/src/main/res/values-hu-rHU/strings.xml
#	amethyst/src/main/res/values-nl-rNL/strings.xml
#	amethyst/src/main/res/values-pl-rPL/strings.xml
#	amethyst/src/main/res/values-pt-rBR/strings.xml
#	amethyst/src/main/res/values-sl-rSI/strings.xml
#	amethyst/src/main/res/values-sv-rSE/strings.xml
#	amethyst/src/main/res/values/strings.xml
2026-09-01 19:10:09 +00:00
Claude d759741f96 fix(quartz): make the whole linuxX64 test suite pass; widen the CI leg
The 78 failures on this target were not 78 unimplemented actuals. Two root
causes accounted for all of them.

TestResourceLoader.linux was a TODO(), so every vector-driven suite failed
before it reached any production code: the full MLS interop set, NIP-44, the
NIP-01 hint indexer, the SQLite store's large-DB tests and the Bolt12 payer
proofs — 69 tests. Implemented over platform.posix (linuxX64 has no Foundation
for the Apple actual's NSData path), resolving against the same
TEST_RESOURCES_ROOT that build.gradle.kts already exports onto every
KotlinNativeTest task. The read is one ftell-sized allocation filled by fread,
so a vector file costs exactly one ByteArray — less than the JVM actual's
bufferedReader().readText(), which grows a StringBuilder as it goes.

UriParser.linux never URL-decoded query values or fragments, though the JVM
actual runs both through URLDecoder.decode(.., "UTF-8"). Every NIP-47 failure
was one symptom of that: relay=wss%3A%2F%2Frelay.damus.io reached
RelayUrlNormalizer still percent-encoded and came back "Invalid relay Url" (6
tests), and the deep-link round trips compared an encoded string against a plain
one (3 tests). Added a decoder matching URLDecoder where the behaviour is
observable — '+' to space, a run of consecutive %XX decoded as one UTF-8
sequence, malformed escapes throwing IllegalArgumentException — with the same
short-circuit URLDecoder makes, returning the original instance when there is
nothing to decode.

Two other divergences fixed while there: getQueryParameter returned an empty
list where the JVM returns null for an absent parameter, and the query string
was re-split on every call rather than parsed once into a lazy map, so a URI
read for four parameters was parsed four times.

With those, linuxX64Test is 3495 tests, 0 failures, so the CI leg added
alongside the LargeCache work drops its cache-package filter and runs the whole
:quartz suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 19:06:53 +00:00
Claude edab00815d fix(quartz): route the roles the search extractor was stranding in the body tier
Audited all ~70 branches of SearchFieldExtractor.base() against the file's
own stated invariant -- "each explicit branch splits exactly the accessors
that kind's indexableContent() concatenates" -- for all 133 searchable
kinds. Nothing tested it, and it had drifted three ways. The full table is
in the PR description; this commit is what it turned up. 19 kinds gain a
branch.

1. A title in the wrong tier. 17 kinds fell through to the catch-all, which
   dumps the whole indexableContent() into the body role, so their titles
   could never reach the title band a weighted backend gives one. The
   marketplace family (30017/30018/30019/30020) and the Podcasting 2.0 pair
   (30054/30055) are the sharpest -- a stall name and an episode title are
   what people actually type. Kind 9002 is the tell-tale: it edits the very
   metadata kind 39000 publishes, and 39000 had a branch while 9002 did
   not. Also branched: 1010, 1065, 1068, 1163, 1985, 2473, 6969, 12473,
   38192, 38383. Every kind still falling through is now body-only -- its
   whole searchable text really is a body (a chat message, a zap comment, a
   git patch, a DVM prompt) -- so no title is left stranded.

2. A role the branch forgot. hashtags and locations are filled systemically
   by the tiers() funnel, but websites is per-branch, and four kinds with a
   public URL were not passing one: GitRepositoryEvent (clones(), the URL
   most people would search a repo by), MeetingSpaceEvent (endpoint(), the
   same `streaming` tag kind 30311 already carries), and both nSite kinds
   (source()). Image, icon and infrastructure URLs stay out on purpose.

3. Drift against indexableContent(). Six kinds concatenated their `t` tags
   INTO the flat blob while the funnel also carried them as hashtags, so
   the same words were indexed twice, in the weakest role -- exactly the
   shape most likely to skew a term-frequency ranker. Fixed by their new
   branches (1111, 1311, 9002, 30018, 30020, 30054), the same treatment
   InterestSetEvent and ContactCardEvent already had.

Two of those branches avoid creating the same duplication they remove:
kind 2473's `alt` is Birdstar's boilerplate wrapper around the two species
names, so it is indexed only when commonName() proves it is NOT that
shape; kind 12473 is a life LIST, so its unbounded species collection sits
in the secondary tier rather than claiming the title band once per bird.

Also writes down the PROFILE XOR TIERED contract in the IndexableFields
KDoc. The sealed type enforces it, and weighted backends already depend on
it: a ranker that scores the two role groups independently and sums them
stays correct only while no document can answer from a naming column in
each group. A shape filling Profile.name and Tiered.primary at once would
claim the top band twice -- measured downstream at ~260 000 against the
~130 000 a whole-field title match earns, i.e. one word per column
outranking a document that IS the query. Saying so makes a future
both-shapes kind a decision with a known cost rather than an accident.

This is derived data: consumers must re-run
IEventStore.reindexFullTextSearch() after upgrading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GznRZiv3zS7V9c2QQ9aMk9
2026-09-01 19:05:12 +00:00
Claude 5e662fef0b perf(quartz): back linuxX64's LargeCache with a striped hash table
The HAMT was the wrong structure for this workload. LocalCache fills on the
order of 100,000 entries in a few seconds, and a persistent map allocates a
fresh path of ~4-5 nodes for every write — including overwrites, which change
no structure at all — then discards it. Over a 100k fill plus scans that is 24
GC cycles.

Replace it with a chained hash table: lock-free reads, striped-lock writes.
This is ConcurrentHashMap's shape, which Kotlin/Native does not ship. Adding a
key prepends one node; overwriting one is a single volatile store into the node
already there, allocating nothing; a scan walks the buckets in place. Chain
nodes hold `next` immutably so a reader never sees it change, which is what lets
reads take no lock at all — structural edits publish a new bucket head, and a
resize rebuilds nodes rather than relinking them.

Measured on linuxX64 (-opt), 100,000 String keys of event-id length:

                 fill  overwrite  reads  20 scans  mixed   GCs  heap
  HAMT + CAS       70         78      6       117    676    24  67MB
  lock + HashMap   13          6      3        71   1197    36  51MB
  striped          15          3      3        13     64     1  43MB

"mixed" is a full fill with a whole-table scan every 1000 writes — the shape
LocalCache actually has. Copy-on-write, the original, is off the scale: 20k
entries alone took 18s to fill.

Every bulk operation now walks the table directly instead of a snapshot, so
scans allocate nothing beyond the result and caller lambdas run outside any
critical section — a LocalCache predicate that reaches back into the cache
cannot deadlock, and there is no ConcurrentModificationException window.

getOrCreate and createIfAbsent are now the JVM actual's bodies verbatim over the
same putIfAbsent contract.

Honest difference from the JVM actual: ConcurrentSkipListMap is fully
non-blocking, whereas writers here block writers hashing to the same one of 16
stripes, for a bucket walk of a few nodes. ConcurrentHashMap makes the same
trade. Readers block for nothing.

Adds LargeCacheCollisionTest, which forces every key into one bucket so the
chain paths — in particular removal, which clones the nodes ahead of the target
onto its tail — run deterministically rather than only on a chance collision.

ConcurrentHashCache.linux moves onto the same table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 18:36:33 +00:00
Vitor PamplonaandGitHub 79f1de3dd4 Merge pull request #4034 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-01 14:29:45 -04:00
vitorpamplonaandgithub-actions[bot] 3e9521204c chore: sync Crowdin translations and seed translator npub placeholders 2026-09-01 18:27:46 +00:00
Vitor PamplonaandGitHub 54fba7f972 Merge pull request #4033 from vitorpamplona/claude/relay-auth-always-allow-09cwpc
Add account-wide relay auth policy choices to NIP-42 prompt
2026-09-01 14:24:02 -04:00
Claude b948a0941a fix: name the scope on the account-wide confirmation buttons
Relabelling the prompt's buttons collided with the confirmation behind
them: with the remember switch on, the prompt says "Always log in" for one
relay while the confirmation for "Always, all relays" said "Always log in"
too, one tap apart and meaning every relay. Same for "Never" against "Never
log in". The confirmation now echoes the link that opened it — "Always, all
relays" / "Never, all relays" — so the scope is stated exactly where the
account-wide answer is committed. No new strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 18:22:20 +00:00
Claude 65ea34342e perf(quartz): make linuxX64's LargeCache lock-free, not lock-based
Follow-up to the previous commit, which fixed the O(n) write by putting a
PlatformLock around a mutable map. That traded one problem for another: the
JVM/Android actual is a ConcurrentSkipListMap, where readers never block and
writers publish with a CAS, and a global lock is a step down from that — worse,
the linux PlatformLock is a spin lock, so a reader could burn a core waiting on
a writer that had been descheduled.

Keep copy-on-write's shape instead — an immutable map behind an AtomicReference,
which is what made reads free in the first place — and fix the two things that
were actually wrong with it. Copying a LinkedHashMap is O(n); a HAMT's putting()
shares structure and copies only the path to the changed key, O(log32 n). And
the read-copy-write was not a CAS loop, so concurrent writers dropped each
other's entries; now they retry.

Reads (get/containsKey/size/keys/values) are a single atomic load plus a lookup.
Bulk operations iterate that same immutable map with no copy, so caller lambdas
run outside any critical section and a LocalCache predicate that reaches back
into the cache cannot deadlock. Writes are a CAS retry.

This is already the house pattern for shared mutable state in commonMain —
FilterIndex and nip86 BanStore hold state in one AtomicReference over persistent
collections and mutate it with the same loop — and kotlinx-collections-immutable
is already a quartz commonMain dependency.

Measured on linuxX64 (-opt, ms per loop), vs copy-on-write and vs the lock
variant this replaces:

  n=20,000       fill   reads  20 scans  mixed
  copy-on-write 17,949      2        13  25,278
  lock+HashMap       1      0        12      35
  HAMT+CAS          14      0        19      28

  n=200,000      fill   reads  20 scans  mixed
  lock+HashMap      44      9       177   6,736
  HAMT+CAS         197     12       237   2,486

Write-only, the lock wins ~4x. But LocalCache interleaves full-cache scans with
arriving events, and there the lock must rebuild an O(n) read snapshot per write
epoch: it loses by 2.7x at 200k. So the non-blocking design also wins the
workload that matters.

ConcurrentHashCache.linux gets the same treatment; iteration order becomes hash
order (as on Apple) rather than insertion order. Nothing depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 17:57:22 +00:00
Claude d3aa91f856 feat: relabel the prompt's buttons to the answer the remember switch gives
With the switch on, "Not now" wrote a permanent DENY and "Log in" wrote a
permanent ALLOW while both still read as one-off answers. The switch is the
scope of the answer, so the buttons now state the answer they actually
give: "Never" and "Always log in". The refusal takes the error colour with
it while the switch is on, which is the weight the removed red "Never
allow" button used to carry.

This closes the mis-tap the switch's new binding opened: flipping it for
"log in", then changing your mind and pressing what still said "Not now",
blocked the relay for good with nothing on screen saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 17:50:49 +00:00
Claude 6c07dcb839 perf(quartz): drop copy-on-write from linuxX64's LargeCache
LocalCache's linuxX64 store kept a LinkedHashMap inside an AtomicReference
and replaced it wholesale on every write, so each put was O(n) in the size
of the cache and filling it was O(n^2). It was not thread-safe either: the
read-copy-write was not a CAS loop, so concurrent writers silently dropped
each other's entries.

Replace it with a mutable map guarded by PlatformLock plus a lazily rebuilt
read snapshot. Point operations (get/put/remove/containsKey/size) are O(1)
under the lock; bulk operations run against a point-in-time copy rebuilt at
most once per write epoch, which also keeps caller-supplied lambdas out of
the critical section — PlatformLock is not reentrant here and LocalCache
predicates call back into the cache.

Two behaviour fixes fall out of matching the JVM actual's putIfAbsent:
createIfAbsent now reports true only when this call inserted (it previously
returned get(key) != null, which also reported true when another thread had
just created the entry), and getOrCreate publishes atomically.

ConcurrentHashCache.linux gets the same treatment. Its only caller,
CachingEventDecoder, writes once per event arriving from a relay, so the
per-write map rebuild was the worst-placed copy of the three.

None of this was caught because no CI job compiled or ran linuxX64. Add
LargeCacheTest to commonTest as a cross-target contract for the ~40 methods
each actual reimplements by hand, a linuxTest suite covering the concurrency
this actual now has to get right on its own, and a CI leg that runs both on
Linux Native.

That leg is scoped to the cache and concurrency packages: the full
linuxX64Test suite is 3,490 tests with 78 pre-existing failures, nearly all
TODO() stubs in linux actuals that were never written (MLS crypto, the
SQLite driver, NIP-44, Bolt12). Filling those in is its own project; the
filter keeps the job meaningful and green, and widening it later is one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxQ1QuyzSkR38iFHbREjoS
2026-09-01 17:26:50 +00:00
Vitor PamplonaandGitHub 94db88943c Merge pull request #4032 from vitorpamplona/claude/pool-outbox-scale-test-fix-2dohcc
Move relay set test to commonTest, expand scale test docs
2026-09-01 12:54:00 -04:00
Claude e61d30dfb7 fix: don't let the answer window swallow an account-wide relay auth answer
Audit of the two commits before this one turned up two ways the new
"Always/Never, all relays" answers could be given and not take effect.

A prompt's answer window is 60s from the dialog appearing, and the
confirmation dialog spends it: a user who reads the warning, thinks, and
confirms past the minute hits a resolved deferred, where complete() is a
no-op. The AUTH was already lost at that point — fine, the socket cannot
wait — but the *setting* was lost with it, silently, which is not. The
policy write moves to AuthCoordinator.applyPolicyEverywhere, called by the
prompt the moment the user confirms; the answer path calls the same
function, so there is still one writer and it is idempotent. A confirmation
that lands late now still sets the policy, and the relay's next challenge
is answered by it.

The other one: prompts queued behind the dialog were decided before the
policy existed, so "all relays" was immediately followed by a question
about relay B. They are now answered with the same choice. That needs
markShown() as well as respond() — an unshown prompt is parked in the
five-minute queue-wait window and does not read an answer dropped into its
deferred until that window ends, which would have left a relay
unauthenticated for five minutes after the user answered for it.
RelayAuthPromptBusTest pins the timing; it fails at 300000ms without the
markShown.

Also retires the comments in the ledger, Account and the resolver that
still explained a DENY as the "never allow" button, which no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 16:35:21 +00:00
Claude cd344ac07d fix(quartz): stop asserting the outbox cost curve where the GC dominates it
PoolEventOutboxScaleTest failed on iosSimulatorArm64. The outbox is not at
fault: on Apple targets LargeCache wraps charlietap's CacheMap, whose
LeftRight `mutate` applies each write to both of its two maps under a lock.
That is O(1) per put with no copying, so the quadratic this test exists to
catch cannot occur there.

What the test actually measures on Kotlin/Native is the GC. It keeps 60k
entries alive on purpose, so the late window runs against a heap ~30x larger
than the early one. A generational collector does not rescan that old
generation on a young collection and the growth stays invisible; Kotlin/
Native's non-generational tracing GC does rescan it, and the ratio reports
the collector instead of the outbox.

Measured on Kotlin/Native (linuxX64, -opt), publishing the same 60k events
into structures that are O(1) per put by construction:

  retains nothing                 ratio 0.51 - 0.80
  one HashMap, 60k live           ratio 1.93 - 3.39
  two HashMaps per put, 60k live  ratio 2.28 - 5.21

The last row is the Apple path's actual work, and it crosses the test's 5.0
threshold on a loaded machine — which is how a shared CI runner turns a
healthy implementation red. The first row is the control: same allocations,
nothing retained, curve flat.

So move the timing assertion to jvmAndroidTest, where LargeCache is a
ConcurrentHashMap and a wall-clock ratio is a valid instrument. The guard it
provides is unchanged: reintroducing a copy-on-write map or a per-publish
full scan in this class still fails it. The test body is untouched; only its
source set and its KDoc change.

The relay-set bookkeeping half was platform-independent logic, not a
measurement, so it stays in commonTest as PoolEventOutboxRelaySetTest and
keeps running on every target.

Worth a separate look: linuxX64's LargeCache actual is genuinely
copy-on-write (LinkedHashMap(mapRef.value) per mutation), so it really is
O(N) per put. No CI job runs linuxX64Test today, and the numbers above show
a wall-clock ratio cannot report that reliably anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F4Z1E5JJkYXqZznqVsYex6
2026-09-01 16:29:11 +00:00
Claude 8d2c50ae5c fix: make the prompt's remember switch mean the same thing for both answers
"Remember for this relay" was read only by the Log in button. Pressing "Not
now" with the switch on wrote nothing at all — no exception, not even a
session-scoped no — so the same dialog came back on the relay's next
reconnect, while the switch sat there claiming otherwise. The one way to
say "stop asking about this relay" was the red "Never allow" button beside
it.

The switch is now the scope of whichever answer is given, so the two
buttons times the switch are the four per-relay UserAuthChoice values: log
in once or always, refuse once or for good. That makes "Never allow"
exactly "Not now" with the switch on, written twice, so it goes.

Its slot becomes the missing half of the account-wide pair: "Never, all
relays" sets RelayAuthPolicy.NEVER opposite "Always, all relays". Both
confirm first, sharing one confirmation that names the consequence of each
direction — the never side warns that relays will refuse to serve, which is
the part a link label cannot carry. It routes through
Account.changeDefaultRelayAuthPolicy, which drops this run's session grants
along with the flip; a grant left behind outranks the policy, so "never log
in" would have gone on authenticating the relays just answered "log in".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 16:23:55 +00:00
Claude 19d3472ad8 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationContent.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/shorts/VideoCardCompose.kt
2026-09-01 16:05:08 +00:00
Claude 3f31c78242 test: pin Blossom notes across the consume window - LargeSoftCache evicts
Root cause of the recurring DesktopBlossomServerListTest CI failures,
finally caught by the diagnostic added last round (flow=[], getter=null,
no verification warning): DesktopLocalCache.addressableNotes holds notes
via SoftReference (LargeSoftCache). Under CI memory pressure a GC evicts
the consumed note between cache.consume() and the state's
getOrCreateAddressableNote(), which then mints a fresh EMPTY note - the
flow can never surface the servers. Production is immune because
BlossomServerListState pins blossomListNote as a field for its lifetime;
the tests just never held a strong reference across that window. All
three tests now pin the note before consuming, and the state test
asserts the event landed before construction so an eviction fails fast
at the source.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 15:58:21 +00:00
Vitor PamplonaandGitHub 6f52d1de90 Merge pull request #4028 from vitorpamplona/claude/m3u8-playlist-quality-kl0xrz
fix(video): use the HLS master playlist and its full quality ladder
2026-09-01 11:54:02 -04:00
Claude 58ca467ff0 feat: let the relay auth prompt turn on "Always log in" for every relay
The prompt's bottom row had one standing answer, "Never allow", and a link
out to the settings screen. The opposite standing answer — "just log in
everywhere, stop asking" — was only reachable by finding Settings ▸ Relay
login, so the fast way to stop a run of prompts was to block relays one at
a time.

"How Amethyst decides" is replaced by "Always, all relays", which switches
the asking account to RelayAuthPolicy.ALWAYS and answers the pending
challenge. It is the one action here that writes an account-wide setting,
so it confirms first: the label cannot carry the fact that it applies to
every relay that ever asks, and a mis-tap would reveal that npub to all of
them.

The write lands in AuthCoordinator, not the dialog, because the policy
belongs to the account the prompt named — one socket serves every
logged-in account, so the screen's account is not necessarily that one. No
per-relay exception is stored alongside it: the policy already answers this
relay, and an exception would outlive a later switch back to "decide per
relay". Blocked relays and existing "never" exceptions still outrank it,
which is what the confirmation promises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NTYFnqWcwLVPNNSr5kusd
2026-09-01 15:50:45 +00:00
Claude 54c6521f3f fix(video): five defects found auditing this PR's own diff
Four were confirmed with throwaway probe tests against the branch.

An HLS master labelled `audio/x-mpegurl` or `audio/mpegurl` was read as a
separate audio track and dropped. Those are two of the four playlist MIMEs
this repo already recognises in isHlsMimeType and MediaItemCache — legacy
aliases naming the manifest format, not a claim about the content. A master
labelled that way lost to a 360p rung; when every entry used it the candidate
list emptied and selection fell through to the poster JPEG, handed to the
video player. The HLS test now precedes the audio test.

withLadderMetadataFrom filled `dimension` from every imeta, poster included,
so a 16:9 thumbnail beside a vertical short produced a 16:9 master and
JustVideoDisplay laid the box out at 16:9. Only entries that could be the
video may describe its shape; the poster still supplies the still image.

isHlsPlaylist treated any declared MIME as authoritative, so `master.m3u8`
served as application/octet-stream — a server default, not a claim — was not
HLS and lost to a correctly labelled low rung.

The metered 480px cap had no fullscreen exemption, so tapping into fullscreen
on mobile data pinned 480p and put a ceiling the quality menu's "Auto" could
not exceed. The cap exists to hold back feeds that autoplay unasked; someone
who tapped fullscreen asked.

The PiP gate skipped the viewport push entirely until isInPictureInPictureMode
turned true, with no retry. Since demoteToCold clears track overrides but not
the viewport, a pooled player kept whatever its previous view pushed if PiP was
never entered (per-app PiP off, no FEATURE_PICTURE_IN_PICTURE). It now caps the
pre-shrink measurement instead of skipping it, so a viewport is always pushed
and can never be a stale full-screen one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 15:50:04 +00:00
Claude 5b3eb140a6 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm
# Conflicts:
#	amethyst/src/main/res/values-de/strings.xml
#	amethyst/src/main/res/values-eo/strings.xml
#	amethyst/src/main/res/values-fa/strings.xml
#	amethyst/src/main/res/values-fr/strings.xml
#	amethyst/src/main/res/values-nl/strings.xml
#	amethyst/src/main/res/values-ta/strings.xml
#	amethyst/src/main/res/values-th/strings.xml
2026-09-01 15:27:06 +00:00
Claude 7ed368118c feat(video): cap the rendition viewport on metered connections; fix a PiP race
Follow-up to @davotoula's review on #4028. Sizing the ladder to the player
is right on wifi, but it removed the app's only bandwidth lever and put
nothing back: on mobile data a full-width card would pull most of the ladder,
with only the ConnectivityType autoplay gate — which decides whether to play,
not how much to pull — standing between a scroll and the data bill.

Clamp the pushed viewport to a 480px short side while
isMobileOrMeteredConnection is true, preserving aspect so the viewport still
describes the player's shape. It stays one lever at the single setViewportSize
call rather than a policy per call site, and it keeps the "quality
proportional to the player" behaviour on wifi. Connectivity changes do not
relayout, so a LaunchedEffect re-pushes with the last measured size when the
ceiling flips; before the first measurement the existing zero-size guard makes
that a no-op.

Also from the same review: processIntentForPiP calls enterPictureInPictureMode
from composition (PiPFromIntents), so the first layout pass can measure the
activity at full screen before the window shrinks, handing the selector a
full-screen viewport for the opening seconds of a PiP that is a few inches
wide. RenderPipVideo now withholds the push until isInPictureInPictureMode is
true; the shrink relayouts and pushes the real size. The pre-T makeBasic()
path still wants an on-device look.

clampViewportShortSide rounds up so a rounding artifact can never ask for 479
and drop a rung that sits exactly at the cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 14:58:26 +00:00
Claude d691317489 fix(video): don't let a dim-less rendition outrank a dimensioned master
Review catch from @davotoula on #4028. The selector checked "any HLS entry
without a dim" before comparing declared resolutions, so a dim-less entry
won outright. That reads the wrong shape: the sloppy-publisher case is not
"master without dim, renditions with dim" but the reverse — a master that
declares its top resolution beside a rendition that forgot one. There the
old order picked the single-rung media playlist, which is the exact bug this
selector exists to fix, silently.

Tag order now decides only when no HLS entry declares a dim at all;
otherwise the largest declared dim wins. That fails the other way instead:
worst case we take the top rendition and lose adaptation, never the bottom
one.

Also from the same review: presentation metadata was filled from the
playable candidates only, so a poster published as its own image/* imeta —
which canBeTheVideo() excludes — never reached the chosen entry. Fill from
every imeta, and take an image sibling's own url as the poster when no entry
carries one in `image`, which is where the notification big-picture path
looks.

Restore the rendition diagnostics that went with the old fixed-policy
selector: every viewport push and the rung adaptive selection actually
landed on, against the ladder on offer, under the VideoQuality tag. The
listener is registered only when the trace can be emitted (debug sets
Log.minLevel = DEBUG, benchmark/release ERROR), so the release path keeps
the no-listener-per-player property.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 14:39:50 +00:00
Vitor PamplonaandGitHub 725bbc557c Merge pull request #4029 from vitorpamplona/dependabot/github_actions/actions-9a9d8496c4
chore(actions): bump the actions group across 1 directory with 3 updates
2026-09-01 10:06:44 -04:00
David KasparandGitHub e9437aa371 Merge branch 'main' into claude/m3u8-playlist-quality-kl0xrz 2026-09-01 13:30:51 +02:00
dependabot[bot]andGitHub cdff305242 chore(actions): bump the actions group across 1 directory with 3 updates
Bumps the actions group with 3 updates in the / directory: [actions/setup-java](https://github.com/actions/setup-java), [mikepenz/action-junit-report](https://github.com/mikepenz/action-junit-report) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release).


Updates `actions/setup-java` from 5.7.0 to 6.0.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5.7.0...v6.0.0)

Updates `mikepenz/action-junit-report` from 6.4.2 to 6.5.0
- [Release notes](https://github.com/mikepenz/action-junit-report/releases)
- [Commits](https://github.com/mikepenz/action-junit-report/compare/d9f48fc87bc235f7e214acf696ca5abc0a986f16...a9170d5795813c01ab4901ffb045b52bab4ab09d)

Updates `softprops/action-gh-release` from 3.0.2 to 3.0.3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: mikepenz/action-junit-report
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 11:01:36 +00:00
David KasparandGitHub 1590dc8570 Merge pull request #4030 from davotoula/fix-i18n-and-add-hook-to-prevent-future-failure
Fix i18n and add hook to prevent future failures
2026-09-01 12:57:40 +02:00
davotoula 42c96bf8fc feature: document the orphaned-translation trap and gate it pre-push
The [ExtraTranslation] failure that took main red in 1ce583ec92 was not
documented anywhere. amethyst/src/main/res/CLAUDE.md and the
find-missing-translations skill both mention the lint rule, but only inside
one narrow case (converting a <string> to <plurals>). Neither stated the
general rule: removing or renaming a key in the default values/strings.xml
orphans every locale entry that still declares it.

Nor would running lint have caught it in practice. The only pre-push gate is
pre-push-spotless.sh, which runs spotlessApply and nothing else, and
:amethyst:lintFdroidBenchmark takes ~19 minutes on a warm daemon, so it is
not a per-commit check.

Add both halves:

- amethyst/src/main/res/CLAUDE.md gains a "Renaming or removing a string key"
  section stating the same-commit rule and why Crowdin is not a cleanup step
  CI waits for. The trap is that Crowdin is *partly* reliable — it cleaned 32
  of 47 locales — so the tree looks correct in whichever files you open.
  Retitled the file (it is no longer plural-only) and marked the existing
  sections as the plural-specific ones they always were.
- orphan_strings_check.py scans every locale's resource names against its
  tree's default values/, across both Crowdin-managed resource systems: the
  Android res trees and the commons Compose-Multiplatform catalog. 0.17s
  against the full repo. pre-push-orphan-strings.sh wraps it as a PreToolUse
  gate on git push / create_pull_request, reusing the shell-tokenizing
  push-detection from pre-push-spotless.sh so "push" inside a commit message
  is not mistaken for the subcommand.
- find-missing-translations gains a Common Mistakes entry pointing at both.

Verified: clean on the current tree; exit 2 listing the orphans when
route_video is reintroduced in two locales and a retired key is seeded in the
Compose catalog; gate fires on git push and create_pull_request, stays quiet
on a commit whose message contains "push" and on non-Bash tools.
2026-09-01 11:12:43 +02:00
davotoula 467a1f0c06 fix(i18n): drop retired route_video/new_short keys from 15 locales
d6d5a72e49 renamed route_video -> route_media and new_short -> new_media
in the default locale, on the assumption that Crowdin would retire the
old keys on its next sync. The sync merged right after (7a4dc1b378)
cleaned 32 of the 47 locales but left both stale keys in 15 of them.

Android lint runs on the pushed tree, not on Crowdin's next round, so
those 2 keys x 15 locales became 30 [ExtraTranslation] errors and failed
:amethyst:lintFdroidBenchmark on main:

  values-th/strings.xml:515: Error: "route_video" is translated here but
  not found in default locale [ExtraTranslation]

Delete the orphaned entries. A diff of all 4540 default keys against
every locale confirms these were the only two orphans;
:amethyst:lintFdroidBenchmark now passes.
2026-09-01 11:12:37 +02:00
Claude d2950b03de test: pin the Blossom flow test's collector to Dispatchers.Unconfined
Third CI failure mode for this test, and the first that was real: with
CoroutineScope(SupervisorJob()) the stateIn(Eagerly) collector needs a
Dispatchers.Default worker (4 on CI), and another desktop test in the
shared JVM leaking a blocked Default thread starves it - the flow then
never surfaces the servers and the 30s timeout fires. Unconfined starts
the collector synchronously and resumes it on the flowOn(IO) producer
thread, so the test depends only on the 64+-thread IO pool. Timeout now
fails with the flow/getter state for diagnosis instead of a bare
TimeoutCancellationException.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 04:20:57 +00:00
Claude 1b5aa8eadb docs(plans): record the executed Wave-3 bulk string migration
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 04:04:07 +00:00
Claude 7c6a18ee52 feat(i18n): bulk-migrate 2,565 mechanically-safe string keys to commons
Every key whose usages are all composable stringRes/stringResource
calls, with no XML references, no bare %s/%d, only %N$s/%N$d args and
no inline markup, moves from the app's res/ to commons Compose
resources (~105,800 locale entries across 57 locales, translations
preserved byte-for-byte for Crowdin). 568 app files repointed to
Res.string via the stringRes bridge overloads.

The app keeps 1,866 keys that are genuinely Android-bound: ctx-based
call sites, Int-typed id storage (maps/whens), @string/ XML references,
and non-positional format args.

Tool fix folded in: Crowdin emits some entries with attributes before
name= (xmlns:ns0=... name="key") - the extraction regex now matches
any attribute order; the six entries the old pattern missed (zh,
nl-rBE, es x account_backup_tips{2,3}_md) are relocated, and 11 in-file
duplicates from keys that already existed in commons are removed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 03:31:48 +00:00
Claude 8128507c12 feat(i18n): build the Wave-3 strings bridge and migrate the first key
- commons/ui/StringRes.kt: the Compose-resources twin of the app's
  stringRes family (composable, formatted, plural, and suspend
  loadStringRes variants). Thin delegates - compose-resources caches
  parsed locale files process-wide, so the Android-style LruCache is
  unnecessary here.
- App StringResourceCache.kt gains stringRes(StringResource) overloads
  delegating to the bridge, so a file can mix migrated and unmigrated
  keys under its existing single import; migrating a key is just
  R.string.x -> Res.string.x.
- tools/strings-migrate: moves keys from app res to commons
  composeResources across all locales byte-for-byte (both trees are
  Crowdin-managed with the same android mapping); refuses keys using
  bare %s/%d since compose-resources only formats positional args.
- Exemplar: profile_banner - the single string blocking the ui/layouts
  cluster - migrated across 57 locales, all 7 call-site files repointed.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:35:21 +00:00
Claude 13dc29f50f refactor: restore the napplet registry boundary; dedupe the Skia converter
Move NappletLaunchRegistry back to :amethyst - CLAUDE.md's napplet
security model relies on the broker-side registry being unimportable
from :nappletHost, and its only consumers live in :amethyst anyway.

Hoist PlatformImage.toSkiaBitmap() into a new skikoMain source set
(desktop JVM + iOS, both Skiko-backed) instead of duplicating it in the
two CoilImageBridge actuals; add skikoMain to the KMP purity gate.

Update the migration plan's handoff notes: audit findings 1/2/3/5/6
fixed, baseline-profile regeneration is the one open item.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:22:55 +00:00
Claude 6423b65951 fix(app): set HTTP env flags before AppModules builds the OkHttp factories
AppModules' constructor eagerly builds both OkHttp factories, whose
dispatchers read HttpClientEnvironment.isEmulator at construction time.
Setting the flag after AppModules left the emulator-safe limits dead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:22:52 +00:00
Claude cb9bb9d4b4 fix(model): keep TopFilter's pre-move serial names so saved prefs survive
The move to commons changed every subclass's kotlinx default serial name
(the FQN), which is the polymorphic type discriminator JsonMapper writes
into the per-account DEFAULT_*_FOLLOW_LIST preferences. Decode failures
are swallowed by parseTopFilterOrDefault, so without this every user's
~30 saved tab selections silently reset on upgrade. @SerialName pins the
old names; TopFilterSerialNameTest pins them (and legacy-JSON decoding)
on JVM and iOS.

Also annotate the nativeMain Address actual @Serializable to match the
jvm/android actuals: @Contextual properties only get the plugin's
compile-time fallback on targets whose actual is serializable, so
encoding an address-carrying TopFilter threw on iOS.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:22:49 +00:00
Claude 36edbb146e test: run the Blossom flow test on real dispatchers, not runTest virtual time
BlossomServerListState.flow hops through real Dispatchers.IO (flowOn)
into a stateIn collector. Under runTest both the awaiting coroutine and
the stateIn scope sit on the virtual-time scheduler, and the IO handoff
can park while that scheduler is idle - runTest then aborts with
UncompletedCoroutinesError, which is exactly how the previous hardening
(await-the-flow-first) failed on the Linux DEB CI job. runBlocking with
a private cancellable scope keeps every dispatcher real, and withTimeout
bounds a genuine hang with a clear error instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 01:06:17 +00:00
Claude 421b25bb0e refactor(video): size the quality ladder to the player, not to a policy
The rendition policy this branch added was two named cases — LOWEST for
inline media, AUTO for the media-card feeds — with the choice threaded
through VideoView, ZoomableContentView and every call site. Both cases
were guessing at the same underlying quantity: how big the player
actually is.

ExoPlayer already filters renditions by a viewport; its default is the
physical display size (TrackSelectionParameters.Builder.init sets
isViewportSizeLimitedByPhysicalDisplaySize), which is why AUTO on a
thumbnail fetched a display-resolution rung and why LOWEST had to exist
as a counterweight. Handing it the measured size instead answers the
question directly: a full-width short gets the top of the ladder, a
small inline player gets the rung matching its pixels, PiP gets a small
one because its window is small, and all of them still adapt to the
connection under that ceiling.

setViewportSize is safe where setMaxVideoSize would not be:
DefaultTrackSelector derives its retain threshold from an actual
rendition and leaves the group untouched when nothing covers the
viewport, so a small player can never filter every track away. Manual
picks from the quality menu still win, since overrides are re-applied
after constraint-based selection.

The measurement rides the onSizeChanged RenderVideoPlayer already had
for double-tap seeking, so no new layout observation and no
recomposition; a guard keeps a settling layout pass from re-running
track selection over IPC. VideoQualityPolicy, ApplyInitialVideoQuality
and findLowestResolutionTrackIndex are gone, and VideoView and
ZoomableContentView are back to byte-identical with main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-09-01 00:56:49 +00:00
Claude 7965ab5697 docs(plans): record migration state, audit findings, and next steps for handoff
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-09-01 00:52:14 +00:00
Claude 5cf47f6fb5 fix(i18n): drop orphaned route_video/new_short keys from 15 translations
PR #4026 retired the route_video and new_short keys from the default
locale (the mixed media feed is no longer labelled "Shorts"), expecting
the next Crowdin sync to drop the retired keys from the translations.
The sync merged in #4027 didn't, so 15 locales still carry them and
:amethyst:lintFdroidBenchmark fails ExtraTranslation with 30 errors
(15 locales x 2 keys) on main and on every branch that merges it.

Same cleanup as e4d288a9 did for the orphaned AI-writing keys.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 23:34:52 +00:00
Claude bf75e43102 Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm 2026-08-31 23:34:16 +00:00
Claude 2cbb39f113 test: harden DesktopBlossomServerListTest against the IO-dispatcher race
The compose-ui-test job failed once on this test (green locally and on
every re-run): BlossomServerListState's flow is stateIn over
Dispatchers.IO, so asserting the synchronous getter before the flow had
settled raced the IO hop on fast runners. Await the flow first - it
settling proves the state finished wiring - then assert the getter.
This PR does not otherwise touch nipB7Blossom; the test predates it
(#3918).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 22:36:58 +00:00
Claude 9639f97c5e fix(video): use the HLS master playlist and its full quality ladder
NIP-71 video events (kinds 34235/34236) carry a whole HLS ladder in their
imeta tags: one entry for the master playlist, which enumerates every
rendition, plus one per rendition, each locked to a single resolution.
Two separate things kept playback at the bottom of that ladder.

Renderers took `imetaTags()[0]` blindly. That only works because our own
publisher emits the master first (HlsVideoEventBuilder); a client that
orders the tags differently pinned us to one rung, with no adaptive
bitrate and no quality menu either, since a media playlist exposes a
single video track and VideoQualityButton hides itself below two. The
feed filters already accepted an event when *any* imeta was playable,
so the tag the card rendered was not necessarily the one that made it
pass. A new `VideoEvent.selectVideoTrack()` in commons prefers HLS over
a progressive file, then the master among the HLS entries (largest
declared dim, earliest tag winning a tie; a dim-less manifest ahead of
dimensioned rungs), skipping the separate audio track NIP-71 PR #2255
allows and any poster image. Presentation metadata is ladder-wide, so
whatever the chosen entry is missing is filled in from its siblings and
the blurhash, poster and aspect ratio survive the switch.

Even with the master selected, VideoViewInner derived its rendition
policy from `isFullscreen` alone, so everything outside the fullscreen
dialog was pinned to LOWEST. That is right for a video attached to a
note, but the shorts, video and longs feeds render the video full-width
as the post itself — a portrait short fills most of the screen at 360p.
The policy is now an explicit parameter, defaulting to today's behaviour
everywhere, and the media-card feeds pass AUTO so ExoPlayer adapts.

Notification big pictures go through the same selector, so a ladder that
declares `image` on only some rungs gets a poster instead of falling back
to a playlist URL Coil cannot decode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQs7TP2WeXNR8SwUNLgmUC
2026-08-31 22:29:10 +00:00
Claude a9fc0184d4 refactor: execute Tier 3 - diagnostics, image fetchers, minter JSON
BootRelayDiagnostics -> commonMain. The concurrency review found it
already fully non-blocking: per-relay atomic counters on the hot
per-message path and get-or-create maps, no locks. Swaps: stdlib
kotlin.concurrent.atomics, quartz ConcurrentMap, TimeUtils.nowMillis,
and the daemon dump thread became a coroutine that completes after the
last scheduled census instead of parking a thread for process lifetime.

Blurhash/Thumbhash/Base64 fetchers -> commonMain over a new
CoilImageBridge expect (PlatformImage.toCoilImage +
base64DataUriToCoilImage; android actual = Bitmap.asImage, jvm/ios
actuals = Skia N32 premul from the ARGB pixel buffer, iOS base64 via
Image.makeFromEncoded). desktopApp deletes its three hand-rolled clone
fetchers and registers the shared ones - the first UI-adjacent
duplication the migration removes outright.

BuzzInviteMinter drops Jackson for kotlinx-serialization but stays
jvmAndroid: its OkHttp pin is load-bearing, since the NIP-98 u tag is
signed over OkHttp's canonical URL string and the transport must not
drift from the canonicalizer. PodcastRemoteContent is reclassified to
the OkHttp tier - the object IS a capped HTTP GET; injecting the fetch
would leave an empty shell.

Verified: verifyKmpPurity, JVM, iosArm64 compile + test-compile,
Android, desktop, and the quartz/commons/cli test suites.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 22:19:16 +00:00
Vitor PamplonaandGitHub 2c4cf9d160 Merge pull request #4027 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-31 18:08:09 -04:00
Claude 33c87b4935 refactor: promote 16 Tier-2 files to commonMain with a concurrency review
Instead of blindly porting synchronized blocks, each lock was judged:

Removed (guarded nothing, or a lock-free design exists):
- RelayAuthPromptBus: mutableMap+synchronized -> ConcurrentMap.getOrPut
  with identity-check ownership; lock-free, cold path
- NappletLaunchRegistry: its JVM-only access-ordered LinkedHashMap +
  @Synchronized trio became androidx.collection.LruCache (internally
  synchronized, access-ordered cap - identical touch-on-resolve/evict
  semantics); SecureRandom -> quartz RandomInstance

Kept as KmpLock (real multi-field invariants; uncontended lock beats
copy-per-write CAS on GC):
- ChatDeliveryTracker (hot OK path already lock-free via volatile
  immutable maps; the lock coordinates three structures + eviction)
- NWCPaymentFilterAssembler (debounce set + job swap atomicity)
- NappletNotificationStore (per-coordinate insertion-ordered buckets)
- DeferredDeleteFileSystem (pending-set membership decides deletion)

Mechanical replacements throughout: java.util.concurrent atomics ->
kotlin.concurrent.atomics; ConcurrentHashMap -> quartz ConcurrentMap
(extended with putIfAbsent/remove(key,value)/clear across expect, JVM,
and native CoW actuals); System.currentTimeMillis -> TimeUtils.nowMillis;
java.io.IOException -> okio; TimeUnit TTL -> plain ms. speedLogger's
kotlin.concurrent.timer became a coroutine tick RelaySpeedLogger cancels
in destroy() - the old daemon timer ran forever.

Also promoted: BlossomAuth (quartz-only imports, unblocks the token
provider). Reclassified to blocked: NappletIdentityWatch (needs
NappletProtocolJson, pinned by java.util.Base64) and NamecoinNameService
(quartz ElectrumXClient is jvmAndroid).

Verified: verifyKmpPurity, JVM, iosArm64 compile + test-compile,
Android, desktop, and the quartz/commons/cli test suites.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 22:04:07 +00:00
vitorpamplonaandgithub-actions[bot] 7a4dc1b378 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-31 21:56:42 +00:00
Vitor PamplonaandGitHub b335e2991a Merge pull request #4026 from vitorpamplona/claude/shorts-link-naming-0nd6mz
Distinguish media feed from shorts feed in UI labels
2026-08-31 17:53:39 -04:00
Claude 8ab56e7b37 refactor: promote the 9 unpinned jvmAndroid files to commonMain
Tier 1 of the jvmAndroid promotability audit: the seven relay-AUTH model
files, EncryptionKeyCache, and HttpClientEnvironment have no JVM-only
API usage - a pure source-set move. Verified against verifyKmpPurity,
JVM, iOS (compile + test-compile), Android, and desktop.

NWCPaymentWatcherSubAssembler turned out to reference
NWCPaymentQueryState, declared same-package in the OkHttp-pinned
assembler, so it is reclassified to Tier 2 in the audit table.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 21:41:29 +00:00
Claude 38f0061710 docs: audit the jvmAndroid-parked files for commonMain promotability
Tier-by-tier table of the 59 files Waves 0-1 placed in jvmAndroid or
androidMain: the exact JVM pin per file and which in-repo KMP
replacement (KmpLock, TimeUtils, stdlib atomics, quartz ConcurrentMap,
RandomInstance, okio, kotlinx-serialization, PlatformImage) unlocks it.
11 move with no code change, 17 with one-line swaps, 6 with small
refactors, 5 wait on a dependency, 20 are the OkHttp engine that stays
until quartz has a KMP transport.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 21:26:23 +00:00
Claude 11ed622abb fix: make the moved commons files compile for the iOS target
CI's test-quartz-ios job compiles commons for iosSimulatorArm64, which
my JVM-only local checks never exercised. Four fixes:

- BitcoinExplorerEndpoint and OtsSettings reference quartz's JVM-only
  OkHttpBitcoinExplorer -> relocated commonMain to jvmAndroid
- GeohashListDecryptionCache, GenericRelayListCache, OutboxLoaderState
  now import kotlinx.coroutines.IO, the commonMain-visible extension
  (plain Dispatchers.IO is internal on Native)
- TorCircuitHealthTracker uses TimeUtils.nowMillis() instead of
  System.currentTimeMillis()
- TopFilter's Address properties are @Contextual: Address is an expect
  class with no serializer, and nothing in the repo actually serializes
  TopFilter, so deferring to a contextual lookup is behavior-preserving

Verified locally: :commons:compileKotlinIosArm64 and
compileTestKotlinIosArm64 now pass (after repairing the sandbox's
corrupted Kotlin/Native gcc toolchain), along with JVM/Android/desktop
compiles, verifyKmpPurity, and spotless.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 21:15:50 +00:00
Claude f598b951c9 fix: make TorCircuitHealthTracker KMP-pure with KmpLock
The move to commons commonMain put two JVM-only synchronized() blocks
behind the verifyKmpPurity gate, which failed CI's lint job. Guard the
streak fields with the commons KmpLock instead, matching the pattern
EOSEAccountFast already documents.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 20:58:15 +00:00
Claude 337e865b4a fix: point main's new NIP-34 notification test at the moved commons filter
NotificationsPerKeyKinds2 lives in commons/relayClient/account since the
migration; the test arrived from main importing the old app-module path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 20:47:21 +00:00
Claude d6d5a72e49 fix(nav): stop labelling the mixed media feed "Shorts"
Two different destinations in the bottom-bar picker showed the same
"Shorts" label with different icons and different content:

- Main > NavBarItem.VIDEO (Route.Video, VideoFeedFilter) is the combined
  media feed: NIP-68 pictures, NIP-94 file headers and every NIP-71 video
  kind (normal, horizontal, vertical, short), scoped by the "stories"
  follow list. This is the one that also shows images.
- Feeds > NavBarItem.SHORTS (Route.Shorts, ShortsFeedFilter) is vertical
  video only (kinds 22 and 34236), scoped by the "shorts" follow list.

Relabel the first one "Media", which is accurate for its contents and
doesn't collide with the neighbouring Pictures / Videos / Shorts feed
entries. The FAB on that screen was described as "New Shorts: images or
videos" for the same reason, so it becomes "New Media: image or video".

Both are new string keys rather than edits in place: all 47 locales had
translated the old keys as "Shorts", and those translations would be
wrong for the new meaning. Crowdin drops the retired keys on its next
sync and the label falls back to English until retranslated.

The NavBarItem enum constants are serialized into user settings by name,
so VIDEO keeps its name — only the display label changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ya9C9GkvkRHLRqrPCAa4WA
2026-08-31 20:41:48 +00:00
Claude 1b2f95f00d Merge remote-tracking branch 'origin/main' into claude/amethyst-commons-migration-hm8vgm 2026-08-31 20:33:27 +00:00
826fc826db feat(notifications): surface NIP-34 PR replies, merges, closes, and drafts
Amethyst already notifies on NIP-34 issues (1621), patches (1617), pull
requests (1618), and PR updates (1619), but the four remaining
participant-facing kinds arrive on the device and go nowhere:

- **1622 GitReplyEvent** — legacy comment. Deprecated by NIP-22 but still
  in the wild (any old-shape ngit/gitworkshop event, and freshly-signed
  ones from clients that haven't migrated). Was fetched by
  `NotificationsPerKeyKinds2` and stored in `LocalCache`, but no
  notification-tab kind-gate and no push consumer branch.
- **1630 / 1631 / 1632 / 1633 GitStatus{Open,Applied,Closed,Draft}** —
  merged, closed, reopened, drafted. Not fetched at all: no relay
  subscription anywhere in the app asks for them for the current user,
  and no repo-scoped fetch pulls them for a visible PR either. As a
  result `GitStatusIndex.latestByTarget` — the source of the
  "closed/merged" pill on the repo listing — could only ever populate
  for the local user's own drafts, since nothing else lands in cache.

Symptom on `main` today: someone merges your PR on a NIP-34 relay
(mine, in a recent example) and Amethyst is silent. No badge on the
notifications icon, no push, no pill on the repo row, nothing. Opening
the PR thread will surface the status through the reply pane's
engagement fetch, but the user has to know to look.

## Fix

Wire all five kinds through the four notification-plumbing layers they
have to pass through, matching the existing patch/issue/PR shape:

1. **`FilterNotificationsToPubkey.NotificationsPerKeyKinds2`** — add
   the four status kinds so `#p`=me on inbox relays actually pulls
   merges/closes for PRs and issues the user participates in. NIP-34
   status events p-tag every prior participant of the target, so a
   pubkey filter is the right primitive.

2. **`FilterRepliesAndReactionsToNotes.RepliesAndReactionsKinds2`** —
   add PR-update (1619) and the four status kinds so when a repo,
   PR, patch, or issue row is on screen the engagement `#e`=<target>
   fetch pulls their status transitions and revision chain. This is
   the wire that finally makes `GitStatusIndex` see data for anyone
   who isn't a p-tagged participant.

3. **`NotificationFeedFilter.NOTIFICATION_KINDS`** + `tagsAnEventByUser`
   short-circuit — add reply (1622) and the four status kinds so the
   in-app Notifications tab renders them. Trust the p-tag relay gate
   (same policy applied to patches/issues/PRs above), because chasing
   a chain of prior status events to reconfirm participant relevance
   would require walking events that aren't guaranteed to be in cache.

4. **`NotificationDispatcher.NOTIFICATION_KINDS`** — add the same five
   kinds so `LocalCache.observeEvents` fires the push consumer. Flip
   the constant from `private` to `internal` so the new contract test
   can pin it against the in-app feed's set without opening it to the
   whole world.

5. **`EventNotificationConsumer.consume()`** — route each of the five
   kinds to `CodeNotification.notify(...)`, matching the existing
   patch/issue/PR/PR-update branches.

6. **`CodeNotification`** — five new `notify(...)` overloads. Reply
   uses a single title string. Status kinds pick their title from the
   *target*'s kind so a 1631 on a kind-1618 PR reads "merged a pull
   request" but the same 1631 targeting a kind-1617 patch reads
   "applied a patch" (matches gitworkshop's conventions). Falls back
   to a generic wording when the target isn't yet in cache — rare,
   because the p-tag subscription pulls a status event regardless of
   whether its target has ever been seen.

7. **`LocalCache.computeReplyTo`** — add `GitStatusEvent` and
   `GitPullRequestUpdateEvent` branches so status/revision events
   thread under their target patch/PR/issue in `Note.replies`. Only
   the marked-`root` `e` tag (for status) / `parentPullRequestId()`
   (for PR update); the repository `a` tag is not a reply target.

8. **`KindDisplayName`** — wire the four status kinds plus PR + PR-
   Update into the kind→label mapping used by the relay debug screen
   (the pre-existing `kind_git_pr` / `kind_git_pr_update` strings
   already existed but weren't wired; the status labels are new).

9. **Strings** — new `app_notification_code_channel_message_reply`,
   four `_status_open/applied/closed/draft` titles plus target-kind-
   specialized applied/closed variants (`_status_applied_pr`,
   `_status_applied_patch`, `_status_applied_issue`, and the closed
   trio); new `kind_git_status_{open,applied,closed,draft}` labels.
   `translatable="true"` (Crowdin's default) so translators can pick
   up appropriate phrasing.

Nothing changes for events the user isn't p-tagged on: the relay-side
filter is still `#p`=me. Nothing changes for the four kinds already
covered: their existing branches are untouched.

## Tests

New `Nip34NotificationCoverageTest` pins the full NIP-34 collaboration
surface across the four independent kind lists that have to move
together (relay subscription, engagement fetch, in-app kind gate,
push kind gate). Miss any one and one specific transition silently
drops. Tests explain the failure mode in each assertion message.

Existing `NotificationKindsContractTest` and every other test under
`notifications/*` still passes.

`./gradlew :amethyst:compilePlayDebugKotlin` clean.
`./gradlew :amethyst:testPlayDebugUnitTest --tests
"…notifications.*"` all green (58 tests including the 4 new).
`./gradlew spotlessCheck` clean.

(cherry picked from commit 3f2c52b97a68e6e3274443682ddbeddb3dbd6fe9)

Applied from nostr proposal
819c0ccc881ced7753675f9ba6a262579eb9772d8b910d14727b5531ede52014
(branch feat/nip34-pr-notifications). Cherry-picked rather than merged via
`ngit pr merge` because that proposal is not surfaced by `ngit pr list` --
it is absent from every status and `ngit pr view` reports "proposal not
found", even though the event is well formed on relay.ngit.dev with the
correct a-tag, p-tag and r-tag.

One fix folded in on top of the original commit: the new test imported
`RepliesAndReactionsKinds2` from
`com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers`,
which no longer exists. `FilterRepliesAndReactionsToNotes.kt` moved to
`commons` (`com.vitorpamplona.amethyst.commons.relayClient.event.watchers`)
in the 537 commits since this branch's merge-base. Git followed the rename
for the production edit but not for the new test file's hardcoded import, so
the branch did not compile as submitted.

Verified on current main after that fix:
- Nip34NotificationCoverageTest: 4 tests, 0 failures.
- Full *notifications* unit-test package: 5 classes, 24 tests, 0 failures.

Premise confirmed against main before applying: NotificationsPerKeyKinds2
carried 1617/1618/1619/1621/1622 but no 1630-1633, and neither
NotificationFeedFilter.NOTIFICATION_KINDS nor
NotificationDispatcher.NOTIFICATION_KINDS listed the status kinds -- so a
merge/close on a thread you participate in was fetched nowhere and rendered
nowhere.

Open question left for follow-up, not a blocker: nothing checks that a status
event's author is a maintainer in the repo's kind-30617 announcement, so any
pubkey can p-tag you with a 1631 and produce a "merged a pull request"
notification. The notification strings name the actor, so the claim is at
least attributable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 15:53:12 -04:00
6cf09f0d75 feat(namecoin): add electrumx2.testls.space LE-cert server to default list
Add electrumx2.testls.space:50012 to DEFAULT_ELECTRUMX_SERVERS and
TOR_ELECTRUMX_SERVERS as a redundancy endpoint for the testls.space
operator. It runs on the same box as relay.testls.bit (23.158.233.10)
but terminates TLS at nginx with a publicly-trusted Let's Encrypt cert
(CN=electrumx2.testls.space, issuer LE YE1) instead of the self-signed
relay.testls.bit cert on the standard ports.

usePinnedTrustStore is left at the default (false) since the system
trust store is sufficient, same as electrum.nmc.ethicnology.com.

The same nginx vhost also exposes WSS on port 50014, making it the
second browser-viable public Namecoin ElectrumX endpoint (alongside
electrum.nmc.ethicnology.com) for pure-browser Nostr clients that
cannot use self-signed certs.

(cherry picked from commit 645382a95b9a314eb6a4e1221ba97dfc1c13f1ae)

Applied from nostr proposal
c0eb8d1a09651c377827f4aa97c1ed7a2f93fafceb823233c5650506b661b8ba
(branch feat/electrumx2-le-server). Cherry-picked rather than merged via
`ngit pr merge` because that proposal is not surfaced by `ngit pr list` --
it is absent from all statuses and `ngit pr view` reports "proposal not
found", though the event is well formed on relay.ngit.dev with the correct
a-tag and r-tag. It appears to collide with a stale earlier proposal for the
same branch name.

Endpoint verified before applying:
- electrumx2.testls.space resolves to 23.158.233.10, the same host as the
  existing relay.testls.bit / 23.158.233.10 entries, as the commit claims.
- TLS on :50012 presents CN=electrumx2.testls.space issued by Let's Encrypt
  (C=US, O=Let's Encrypt, CN=YE1), so usePinnedTrustStore = false is correct.
- server.version reports ElectrumX 1.16.0 and server.features reports
  genesis_hash 000000000062b72c5e2ceb45fbc8587e807c155b0da735e6483dfba2f0a9c770,
  i.e. it indexes Namecoin rather than Bitcoin.

Note this is the third default entry pointing at host 23.158.233.10, so it
adds certificate-path redundancy (works where a self-signed cert is stripped)
rather than host redundancy. Low risk: nameShowWithFallback tries servers
sequentially and returns on first success, and this entry is appended last in
both lists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 15:38:19 -04:00
Vitor PamplonaandClaude Opus 5 9ea7c36cf9 Merge PR: fix: narrow FileProvider external root to the app-specific dir
Merges nostr proposal 91f762d3 into main:
- fix: narrow FileProvider external root to the app-specific dir

Replaces `<external-path path=".">` with `<external-files-path>`, so the
FileProvider no longer roots at /storage/emulated/0, and adds
FileProviderPathsTest to pin both directions on device.

Supersedes proposal a5d172d8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 11:38:37 -04:00
Vitor PamplonaandClaude Opus 5 69532badee fix: narrow FileProvider external root to the app-specific dir
`<external-path path=".">` rooted the provider at
Environment.getExternalStorageDirectory() (/storage/emulated/0), which is far
broader than anything Amethyst hands out. The only external-storage consumer is
TakePicture's getPhotoUri/getVideoUri, both of which write into
getExternalFilesDir(...) — so `<external-files-path>` describes the actual
surface exactly.

Not a live vulnerability: the provider is exported="false", every
getUriForFile() call site builds its File from app-controlled constants under
cacheDir or getExternalFilesDir, and the one name derived from event content
(shareIcs) is passed through IcsExport.safeFilename, which strips '/' — so no
attacker-influenced path can reach the provider today. This is defence in
depth plus an accurate declaration.

Prefer external-files-path over hardcoding the path under
Android/data/<applicationId>/: the latter is wrong for the .debug and
.benchmark applicationIdSuffixes, while external-files-path resolves per
variant. The `external_files` name is kept so the generated content:// URI
shape does not change.

FileProviderPathsTest pins both halves on device: the capture URIs still
resolve under /external_files/, cacheDir still resolves under /cache/, and a
file at the external-storage root no longer maps. Against the old config that
last case fails with
content://com.vitorpamplona.amethyst.debug.provider/external_files/Download/not-ours.pdf.

Supersedes nostr proposal a5d172d8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYVqQqpUY1xqYG5LUY6jQr
2026-08-31 11:36:31 -04:00
Vitor PamplonaandGitHub a1f9cc96f1 Merge pull request #4023 from davotoula/fix/save-video-to-movies-dir
Save videos to Movies/ instead of Pictures/ (fixes #4009)
2026-08-31 10:13:16 -04:00
Vitor PamplonaandGitHub a0a55af174 Merge pull request #4022 from davotoula/fix/nwc-omit-null-request-params
Fix/nwc omit null request params
2026-08-31 08:50:46 -04:00
davotoula defbdfbb28 Code reviews: apply review cleanups to the media-save fix and its tests
- Extract the duplicated drive-the-save harness (temp file, runBlocking save,
  success/error assertions) from both instrumented e2e tests into
  MediaSaverTestSupport, following the AvifInstrumentedTestSupport precedent,
  with UUID-based filenames per the existing convention.
- MediaSaverToDiskMediaStoreTest: record inserted rows as item Uris
  (ContentUris.withAppendedId) instead of Pair + hand-built _ID selection; trim
  the KDoc paragraph that re-quoted the MediaProvider rejection verbatim - the
  canonical copy lives on MediaStoreTarget.
- MediaSaverToDiskLegacyStorageTest: derive watchedDirs from
  MediaStoreTarget.entries instead of a third hand-maintained directory list;
  move the exact run recipe (assemble, install -g, am instrument) into the class
  KDoc and point the skip message at it; unfold the write-probe .also puzzle.
- MediaSaverToDisk: drop the outer withContext in saveDownloadingIfNeeded (both
  delegates now dispatch themselves, leaving the decision in the leaf writers);
  scope `val extension` to the pre-Q branch that uses it; drop the rot-prone
  composable file name from save()'s KDoc.

Considered and left alone: isSaveableMimeType deriving from MediaStoreTarget.of
(kept - one definition of the accepted set beats re-spelling the prefix triple);
the nested Dispatchers.IO in save()/downloadAndSave (load-bearing for direct
call sites, fast-path no-op when nested); the redundant launch(Dispatchers.IO)
at two call sites outside this branch.
2026-08-31 14:10:07 +02:00
davotoula af49bcc396 On device testing
test: only ever delete MediaStore rows the test itself inserted
test: cover the pre-Q save path on API 26
test: cover #4009 end-to-end against a real MediaStore on API 29
2026-08-31 12:38:49 +02:00
davotoula cbd0a4a174 fix: save videos to Movies/ instead of Pictures/ (#4009)
MediaProvider validates the primary directory of RELATIVE_PATH against the
collection being inserted into. saveContentQ paired
MediaStore.Video.Media.EXTERNAL_CONTENT_URI with Environment.DIRECTORY_PICTURES,
so every video save built content://media/external/video/media +
"Pictures/Amethyst" and Android 10 rejected it with

    IllegalArgumentException: Primary directory Pictures not allowed for
    content://media/external/video/media; allowed directories are [DCIM, Movies]

Newer Android releases don't reject the mismatch, which is why the crash only
reproduces on older devices - but the file still landed under Pictures/ rather
than Movies/ everywhere, confirmed on a current device.

Collection and directory now travel together in a MediaStoreTarget enum, so the
two cannot drift apart again, and the catch-all falls through to Downloads
(which accepts any file) instead of the Video collection. The MIME type is
resolved above the SDK_INT fork and both writers route through the enum: the API
level now decides how a file is written, never which directory it belongs in, so
the pre-Q path stops filing videos and PDFs under Pictures/ too.

The directory names are spelled out as literals because Environment's DIRECTORY_*
are plain static fields that the unit-test android.jar nulls out. The JVM test
covers the routing; MediaStoreTargetInstrumentedTest pins the literals back to
the platform constants on-device.

Stop leaking a file descriptor and blocking the UI on local saves
2026-08-31 12:38:49 +02:00
davotoula 34c60fada1 Code review: omit nulls one level down too + make the null-omission guard cover every method
fix(nwc): omit nulls one level down too, inside pay_keysend's TLV records
refactor(nwc): make the null-omission guard cover every method, on every target
2026-08-31 11:39:07 +02:00
davotoula 0030432e20 fix(nwc): omit absent request params instead of sending them as null
Viewing transactions on one NWC wallet failed with

    Invalid list_transactions params: from must be an integer

because Amethyst sent every optional parameter explicitly:

    {"method":"list_transactions","params":{"from":null,"until":null,"limit":20,
     "offset":0,"unpaid":false,"unpaid_outgoing":null,"unpaid_incoming":null,"type":null}}

NIP-47 marks those optional, and a wallet is free to type `from` as an integer
and refuse a null. Nothing in the request was wrong except the nulls.

The two serialization backends had disagreed since they were written.
Nip47RequestKSerializer builds every params object with
`params.x?.let { put("x", it) }`, so kotlinx has always omitted nulls; Jackson
serializes the params classes reflectively and wrote them. The same request was
two different documents depending on the platform, and only JVM/Android was
broken — which is why it survived: the tests that cover this shape run against
the backend that was already correct.

A Jackson mixin now applies NON_NULL to all twelve NIP-47 params classes. A
mixin rather than an annotation because the classes live in commonMain and
Jackson annotations are JVM-only.

The regression test asserts the property rather than the symptom: no request
type may emit a null param, and both backends must produce the same document.
The second is the one that would have caught this.

Not new to any recent change — the reflective serialization predates it. What
changed is that 24a8540ad9 surfaces a NIP-47 refusal instead of rendering it as
an empty list, so users now see the error rather than an empty transaction
screen. Older builds sent the same request and were refused just as silently.
2026-08-31 10:52:15 +02:00
Claude beb4b9f456 fix: repair test sources for the commons moves
Adds the imports the migration's same-package rewrites missed in test
source sets (moved topNavFeeds filters, okhttp classes, LargeSoftCache
address extensions, latestBuzzEdit), inlines the two multi-line
old-package FQNs in NewMessageTaggerKeyParseTest, and widens
NappletRelayCleartext.forDelivery with the rest of the object so its
test keeps calling it cross-module.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-31 07:24:56 +00:00
Vitor PamplonaandGitHub 36819b1011 Merge pull request #4021 from davotoula/feat/nwc-outgoing-attribution
Nwc outgoing attribution (NWC-06)
2026-08-30 17:26:12 -04:00
davotoula 985b4c2ea3 fix(nwc): only claim a binding for a zap request the provider accepted
Kotlin review found the feature's own soundness property could be false on
the wire.

lnAddressInvoice drops the zap request for a provider that does not advertise
`allowsNostr` — `nostrRequest = if (allowsNostr) nostrRequest else null` — but
assembleInvoice set Payable.zapRequest unconditionally from the request it had
built. So paying a lightning address whose provider ignores `nostr=` still
attached metadata.nostr to the payment, for an invoice whose description_hash
commits to nothing about it. Every claim the feature makes — the KDoc, the
byte-identity test, the wallet-side binding check we asked BrollyZapper to
keep strict — rests on those bytes being what the callback hashed. Here they
were not.

A conformant wallet refuses such a row, so no false attribution was displayed;
what was wrong is that we asserted a binding we could not support, and spent
the 4096-char budget doing it. lnAddressInvoice now reports the request it
actually sent, and only that is carried forward.

The size estimate also counted raw string length for `comment`, which is free
text a user typed. JSON escaping expands it — a quote or backslash to two
characters, a control character to six — so an escaping-heavy comment could
breach the ceiling unnoticed, and NWC-06 makes the wallet drop the WHOLE
object then, taking recipient_data with it. escapedLength() counts what
actually reaches the wire; KEY_OVERHEAD drops to the fixed punctuation cost
now that escaping is no longer hiding inside it.

Both paths were untested and now have regression tests.

Not changed: dropMetadataIfUnsupported still mutates the caller's Request. The
review confirmed every current call site builds a fresh request inline, and
the contract is documented on both public send functions.

Verified: quartz + amethyst suites, commons/desktopApp/cli/geode compile,
spotless clean.
2026-08-30 22:31:29 +02:00
davotoulaandClaude Opus 5 c25517c2d6 refactor(nwc): share anyToJsonElement, and drop a refresh that never refreshed
Cleanup pass over the squashed branch. Net -109 lines.

anyToJsonElement was a second copy of a private helper that already existed in
ClinkKSerializers, serializing the same Map<String, Any?> shape. Worse than
tidiness: RawJson is declared in nip01Core and registered globally for Jackson,
but the kotlinx half lived inside one NIP's package, so a RawJson routed
through Clink's copy would have been emitted as a quoted, escaped JSON string —
exactly the corruption RawJson exists to prevent. One declaration now, beside
the other kotlinx serializers at nip01Core level, and Clink picks up the RawJson
and Array branches its copy lacked.

The getFresh call in fetchTransactions is deleted. Its own KDoc claimed it
re-read capabilities "bypassing the info cache's TTL", and getFresh does no such
thing: it returns a fresh entry as-is, so the case it was written for — a wallet
that added `06` twenty minutes ago — was the one case it could not cover. It
also refreshed the SELECTED wallet while zaps read the DEFAULT one. The send
path's currentOrFetch already fetches on cold and background-refreshes on
stale, so nothing is lost. A real force-refresh would mean a relay request per
refresh press, which is a policy decision rather than a cleanup.

Three KDoc blocks documented behaviour their function no longer had after the
walletInfo refactor: prefersNip44 kept four paragraphs about waiting, and
supportsMetadata opened "WAITS ON A COLD CACHE" while doing neither. The
rationale now lives once, on the one function that waits, and supportsMetadata
is inlined into its only caller. Also deleted a comment claiming a metadata-free
method "returns before the info cache is consulted" — both call sites fetch
first, so it never did.

Smaller: RawJson becomes a data class; the unused metadata parameter comes off
PayInvoiceMethod.create(bolt11, amount); TransactionRowLabels drops a derivable
flag and a twice-computed fallback; KEY_OVERHEAD's comment now says what its
slack is for; the three blank-description tests become one loop; a test that
asserted the Kotlin stdlib now calls displayDescription(); and two test comments
had lost their backticked literal to a heredoc.

Verified: quartz + amethyst suites, commons/desktopApp/cli/geode compile,
spotless clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019upqJTtAMNNfxKCDV1xDn3
2026-08-30 22:31:29 +02:00
davotoula 790458f9a0 Field-testing fixes: timings + read request's own bytes
perf(nwc): read the wallet's info event once per send, not twice
fix(nwc): wait for the wallet's info event before deciding it lacks NWC-06
fix(nwc): send the zap request's own bytes, not a rebuild of it
2026-08-30 22:31:29 +02:00
davotoula 6f71eb0c4c Code review: seal Request + gate metadata at the choke point
refactor(nwc): seal Request so a metadata-bearing method cannot be missed
refactor(nwc): gate metadata at the choke point, not the call site
2026-08-30 22:31:29 +02:00
davotoula 04d506e81e feat(nwc): name the payee on outgoing wallet transactions
Outgoing rows in the NWC wallet history showed an arrow, an amount and a
date, with an invisible blank line where the label should be. Two causes.

The row rendered an empty string. `tx.description ?: fallback` only catches
null, and wallets send `"description": ""` for a payment with no memo, so the
row got a `Text("")` — a line with the height of a real one and nothing in it.
NwcPaymentNotifier already guarded this; the screen did not. Resolution moves
out of the composable into a pure `TransactionRowLabels`, so the behaviour is
a unit test rather than a Compose one.

And we never told the wallet who we were paying. `PayInvoiceParams.metadata`
existed and nothing set it, while a NIP-57 invoice commits to a
description_hash rather than a memo — so the wallet had nothing to lift
either. ZapPaymentHandler held the signed zap request, the lightning address
and the message at the moment it fetched the invoice, and dropped all three.

Amethyst now sends NWC-06 metadata: the zap request, the recipient's address
and the comment. `nostr` is built from the event's TYPED fields, never by
re-parsing its JSON — a verifying wallet recomputes the event id from those
values, and toAnyValue() resolves numbers with toDoubleOrNull() BEFORE
toLongOrNull(), so a round-trip would emit "kind": 9734.0 on the kotlinx path
while the JVM path stayed correct. Over NWC-06's 4096-character ceiling the
zap request is dropped and the much smaller recipient_data/comment pair
survives, so the row still names the payee instead of arriving blank.

SENT ONLY TO A WALLET THAT ADVERTISES `06` in the info event's extensions tag,
which NwcInfoEvent now parses. Users pair with wallets we do not control, and
one that types metadata narrowly would accept today's "metadata": null but
refuse an object — costing a payment for a cosmetic field. The gate sits in
NwcSignerState where the request is built rather than at the call site, so no
caller can route round it, and "not yet fetched" reads as no. Every wallet
that has not advertised receives a request byte-identical to today's; there is
a test for exactly that.

The blank-string guard is what fixes existing history, for every wallet, with
no wallet change at all.
2026-08-30 22:31:28 +02:00
Claude 10d20a2d08 docs: record executed state of migration waves 0-1 in the sweep plan
Adds an execution-status header: what landed on this branch, the
corrections found while executing (import-graph analysis undercounts
same-package coupling; ui/theme+layouts are not mechanically movable),
and the refined LocalCache move recipe with its one open IAccount
design question.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 19:57:29 +00:00
Vitor PamplonaandGitHub b10be95a6e Merge pull request #4020 from vitorpamplona/claude/patch-review-apply-wen13f
Give Trusted Lists and contact cards their own extractor branches
2026-08-30 13:43:13 -04:00
Vitor PamplonaandClaude 514f4b26f0 Give Trusted Lists and contact cards their own extractor branches
kinds 30392-30395 and 30382 had no branch in SearchFieldExtractor, so
both fell through to the generic `is SearchableEvent ->` case, which puts
the whole of indexableContent() in the TERTIARY (body) tier.

For a Trusted List that whole content IS its title, and a title is not
body text. On a tiered backend the difference is large and measurable: on
search-staging, a 30392 titled exactly "Verified Human" matched the query
`Verified Human` on the same rung as a profile whose bio happens to say
"humans are amazing" - 550 against 130 000 on that schema's ladder, a
236x discount - and reached the title only through trigram substring
rather than the prefix/typo columns a title normally gets.

A contact card decomposes the same way every other kind in this file
does: petname() is a trust provider's NAME for a person - the direct
analogue of kind 0's `name`, and what a people search is looking for -
and summary() is the description beside it.

The card's topics change ROLE, and that is the one behaviour change here.
topics() is TopicTag, which is the `t` tag under another name - same
predicate, same array - so the tiers() funnel already carries every topic
as a hashtag. The old fallback therefore indexed them TWICE, once inside
the concatenated body and once in the hashtag role; they are now carried
once, in the role, and whether that is tokenized or kept as keywords is
the backend's call per IndexableFields. Since build() puts petname and
summary in the NIP-44 content, topics are the only public text on a card
this library authors, so that shape is pinned by its own test - including
that a hashtags-only extraction does not normalize to None.

Nothing else changes what is indexed, only which tier each accessor lands
in. indexableContent() is untouched, so the SQLite and filesystem stores
(the only in-tree consumers, both of which index the flat form) are
bit-identical. SearchFieldExtractor has no in-tree consumer at all - it
is the protocol surface external tiered backends read - so the app, both
flavours, and every feed are unaffected by construction.

The encrypted half of a contact card stays out of the index as before:
petName()/summary() read the public tag array only.
2026-08-30 17:26:07 +00:00
David KasparandGitHub 91d4d66461 Merge pull request #4019 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-30 17:17:58 +02:00
vitorpamplonaandgithub-actions[bot] 8a43d707e6 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-30 14:44:32 +00:00
Vitor PamplonaandGitHub de3ce56bcc Merge pull request #4018 from vitorpamplona/claude/org-json-kotlin-serialization-bxhfcr
Migrate JSON parsing from org.json to kotlinx.serialization
2026-08-30 10:41:53 -04:00
Claude 2aa4a43337 refactor: consolidate JsonObject tree accessors and pin ime envelope parsing
Follow-ups from the branch audit:

- Adds commons/util/JsonTreeUtils.kt: one shared set of total, null-safe
  JsonObject accessors (parseJsonObjectOrNull, stringOrNull, intOrNull,
  longOrNull, doubleOrNull, booleanOrNull, objectOrNull, withString) for
  ad-hoc JSON trees. Replaces the two near-identical private sets this
  branch had introduced (nappletHost's JsonEnvelope.kt, now deleted, and
  EmbeddedImeBridge's file-local helpers) and FeedDefinitionSerializer's
  identical bool/int/long copies. FeedDefinitionSerializer keeps its
  deliberately stricter isString-guarded string(), now documented, and
  NappletProtocolJson keeps its throwing accessors (rejecting malformed
  input at the trust boundary is its job). Quartz's copies stay: quartz
  cannot depend on commons.
- Adds EmbeddedImeBridgeTest (16 JVM tests) pinning parseImeEvent /
  parseSelectionGeometry: per-event parsing, defaulting of absent fields,
  the total-accessor behavior for mistyped fields, and the ime.resync
  envelope. This parser became JVM-testable when it moved off Android's
  org.json; the browser suite in tools/ime-test still owns the page side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW
2026-08-30 14:29:28 +00:00
Claude ffbf17dabe refactor: reunite topNavFeeds in commons via ICacheProvider signature fix
The two-line poison edge the migration sweep identified:
IFeedTopNavFilter.toPerRelayFlow/startValue hard-coded the concrete
LocalCache, which kept all 23 implementors app-side even though they
only call relayHints and getOrCreateAddressableNote. The signatures now
take the commons ICacheProvider port (checkGetOrCreateAddressableNote
gains a default implementation there, mirroring LocalCache's).

With that edge cut, this moves to commons/model/topNavFeeds:
- IFeedTopNavFilter, IFeedFlowsType, OutboxRelayLoader/State,
  CommunityRelayLoader, UsingRelayUnwrapper, FeedDecryptionCaches
- the TopNavFilter/FeedFlow pairs for allFollows, allUserFollows,
  global, hashtag, mine, relay, aroundMe (geohash), noteBased
  (community/author/muted), favoriteAlgoFeeds filters, unknown
- TopFilter itself, extracted out of AccountSettings.kt where it never
  belonged (persisted by its code string, so the move is wire-safe)
- the nip51 geohash list card + decryption cache they depend on

Still app-side, each named by its real blocker: FeedTopNavFilterState
(Account/AccountSettings wiring), AllFollows/AllUserFollows feed flows
(serverList MergedFollowListsState), Kind3UserFollowsFeedFlow
(nip02FollowLists), AroundMeFeedFlow (LocationState), favoriteAlgoFeeds
flows (algoFeeds orchestrator).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 07:07:03 +00:00
Claude 8424838450 refactor: move 37 clean model/service singles from amethyst to commons
Batch 5 of the commons migration sweep. Moves, with no behavior change:

- model root: Dao, HomeFeedType, VideoPostKind, ConcordInviteResult,
  NoteEditOverlays, PrivateChatroomReadState, RelayGroupContentRouting,
  MutedPublicChats -> commons/model; LargeSoftCacheAddressExt ->
  commons/model/cache (jvmAndroid, next to LargeSoftCache)
- model/nip03Timestamp (OTS settings, explorer endpoints, verification,
  Tor-aware resolver builder) -> commons/model/nip03Timestamp
- model/nip51Lists/relayLists RelayListCard + GenericRelayListCache and
  model/edits PrivateStorageRelayListDecryptionCache -> commons twins
- ChessAction -> commons/nip64Chess; InMemoryMlsGroupStateStore ->
  commons/marmot; NwcInfoCache -> commons/model/nip47WalletConnect
- AdditiveComplexFeedFilter -> commons/ui/feeds
- napplet clean half (LaunchRegistry, NotificationStore, IdentityWatch,
  RelayCleartext) -> commons/napplet; NappletRelayCleartext widened from
  internal so the Android broker can keep calling it cross-module
- NamecoinNameService -> commons/service/namecoin (desktop already
  reimplements it verbatim); image fetchers (Base64/BlurHash/ThumbHash
  to androidMain, BlossomReadAuth/DeferredDelete to jvmAndroid) ->
  commons/service/image; PodcastRemoteContent -> commons/podcasts;
  BuzzInviteMinter -> commons/actions; WritingAssistant ->
  commons/service/ai; DevReportContact -> commons/service/crashreports;
  ConnectivityStatus -> commons/service/connectivity;
  ScheduledPostWorkGate -> commons/scheduledposts; MeltResult ->
  commons/cashu/melt

Files whose hidden same-package coupling to Account/LocalCache the
sweep's import analysis missed (AccountMarmotActions, EventBroadcaster,
ParticipantListBuilder, UnexpectedCrashSaver, Blossom/ProfilePicture
fetchers, BlossomServerResolver) stay in the app until those hubs
migrate.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 06:55:34 +00:00
Claude 4a1d2211af refactor: move OkHttp client stack to commons/service/http (jvmAndroid)
Batch 3 of the commons migration sweep: IHttpClientManager, the dual
direct/Tor client managers, both OkHttp factories, all interceptors
(Blossom read-auth, encrypted blob, local-cache redirect, onion
location/rewrite, content type, logging), both event listeners,
OnionLocationCache, EncryptionKeyCache, OkHttpDebugLogging, plus the
role-based client builders from model/privacyOptions.

Two small seams so the shared code stays Android-free:
- MediaCallEventListener.verboseLogging replaces the app isDebug read;
  the app sets it at startup.
- HttpClientEnvironment.isEmulator replaces the Build-fingerprint call
  in the factories; the app sets it at startup, desktop stays false.
- EncryptionKeyCache now uses androidx.collection.LruCache (KMP) with an
  explicit null-url guard where android.util.LruCache would have thrown.

Desktop's hand-rolled DesktopHttpClient can now adopt these factories
and gain the interceptors it currently lacks.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 06:21:12 +00:00
Claude 4fa2bc6fa0 refactor: move 38 relayClient files from amethyst to commons
Batch 1 of the commons migration sweep (see
commons/plans/2026-08-30-commons-migration-sweep.md):

- eoseManagers (PerUser, PerUserAndFollowList, PerUniqueId,
  AccountScopedSingleSubNoEoseCache) -> commons/relayClient/eoseManagers
- AccountScopedQuery -> commons/relayClient, generalized from the
  concrete Account to commons IAccount (covariant overrides keep all
  56 implementors source-compatible)
- EOSEByKey/EOSEAccountKey (service/relays/EOSE.kt) -> commons/relays
- account/channel/search/nwc pure filter functions ->
  commons/relayClient/{account,channel,search,nip47WalletConnect}
- relay AUTH permission model (9 files) -> commons/relayClient/auth
- notify request model -> commons/relayClient/notify
- chatDelivery, speedLogger, diagnostics -> commons/relayClient (jvmAndroid)
- TorCircuitHealthTracker -> commons/relays/health

Also inserts the same-package imports the earlier shim-removal commit
missed (its insertion regex never matched below license headers).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 06:14:57 +00:00
Claude da28297761 refactor: delete 12 typealias shim files, point call sites at commons
Removes the backwards-compat re-export shims (Note, User, HashtagIcon,
TorRelaySettings/Evaluation, FeedFilters, ChangesFlowFilter, FeedStates,
BundledUpdates, BookmarkListState, ChatroomFeedFilter, UserFinderShims)
and the typealias lines inside the five mixed shim files, rewriting all
1,165 imports to the canonical commons FQNs. Renames the two files whose
remaining single class no longer matched the filename.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 05:56:17 +00:00
Claude 2415565ebf refactor: replace org.json with kotlinx.serialization in source files
Sweeps the last org.json usages out of the Kotlin sources and moves them
to kotlinx.serialization's JSON tree API (already the project standard):

- nappletHost: bridge/broker envelope handling in NappletHostActivity,
  NappletHostService, NappletBrowserActivity, NappletBrowserService and
  NappletFaviconSniffer now parses with Json.parseToJsonElement via new
  total helpers in JsonEnvelope.kt (absent/mistyped fields degrade to
  empty/false instead of throwing, matching the old opt* semantics).
  Adds the kotlinx-serialization-json runtime to the module (tree API
  only, so no serialization plugin needed).
- amethyst embed IME relay: EmbeddedImeBridge parses ime.* envelopes
  with JsonObject accessors; RemoteImeView and EmbeddedTabLayer build
  their outgoing envelopes with buildJsonObject.
- tools/ime-test: drops the now-stale "org.json is stubbed in JVM unit
  tests" rationale from the README and shim-events.mjs header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVcZwp65oybotmq5o66foW
2026-08-30 05:52:29 +00:00
Claude b658d3047b docs: full sweep of amethyst-module sources that can and should move to :commons
Audit of all 2,347 Kotlin files in amethyst/src/main via import
classification + transitive-closure analysis plus six per-area deep
audits. Key findings: 509 files are movable today with no refactoring;
the dominant blockers are Account/LocalCache/AccountViewModel and the
string-resource bridge, not Android APIs; LocalCache itself has only
three trivial Android-dirty deps and moving it deletes the 1,173-line
DesktopLocalCache; a two-line IFeedTopNavFilter signature fix unlocks
the app half of topNavFeeds. Includes MOVE-NOW batches, blocker-tagged
MOVE-AFTER tables, a desktop-duplication catalog, and a six-wave
migration sequence.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
2026-08-30 05:35:52 +00:00
David KasparandGitHub a370b1d8c5 Merge pull request #4016 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-30 06:19:22 +02:00
vitorpamplonaandgithub-actions[bot] e38981fd1d chore: sync Crowdin translations and seed translator npub placeholders 2026-08-30 00:13:08 +00:00
Vitor PamplonaandGitHub 7ff8e3b5ac Merge pull request #4017 from vitorpamplona/claude/ci-bugs-4npv9z
fix(i18n): unbreak main's lint; publish desktop test reports on failure
2026-08-29 20:10:11 -04:00
Claude e4d288a9c0 fix(i18n): drop three orphaned AI-writing keys from the translations
6f97faf1 removed ai_writing_help, ai_tone_more_direct and ai_tone_punchy
from the default locale when it reworked the restored AI writing helper,
but left them in all 55 translated strings.xml files. Android Lint's
ExtraTranslation reports one error per (key, locale), so
:amethyst:lintFdroidBenchmark fails with exactly 3 x 55 = 165 errors —
the count CI reports — and main has been red since #4015 merged.

Mirrors a5e2aae9, the original removal of these same keys, which touched
56 files: the default locale and all 55 translations. Nothing in Kotlin
references any of the three, so there is nothing to restore instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017shnUK5t71BkBXgTAcACbA
2026-08-29 22:42:28 +00:00
Claude f4c452a761 ci(desktop): publish test reports when the desktop job fails
build-desktop runs five test suites (:quartz:jvmTest, :commons:jvmTest,
:nestsClient:jvmTest, :cli:test, :desktopApp:test) across three OSes and
was the only test-running job with no failure reporting — test-geode,
test-quartz-ios and test-and-build-android all upload on failure.

When a test failed there, the console printed the test name and the
exception class and nothing else, and the reports died with the runner.
Run 10540's macOS leg is the case in point:

  NostrClientNegentropySyncTest[jvm] >
    multiRoundReconcileStreamsEveryEventThrough[jvm] FAILED
      com.vitorpamplona.quartz...NegentropySyncException at
      NostrClientNegentropySyncTest.kt:146

Line 146 is the runBlocking frame, so all that survives is "something
threw". NegentropySyncException carries a `detail` naming which of the
four branches fired — connect timeout, idle silence mid-reconcile,
NEG-ERR, or disconnect — and that string is what says whether the run
hit a real protocol fault or lost a race against a loaded runner. It
was unrecoverable.

Two steps, mirroring the Android job: the same pinned
mikepenz/action-junit-report annotates the failing assertion inline
(annotate_only keeps this working under `permissions: contents: read`
and on fork PRs), and the HTML reports upload on failure for the full
stack traces the annotations truncate. Artifacts are named per-OS
because the three matrix legs share a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017shnUK5t71BkBXgTAcACbA
2026-08-29 21:14:31 +00:00
Vitor PamplonaandGitHub 12a1b571ba Merge pull request #4015 from vitorpamplona/claude/revert-text-generation-features-n4vz2r
Revert "feat: remove the AI writing helper from the post composer"
2026-08-29 16:27:32 -04:00
Claude 6f97faf167 fix(composer): address the audit of the restored AI writing helper
Bugs

- Emptying the composer left the proposals on screen. precomputeAiResults
  early-returned on short text without clearing state, and cancel() — which
  runs after a post is sent — resets every other suggestion source but not
  this one. The freshly emptied composer kept showing proposals for the note
  just published, and "Use This" pasted it back in.
- The client caches were plain HashMaps written by the nine tone coroutines
  at once. Two tones mapped to the same rewriter, so every batch raced on the
  same key and orphaned a Rewriter nothing would close. They are now
  ConcurrentHashMaps built through computeIfAbsent, and close() drains them.
- The assistant held the Activity context inside a ViewModel that outlives it.
  It now keeps the application context, and the screen passes that too, as
  MLKitImageLabelService already does.
- DOWNLOADABLE was folded into "unavailable" and nothing ever called
  downloadFeature(), so on a device whose model had not been fetched the
  feature could never start. Status is now re-read (throttled) while it is not
  ready, and the model is requested once when the user has the setting on.
- lastComputedText was stamped before inference, so a cancelled run marked
  that text as done and returning to it showed nothing. It is stamped after
  the run completes.
- The Settings toggle was read as a plain StateFlow value, so turning it off
  did not hide the panel. The screen collects it now.
- precomputeAiResults/showAiPanel touch a lateinit accountViewModel; they now
  guard it like the functions above them.

Performance

- Language detection ran once per tone over identical text; it is memoized per
  text, so a batch detects once instead of nine times.
- MORE_DIRECT and PUNCHY issued the same request as PROFESSIONAL and SHORTER
  — ML Kit has no other output type for them — so two of nine inferences were
  wasted and two chip pairs rendered identical text. Both tones are dropped.
- Applying a proposal cleared lastComputedText, and the programmatic edit
  re-entered onMessageChanged, so accepting a suggestion immediately queued a
  fresh batch over it. It now remembers the applied text.
- Inference blocked on future.get(), which coroutine cancellation cannot stop,
  so abandoned batches kept running. Futures are awaited through
  suspendCancellableCoroutine and cancelled with the coroutine.
- Drafts under 20 characters no longer spend the model at all, and proposals
  identical to the draft are dropped instead of becoming a chip.

Cleanup

- Deletes MockWritingAssistant (shipped in main behind a dead flag, carrying
  its own "remove before shipping" note) and the unused AiWritingHelpButton.
- Hides the Settings tile on F-Droid, where the assistant is a no-op.
- Panel takes an ImmutableMap; the ML Kit language constants are mapped
  explicitly instead of relying on the two APIs numbering them alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfjXrjHdJ435kQwyp2HnL
2026-08-29 20:18:34 +00:00
Vitor PamplonaandGitHub 7665acb878 Merge pull request #4011 from davotoula/fix/rapid-settings-toggles-lost
fix: rapid settings toggles are silently discarded
2026-08-29 16:06:05 -04:00
davotoulaandClaude Opus 5 f150696a26 fix: wait out the second instead of stamping created_at in the future
Review feedback on the PR: created_at has second resolution, so nothing
on nostr can replace an address more than once per second — and a client
that keeps out-stamping the previous version drifts a second further into
the future per republish, which relays may reject.

That is right, and it points at a better guard than `+ 1`. One second is
the real floor on how often an address can be replaced, so a client that
replaces one faster should wait for the clock rather than invent a
timestamp. awaitCreatedAtToSupersede suspends until the second the
previous version claimed has passed, then stamps the real time — the new
version still wins, and no event is ever dated ahead of the clock.

The wait is bounded (MAX_SUPERSEDE_WAIT_SECONDS). A version further ahead
than that came from another device's skewed clock rather than this
client's own burst, and sleeping it out could take hours, so past the
bound out-stamping is still the only way to supersede.

Applied to the two paths that can accumulate drift across repeated edits
and were already suspending under a mutex: the NIP-78 settings blob and
the per-d-tag app recommendations. RoomParticipantActions keeps the
non-suspending form — it is reached from Compose click handlers, and its
stamp derives from the single event being acted on, so it sits at most one
second ahead and cannot drift.

Note the debounce added earlier already keeps the settings pickers from
publishing sub-second at all (measured on device: 23 rapid toggles → 3
events, each stamped at the true wall-clock second, the `+ 1` never
firing). This makes that a guarantee rather than a consequence of timing,
and extends it to the settings paths that are deliberately not debounced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Voa2KcknNffhvPqsRG92hx
2026-08-29 19:34:36 +02:00
Claude 3eff5f2d87 Revert "feat: remove the AI writing helper from the post composer"
This reverts commit a5e2aae960 (PR #3979),
bringing the on-device AI writing assistant back to the post composer:

- restores the WritingAssistant abstraction with its play (ML Kit GenAI /
  Gemini Nano) and fdroid (no-op) implementations, the mock, and the
  AiWritingHelp panel/button
- restores the AI state, the precompute job and the lifecycle wiring in
  ShortNotePostViewModel and ShortNotePostScreen
- restores the genai-proofreading, genai-prompt and genai-rewriting
  dependencies
- restores the "Propose text improvements" setting end to end: the Compose
  Settings tile, automaticallyProposeAiImprovements in UiSettings /
  UiSettingsFlow, the ui.propose_ai_improvements DataStore key, and the
  ai_writing_* / ai_tone_* strings in every locale

The one deviation from a straight revert: initWritingAssistant now takes a
`Context` by its simple name instead of the inline fully-qualified name the
original had, since the file already imports it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfjXrjHdJ435kQwyp2HnL
2026-08-29 17:19:24 +00:00
davotoula bfcff43868 fix: stop reporting coroutine cancellation as a signer failure
java.util.concurrent.CancellationException extends IllegalStateException,
so reportSignerErrors' trailing `catch (e: IllegalStateException)` arm was
swallowing every cancelled signer coroutine and showing it to the user as
a "signer not found" toast carrying the raw exception text —
"JobCancellationException: StandaloneCoroutine was cancelled;
job=StandaloneCoroutine{Cancelled}@3ecbac".

Latent since the arm was written: nothing cancelled those jobs, so it
never fired. The navigation pickers' debounce cancels a superseded
publish on every rapid edit, which made it fire on essentially every
fast toggle — confirmed on device, and confirmed absent again with this
change. Swallowing it also broke structured concurrency, since the
cancellation never propagated.

Caught by device testing of the debounce, not by review
2026-08-29 19:10:45 +02:00
davotoula ef22469410 Code review:
fix: publish picker edits on the account scope, not viewModelScope
refactor: one home for the replaceable-event republish timestamp
2026-08-29 19:10:45 +02:00
davotoula be7f099b7f Batch navigation picker edits
perf: publish navigation picker edits once the toggles stop
fix: stop rapid settings toggles from overwriting each other
2026-08-29 19:10:44 +02:00
Vitor PamplonaandGitHub 5ea4d6770e Merge pull request #4014 from davotoula/feat/persist-drawer-section-collapse
feat(drawer): remember which side-menu sections are collapsed
2026-08-29 12:57:44 -04:00
David KasparandGitHub 8d457de93e Merge pull request #4012 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-29 18:52:26 +02:00
davotoula 72324011a4 feat(drawer): remember which side-menu sections are collapsed
Closes #4010.

The drawer's section headings (You / Navigate / Feeds / Create / System)
fold away on tap, but CollapsibleSection kept that in a local
`remember { mutableStateOf(true) }`, so every heading sprang open again
on the next launch. The state is now hoisted out of the composable and
mirrored to the shared `ui.*` DataStore.

Device-global and never published: which headings you have folded is a
per-device view choice, unlike the hidden rows beside it in the same
drawer, which stay per-account and NIP-78-synced.

The preference stores the *collapsed* headings rather than the expanded
ones, for the same reason DrawerItemVisibility stores the hidden rows: a
heading nobody has ever collapsed simply isn't in the set, so a section
added in a later release opens expanded for everyone with no migration,
and the stored default is exactly the stock drawer. Names, not ordinals,
so reordering DrawerSectionId renames nothing by accident and a value
left by another build costs that one heading rather than the whole read.

DrawerSectionCollapsePreferences takes the DataStore rather than a
Context, which lets a plain unit test drive the full save/restore cycle
against a temp file: toggle, cancel the scope, then build a second
instance over the same file — what a relaunch does.
2026-08-29 18:22:28 +02:00
vitorpamplonaandgithub-actions[bot] 4d578006db chore: sync Crowdin translations and seed translator npub placeholders 2026-08-29 15:46:25 +00:00
Vitor PamplonaandGitHub 42652e6b36 Merge pull request #4013 from vitorpamplona/claude/parser-npub-detection-upwxad
Parse bracketed NIP-19 entities and fix token refresh race
2026-08-29 11:43:39 -04:00
Claude c6916d49d1 refactor: keep the bracket-peel helper off the public surface
`nip19OpeningPunctuationLength` has one caller, inside this file. Nothing
outside needs it, and the behaviour is covered through `parseText`.
2026-08-29 15:34:47 +00:00
Claude f4bf36030b perf: price the NIP-19 bracket peel, and make it free
The peel added a check to the per-word segmenting loop, which every word of
every rendered note walks. Measured on a 68 KB / 12,992-word plain-prose note
(no brackets, no entities — where the check can only cost and never pay),
median of 3 JVM runs:

  no check (main)          1,523,109 ns/op    —
  CharArray + `in`         1,601,363 ns/op    +5.1%
  `when` over char consts  1,536,011 ns/op    +0.8%

`CharArray.contains` is a linear scan, and a miss — the answer for nearly every
word — compares against all twelve before rejecting, at ~6 ns/word. A `when`
over char literals compiles to one lookupswitch and lands inside run-to-run
noise (its three runs straddle main's).

Adds RichTextParserBenchmark alongside the existing prodbench suite so the
per-word loop has a standing guard.
2026-08-29 15:27:25 +00:00
David KasparandGitHub 3b4ac12eb1 Merge pull request #4007 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-29 17:09:18 +02:00
Claude 0d9b2d8d29 fix: never hand a completed sign-job to a new Blossom token caller
`signOnce` retired the in-flight entry from `invokeOnCompletion`, which runs
when the job ends — after `fresh.complete()` has already resumed the awaiting
caller. In that window the map still holds a *completed* deferred, so the next
caller took the leader/follower branch and was handed the token that job had
already signed instead of signing a new one.

A caller whose token has just expired does exactly that: `header()` misses the
cache, reaches `signOnce`, and gets the expired token straight back.
`BlossomReadAuthTokenProviderTest.refreshesAfterExpiry` closes that window
immediately, so it hit the bug on every run and has been failing on main.

Remove the entry before completing it. `invokeOnCompletion` keeps its
now-idempotent removal as the cancellation safety net.

The test also asserted the re-signed header differed byte-for-byte from the
first. That cannot hold: the injected `clock` only drives the cache TTL, while
BlossomAuthorizationEvent takes `created_at` from `TimeUtils.now()`, so two
signings in the same second produce identical events. Count signatures instead,
which is what "must be re-signed" actually means.
2026-08-29 15:00:56 +00:00
Claude 2f220656f8 fix: detect NIP-19 entities wrapped in brackets or quotes
`wordIdentifier` classifies a word by its first character, so a bare
`npub1…`/`@npub1…` glued behind an opening bracket or quote — as in the
kind 1111 comment `(@npub1hgvtv4z…)` — never reached
`startsWithNIP19Scheme` and rendered as plain text.

The `nostr:`-prefixed spelling was unaffected: the URL detector finds the
URI inside the parentheses and `fixMissingSpaces` splits it into its own
word. Bare entities are not URIs, so nothing separated them.

Peel a leading run of opening brackets/quotes off into its own
`RegularTextSegment` when a NIP-19 scheme follows, which is what the
`nostr:` path already produces. Trailing punctuation needs no handling —
it is already captured as the entity's `additionalChars`.
2026-08-29 14:25:19 +00:00
vitorpamplonaandgithub-actions[bot] b71f26917a chore: sync Crowdin translations and seed translator npub placeholders 2026-08-29 01:56:07 +00:00
Vitor PamplonaandGitHub 16b4bc9197 Merge pull request #4008 from vitorpamplona/claude/profile-card-kind-0-design-9intah
Add kind-0 profile card rendering in feed
2026-08-28 21:53:02 -04:00
Claude c857bfe064 fix(profile-card): audit fixes — preview collision, self-follow chip, ripple, allocations
Correctness:
- ProfileCardPreview reused NoteHeaderMarkersPreview's pubkeys and metadata
  event ids ("a"*64 / "e1"*32). LocalCache is process-wide across previews and
  consuming a kind:0 no-ops on a duplicate id or a non-newer createdAt, so
  whichever preview rendered first won and this one showed {"name":"Vitor"} —
  the exact layout it exists to check. Now uses keys nothing else claims.
- "Follows you" now hides on your own card. A self-follow in your own kind:3
  is common, and the chip had no isLoggedUser guard (the follow button did).
- The website chip passed `clickable` as Surface's outer modifier, above
  Surface's own shape clip, so the ripple painted a square over the pill.
  Clip first.
- Drop `profile_card_followers`; reuse the already-translated
  `number_followers` ("%1$s Followers") instead of shipping a new key.

Allocation / recomposition:
- `pubkeyDisplayHex()` hex-decodes and bech32-encodes the key, and ran on
  every recomposition whenever metadata hadn't arrived. Remembered.
- The banner's gradient Brush was rebuilt on every recomposition; remembered
  on the background color. Static modifier chains hoisted to file scope, and
  the "@handle" / "(pronouns)" concatenations remembered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D17W8C3bYwwo2mWGm3QCnS
2026-08-29 01:43:36 +00:00
Claude f40b80eb9c feat(threadview): render kind 0 as a profile card in NoteMaster too
`FullBleedNoteCompose` (the thread/detail renderer behind `NoteMaster`)
keeps its own kind dispatch, separate from `RenderNoteRow`, so a kind:0
opened there still fell through to the raw-JSON text fallback. That path
is reachable: an inline `nostr:naddr…` pointing at a kind:0 navigates to
`Route.Note(aTag)`, and a NIP-22 comment rooted on a profile loads the
kind:0 as the thread's root.

Same `RenderProfileCard`, added at the head of the chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D17W8C3bYwwo2mWGm3QCnS
2026-08-29 01:00:17 +00:00
Vitor PamplonaandGitHub 029c40ebb4 Merge pull request #4006 from vitorpamplona/claude/amy-status-redesign-xjgo7u
feat(cli): redesign `amy status` around who is signed in and what they saved
2026-08-28 20:55:28 -04:00
Claude cd5441be44 feat(cli): surface relay config, follows, and account selection in amy status
Audit of everything amy keeps under `~/.amy/` against what `status` showed.
Six gaps, all verified against a real data dir rather than reasoned about.

**No account selected.** With a stale `current` pin, or several accounts
and no pin, every verb but `use`/`status` dies at account resolution
("pins 'ghost' but … doesn't exist", "multiple accounts … pick one") —
and status, the command you run to find out why, showed nothing wrong.
It now leads with the cause and the fix. New `current_exists` in JSON.

**Relay config was invisible.** kind 10002/10050 are the first thing
`amy relay add` writes and every account has them, yet status said
nothing about where the account talks. Now `3 relays (2 write, 2 read)`
and `DM inbox on 1 relay`. The read/write split follows NIP-65, where a
bare `r` counts for both, so the two can exceed the total.

**Follows.** kind 3 — the other headline number of a nostr account.

All three come from the existing single multi-kind query on the account's
pubkey, so they cost no extra store round trips.

**"a published key package" was wrong.** It is backed by
`marmot/keypackages.bundle`, which is local private MLS material — the
old field name `key_package_published` had the same lie in it. Now "a
Marmot key package".

**Marmot messages.** `FileMarmotMessageStore` writes `<group>.messages`
in the `groups/` dir status already lists, so group chat history was
sitting there uncounted: `2 Marmot groups, 5 messages`. Counted by
streaming newlines, not by reading files in.

**The operator key.** `~/.amy/operator/` is a machine-level GrapeRank
signing identity that `listAccounts` skips as a reserved name — the one
thing under `~/.amy/` nothing reported. Now a footer line when present,
via a new read-only `OperatorKeys.peek` that needs no SecretStore and
mints nothing (the instance API creates a master on first use).

Considered and left out: decrypted DM counts (needs the signer, would
break the no-prompt promise); git repos, mute lists, bookmarks, search
relays (long tail — each is its own verb, and adding them all rebuilds
the wall of zeros this redesign removed); store size (that's
`amy store stat`); nutzap info (always published with the wallet).

Gathering moves behind `StatusReport.overview()`, which now returns an
`Overview` carrying selection state and the operator alongside the
accounts, so the command stays parse-call-emit.

JSON: adds `current_exists`, `operator`, and `saved.{follows, relays,
relays_write, relays_read, dm_relays, marmot_messages}`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL
2026-08-28 23:26:29 +00:00
Vitor PamplonaandGitHub 16ef96279f Merge pull request #4005 from vitorpamplona/claude/trusted-list-ptag-score-qprze4
Trusted Lists: a 0–100 member score and the kind-10040 Treasure Map entry
2026-08-28 19:15:51 -04:00
Claude 81e39fe29b feat(feed): render kind 0 as a profile card
A kind:0 in the feed fell through to the generic text renderer, so it
showed up as the raw profile JSON — and tapping it opened the bare note
view. Both are now handled:

- New `RenderProfileCard` (amethyst/ui/note/types/ProfileCard.kt) renders
  the metadata event the way the profile screen it opens does: banner
  faded into the card background, a ringed avatar overhanging the banner,
  the follow/unfollow (or unhide) action beside it, display name with
  custom emoji + pronouns, the @handle, the NIP-05/status line, a
  4-line bio, and a chip row for follower count, "follows you", website,
  lightning address and the bot flag. Chips only appear when the profile
  actually carries the data, so a name-only kind:0 stays clean. Tapping
  anywhere on the card opens the profile.
- `routeForInner` now maps `MetadataEvent` to `Route.Profile`, so quotes
  and `nostr:naddr` deep links to a kind:0 land on the person instead of
  the generic note screen.

Everything reuses existing pieces (BannerImage, BaseUserPicture,
ObserveDisplayNip05Status, ShowFollowingOrUnfollowingButton) — the card
adds layout only, no new profile plumbing.

Adds a `ProfileCardPreview` over real notes seeded into LocalCache
(full profile / name-only / bot) so the layout can be reviewed in both
themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D17W8C3bYwwo2mWGm3QCnS
2026-08-28 23:07:39 +00:00
Claude 340fbee132 refactor(cli): give each amy status saved item its own line
A busy account lists six or seven footprint items. Joined with `·` and
wrapped at 78 columns they read as one run-on sentence that has to be
parsed; a column of short lines scans in one pass:

  saved: 128 events (newest 2h ago)
         3 contacts
         2 Marmot groups
         a published key package

Drops the wrap machinery (`appendWrapped`, the fixed WIDTH) for a plain
hanging indent. `saved: nothing yet` is unchanged, and so is `--json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL
2026-08-28 22:53:59 +00:00
Claude 1da2c32df3 feat(cli): redesign amy status around who is signed in and what they saved
The old output was a generic key/value dump: 12 fields per account, most
of them `no` or `0`, plus a `store` block that was wrong on the default
backend — it always walked the FS store path (`shared/events-store`),
so a SQLite install (the default since AMY_STORE landed) reported
`events: 0` no matter how full the database was.

`status` now answers two questions and drops everything else:

  alice (current)
    Alice Jones · alice@example.com
    npub1hje47kz5qeneyqrxc9nzgmz06ml6l9lguqv0qtsz4rkwqkmf636qvg4sz3
    local key, in the login keychain
    saved: 128 events (newest 2h ago) · 3 contacts · 2 Marmot groups

WHO: the profile name/NIP-05 amy holds locally (read from the account's
own kind:0 in the store — new), the npub, and one plain-English sentence
for the signer instead of three fields (`signer` + `key_storage` +
`can_sign`). Plaintext key storage is called out in yellow.

WHAT'S SAVED: the account's own events in the store and when the newest
one landed (new), contacts, Marmot groups, key package, Concord
communities (new — never reported before), Cashu wallet, DM cursor.

The rule that keeps it short is "absent is silent": anything an account
doesn't have is omitted rather than printed as `no`/`0`, so a fresh
account is four lines and says `saved: nothing yet`. Two accuracy fixes
fall out of that: the self-alias `init` writes is no longer counted as a
saved contact, and the Cashu wallet is detected from a real kind:17375
in the store rather than from `cashu.json`, which only ever held NUT-13
counters. A directory whose `identity.json` won't parse now says so
instead of suggesting `init`, which would mint a new key over it.

Event-store size, backend and kind histogram move out entirely — that is
`amy store stat`, which had its own (correct, backend-aware) version all
along.

Mechanics:
- `Output.emit(result) { color -> … }`, an internal overload for a command
  with a purpose-built human rendering. JSON mode is untouched.
- `StoreFactory.openExistingShared(root)` opens the cross-account store
  only if it already exists, so this read-only command never leaves an
  empty database behind — covered by a test.
- `StoreCommands.fsStat` now calls `StoreStats.of`, which it had
  duplicated line for line; `status` was `StoreStats`' only caller and no
  longer needs it. Same output, ~50 fewer lines.
- Split into StatusCommand (dispatch) / StatusReport (data + JSON
  contract) / StatusText (rendering) to stay under the module's file-size
  convention.

JSON contract change (per DEVELOPMENT.md principle 5): `store` and
`account_count` are gone; `hex` is now `pubkey` per the documented
convention; per-account footprint fields move under `saved`; adds
`profile_name`, `nip05`, `saved.events`, `saved.newest_event_at`,
`saved.concord_communities`. No in-tree consumer read the old shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AtKhnNBSr9CWnZyjTWu7dL
2026-08-28 22:25:08 +00:00
Claude cdfc7ddb17 test(quartz): drop a redundant safe call the compiler flagged
assertTrue(entry?.isGeneric == true) smart-casts entry to non-null, so
the next line's ?. was dead and the build warned on it. Assert
non-nullness once up front and read the fields plainly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 22:17:18 +00:00
Claude 42c297104c fix(quartz): address the audit of the Trusted List work
Three bugs, each one where a write could produce a tag the matching read
refuses -- so the entry can never be found again, and every later write
appends instead of replacing.

replaceTrustedListProvider matched only generic entries but wrote
whatever it was handed. A named write therefore deleted the kind's
generic delegation -- a live delegation, gone irrecoverably, since 10040
is replaceable -- while never finding its own entry, so it duplicated on
every call. Replace and remove now address an entry by kind AND name, the
pair the first element encodes.

TrustedListProviderTag and ServiceProviderTag both let a constructor
write a kind their own parse rejects: outside 30392-30395 for the first,
outside NIP-85's 30382-30385 for the second. Both now require it, making
the unreadable state unrepresentable rather than silently accumulating.

That second bound, added in the previous commit on the read side only,
had regressed `amy graperank register --service 30392:podcaster`: the
dedup probe reads through the parser, so it appended a fresh duplicate
per run, and unregister could never match one. The CLI now rejects a
non-assertion kind with bad_args instead of writing a 10040 that grows a
tag per invocation.

Performance: the member scans that return one entry per tag -- members(),
memberValues(), linkedPubKeys/EventIds/AddressIds -- go through a
presizing fastMapNotNullDense instead of the stdlib mapNotNull, whose
capacity-10 start costs ~20 array copies on a 5k-member list. Deliberately
NOT applied to the sparse scans beside them: picking two discovery tags
out of thousands would allocate a thousands-wide array to hold two, which
is worse than the growth it avoids. The operator's KDoc says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 21:39:03 +00:00
Claude dfddf35e40 feat(quartz): carry Trusted List Map entries in both halves of the 10040
A 10040 keeps half its delegations NIP-44 encrypted in content -- who you
trust to rank the network is itself sensitive -- and the previous commit
only reached the public tags. The parsing was never the gap: it is
TagArray-level, so a caller merging the halves (commons'
PrivateTagArrayEventCache, which is how the app reads NIP-85 providers)
already got private entries out of trustedListProviders(). What was
missing was the event-level surface.

Reading now splits explicitly. publicTrustedListProvider(kind) is the
public tags alone; trustedListProvider(kind, signer) merges both halves
and falls back to the public half with anyone else's signer rather than
failing, matching TrustProviderListEvent.privateTags. Public tags are
searched first, so a Map that violates the invariant across halves
resolves to its public entry.

Writing takes isPrivate and maintains the invariant ACROSS halves: at
most one generic entry per kind is a property of the Map, not of one
half, so the write also drops the entry from the other side. Moving a
delegation between public and private is one call instead of a two-step
that strands a twin -- shadowed on read, republished forever after.

That costs the property the earlier version had of never needing
decryption: a public write on a Map with a private half must open it,
because we cannot drop a twin we cannot read. It throws
UnauthorizedDecryptionException rather than publish a Map that breaks the
invariant. A Map with no private half needs no decryption either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 20:37:47 +00:00
Claude 2ffc5ff49d feat(quartz): read the Trusted List entry in a NIP-85 Treasure Map
A 10040 delegates each assertion kind+metric with
["30382:rank", <pubkey>, <relay>]. Trusted Lists extend the Map with a
generic bare-kind entry, ["30392", <pubkey>, <relay>] (Tapestry ADR
tl-treasure-map/0001), where one entry delegates every list of that kind
and names are never enumerated. Quartz could not see it at all: parsing
went through ServiceType, which requires a `:`, so the entry fell out as
unparseable and the delegation was invisible.

Two further gaps came out of probing the same path:

An entry whose relay hint is the empty string -- what a publisher writes
when it has no relay configured, keeping the three-element shape -- was
dropped whole, taking the pubkey with it. The pubkey is the part a
consumer cannot do without, so relayUrl is nullable here and the
delegation stands without a hint.

A reserved named entry, ["30392:podcaster", ...], splits into two
segments exactly like "30382:rank" and was being handed to NIP-85
consumers as a live provider -- the one thing the spec says readers must
not do with them. ServiceProviderTag.parse is now bounded to NIP-85's own
assertion kinds (30382-30385), so those entries route to the Trusted List
parser instead of the rank/follower-count lookups. Nothing is lost, only
sorted: named entries parse, carry isGeneric = false, and drive nothing.

Readers resolve duplicate generic entries first-occurrence-wins, so two
readers of one Map pick the same publisher. Writers go through
replaceTrustedListProvider, which swaps the entry in place, collapses
duplicates for that kind, and preserves every other tag verbatim -- 10040
is replaceable, so anything dropped on an update is gone from the Map for
good. Content is carried across untouched, so the write needs no
decryption permission.

Kept in experimental/trustedLists/treasureMap rather than the NIP-85
package: this is a pre-NIP extension riding on that kind, and a NIP-85
consumer should stay unaware of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 20:05:20 +00:00
Claude 9fae1e526e feat(quartz): give the trusted-list member score a 0-100 scale
The member tag has carried its score at index 3, right after the relay
hint, since the family landed -- but as a bare Int with no domain. A
number nobody agreed on the ceiling for cannot be compared across two
publishers, or even across two metrics of one publisher, which is the
whole reason a list carries scores instead of just membership.

Pin it to a percentage: an integer 0..100 inclusive, named once in
MemberTagFields.SCORE_RANGE and shared by `p`, `e`, `a` and `i`.

Write clamps into the range, so we never emit a value we would refuse to
read. Read drops anything outside it rather than clamping: a publisher
counting on some other scale (0..1, 0..1000, a raw endorsement tally) is
reporting a quantity this field cannot carry, and pinning 950 to 100
would rank that member above every honestly-scored peer. The member
itself still stands -- it is simply unscored, the same state as a tag
that carries no score at all.

Both bounds are real scores, not sentinels: 0 means "scored, and the
publisher has no confidence in this member", which is not the same as
unscored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXpL2TPmSmxdv7eBwhWvJp
2026-08-28 19:26:10 +00:00
Vitor PamplonaandGitHub e5e6076039 Merge pull request #4004 from davotoula/fix/logging-hygiene
perf(logging): defer message construction to the lambda overload
2026-08-28 13:59:09 -04:00
David KasparandGitHub 163b272ae8 Merge pull request #4003 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-28 19:36:46 +02:00
davotoula b06093e9a2 fix(logging): finish the sweep the line-anchored patterns missed
The audit that produced the previous two commits used line-anchored greps, so
a call formatted across several lines was invisible to it. That selected for
short calls rather than expensive ones, and it shows: BootRelayDiagnostics had
three one-line banner calls converted while two Log.d calls in forEach loops
immediately below them — 25 and 20 iterations per census, each concatenating
five interpolated segments with nested joinToString — were left eager. Those
are the larger cost by a wide margin, and Log.d is dropped in every build.

Convert them, plus the multi-line census header and one in
AccountConcordActions. The three multi-line calls left in AccountCacheState
pass a throwable and carry static messages, so the eager form is correct there.

Also from review: extract the duplicated sort+join chain the two census
summaries shared; drop "${e.message}" from two AccountCacheState calls that
already pass the throwable (the inverse of the bug the first commit fixed, and
pre-existing); hoist refusalReason() in BlossomPaymentHandler, which computed
it twice on the same branch.

Record the rule in CONTRIBUTING-WITH-AI.md's existing Logging section, which
already owns the lambda-Log guidance — the previous commit put it only in the
skill, which is read only when the skill is invoked. Add a comment to
Amethyst's init block explaining why Log.minLevel is set there and not in
onCreate: init runs in every process, including the :napplet sandbox whose
onCreate early-returns, so moving it would leave that process at DEBUG.

The skill gains the multi-line step, and its own errors are fixed: it said
"Two" above a three-item list, Step 3 still used the anchored pattern and
short name list that Step 2 had just been corrected for, and the verify
command used grep -c, which counts lines and so undercounts. Step 4 becomes
Step 0 and moves above Step 1 — it gates the others, and saying so five times
in a document that ordered it last was the symptom.
2026-08-28 19:29:55 +02:00
davotoula 1d420dc406 docs(skill): correct find-non-lambda-logs from a real audit
Three things the 2026-08-28 pass got wrong because the skill told it to.
2026-08-28 19:28:09 +02:00
davotoula ccdcf433e1 Refactor logging
refactor(logging): move the last android.util.Log users onto the quartz wrapper
fix(logging): use the lambda overload, and keep the throwable in a catch log
2026-08-28 19:27:46 +02:00
davotoulaandgithub-actions[bot] ec27db70cc chore: sync Crowdin translations and seed translator npub placeholders 2026-08-28 16:05:01 +00:00
davotoula 8293cbfb7f update cs,se,de,pt 2026-08-28 18:00:25 +02:00
Vitor PamplonaandGitHub 2735d6612b Merge pull request #4002 from davotoula/fix/nwc-nip44-and-lnurl-dedup
fix(zaps): deduplicate LNURL endpoint fetches; stop NWC NIP-04 downgrade on a cold cache
2026-08-28 09:54:15 -04:00
davotoula 9bd85d0cbc Code review: release awaiters + cap the NIP-44 negotiation wait
fix(nwc): release awaiters when the account scope is already dead
fix(nwc): cap the NIP-44 negotiation wait and keep the fetch off the caller
2026-08-28 11:27:49 +02:00
davotoula 4f3e9fd1cd fix(nwc): stop downgrading to NIP-04 on a cold info cache
NIP-47 says a client "should always prefer nip44 if supported by the wallet
service", so prefersNip44() returning false has to mean "the wallet does not
offer NIP-44" — not "we have not asked yet". It meant both.

NwcInfoCache is per-account and in memory only, so it starts empty on every
app launch, and prefersNip44 read it without waiting. The first transaction
to each wallet after each launch therefore went out as NIP-04 even against a
wallet advertising nip44_v2 — a silent downgrade to deprecated encryption on
a payment request. The startup warm-up narrows the window but does not close
it: it only covers the default wallet, and it races the user's tap.

Add currentOrFetch(), which waits only when nothing at all is cached and
returns a stale entry as-is — staleness never caused the downgrade, since a
stale entry already says what the wallet advertises, so waiting on it would
buy nothing. prefersNip44 becomes suspend and uses it; both call sites were
already suspend.

Funnel every fetching path through one request per wallet. getFresh() went
straight to the network with no deduplication — only the background refresh
was guarded, and by a plain key set that could not be awaited. Without this,
making the payment path wait would have had it race the startup warm-up and
issue a second concurrent fetch for the same wallet.

Verified by mutation: reverting currentOrFetch to the old non-waiting read
fails the cold-cache tests, and removing the single-flight fails the
deduplication tests. The prefersNip44 call site itself is a two-line swap
covered by those cache tests — NwcSignerState has no test harness and
building one for it was out of proportion to the change.
2026-08-28 10:37:19 +02:00
davotoula b29519e4e6 refactor(zaps): move LNURL fetch dedup onto LnurlEndpointCache
Single-flight landed inside OkHttpLnurlEndpointResolver, which put the two
halves of one mechanism — "resolve this URL exactly once" — in two modules.
The flight map had to call LnurlForm.normalizeUrl purely to match a keying
detail private to LnurlEndpointCache in quartz. Nothing documented or
enforced that: if the cache changed its canonicalisation, the map would
silently stop deduplicating and no test would fail.

Move it onto the cache as getOrFetch(url, fetch). The key is now computed
once and shared by the lookup, the flight map and the store, so they cannot
disagree. Dedup also becomes process-wide, matching the resource it
protects — a stranger's /.well-known/ endpoint — rather than being scoped to
one resolver instance; clear() resets both maps. The resolver drops to a
one-line delegation and keeps only the HTTP half. Same shape as NwcInfoCache,
which already pairs a cache with an in-flight map and an injected fetch.

Mechanism tests move to quartz beside the cache, using delay() rather than
a blocking sleep. The commons test keeps the one claim it uniquely makes:
that the resolver really routes through the cache over a real OkHttp client.

No behaviour change. Verified by mutation: removing single-flight, keying
the flight map on the raw URL, never releasing the slot, and making the
resolver bypass the cache each fail exactly the test that covers them.
2026-08-28 10:37:19 +02:00
davotoula 319348de9a fix(zaps): single-flight the LNURL endpoint resolver
A zap-receipt burst hands OkHttpLnurlEndpointResolver one resolve() call
per receipt, each on its own coroutine from LocalCache.consume(LnZapEvent).
The resolver's read-through cache only helps once a fetch has landed, so
the whole burst missed together: N receipts for one lightning address made
N requests to that provider's /.well-known/lnurlp/ endpoint. A
lightning-address server observed ~20 per user action, with no zap sent.

Hold one CompletableDeferred per in-flight URL and let the rest await it.
The entry is keyed through LnurlForm.normalizeUrl, matching how
LnurlEndpointCache keys itself, so host case and a trailing slash share a
flight rather than starting two. The winner releases the slot in a finally
after the cache is populated, so a failed fetch is retried by the next
caller instead of being remembered as null, and awaiters are unblocked
even if the winner is cancelled.

The cache itself is unchanged.

Tested with a burst whose callers are released through a shared gate. The
gate is load-bearing: asserting "one fetch" while relying on every coroutine
reaching putIfAbsent before the winner's fetch returns makes a slow machine
fail the test rather than a regression. The burst test failed 20/1 before
this change.
2026-08-28 10:37:19 +02:00
Vitor PamplonaandGitHub 97919fd460 Merge pull request #3996 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-27 22:41:46 -04:00
vitorpamplonaandgithub-actions[bot] d44e1ff1d3 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-27 23:52:37 +00:00
Vitor PamplonaandGitHub 866897022f Merge pull request #4001 from vitorpamplona/claude/gif-insertion-post-comment-3z9ozg
fix(composer): wire keyboard GIF insertion into the last four composers
2026-08-27 19:50:05 -04:00
Claude c8bc0f6378 fix(composer): wire keyboard GIF insertion into the last four composers
Auditing every text-field call site against the view models that can accept
media turned up four more composers with an upload button and a full media
pipeline, but no `onContentReceived` — so a GIF inserted from the keyboard
silently did nothing there too:

- New public message: already called MessageFieldRow, which gained the
  parameter in the previous commit; it just never passed one.
- Nests audio-room chat.
- Long-form markdown editor.
- Minichat, which routes through ChatFileUploadState instead of the view
  model, so it also mirrors the gallery button's encryptFiles choice.

Two composers are deliberately left out. NewHighlightScreen has no media
pipeline at all, so a received GIF would have nowhere to go. EditPostView
uses OutlinedThinPaddingTextField, which has no contentReceiver — supporting
it there means changing that component, not passing an argument.

Only the keyboard commitContent path is addressed here. The chat composers
still lack the onNewIntent listener that catches a share-intent GIF (as
SwiftKey sends it), so sharing one from a chat continues to navigate out to
a new short-note composer; that is a larger change and is left for its own
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMyN6Y7DsXFdPxDxLfgo3g
2026-08-27 23:17:35 +00:00
Vitor PamplonaandGitHub dd98d9da9d Merge pull request #4000 from vitorpamplona/claude/gif-insertion-post-comment-3z9ozg
fix(composer): restore GIF insertion on the comment reply screens
2026-08-27 19:11:16 -04:00
Claude 519b76c708 fix(composer): restore GIF insertion on the comment reply screens
Replying to a kind-1 note opens ShortNotePostScreen, which wires both GIF
delivery paths. Replying to a comment (or a hashtag/geohash/url scope) opens
GenericCommentPostScreen, which wired neither, so GIFs silently did nothing:

- Gboard-style `commitContent` reaches the field only when the caller passes
  `onContentReceived`; ThinPaddingTextField attaches the `contentReceiver`
  modifier just for those, and MessageField defaults the parameter to null.
  The comment composer never passed one.

- SwiftKey delivers a GIF as a fresh ACTION_SEND. ShortNotePostScreen catches
  it with its own onNewIntent listener; the comment composer had none, so the
  global share router in AppNavigation handled it instead — and since its
  guard only recognised Route.NewShortNote, it answered a GIF by starting a
  brand-new short-note composer and discarding the reply in progress.

Wire both paths into GenericCommentPostScreen, which covers all four of its
entry points (comment, hashtag, geohash and url replies), and widen the
onNewIntent guard via consumesSharesInPlace() so a redelivered share no longer
throws away the draft. The launch-intent guard is left alone: a share that
starts the activity has no composer listening yet, so it must still navigate.

The root cause is copy-paste drift between composers, so also pull the four
identical addToMessage() bodies up into IMessageField as a default, and pass
onContentReceived on the other two composers with a working media pipeline
(new product, new group DM). NewHighlightScreen has no media pipeline and
EditPostView uses OutlinedThinPaddingTextField, which has no content receiver
— both left as-is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMyN6Y7DsXFdPxDxLfgo3g
2026-08-27 21:46:02 +00:00
Vitor PamplonaandGitHub f1b36ec4a5 Merge pull request #3954 from carmin777/feat/android-screen-share
feat: add Android screen sharing to calls
2026-08-27 01:13:18 -04:00
Vitor PamplonaandClaude Opus 5 d651ad3b1e fix(call): stop reopening the camera while the call is being torn down
Hanging up during a screen share opened the front camera for ~350ms before
closing it again. CallMediaManager.dispose() calls stopScreenShare(), which
restores the pre-share camera state, and only then calls stopCamera(). On an
SM-T220:

  22:07:46.389 MediaProjection: Dispatch stop to 0 callbacks
  22:07:46.432 CameraCapturer: startCapture: 1280x720@30
  22:07:46.438 Camera2Session: Opening camera 1
  22:07:46.433 CameraCapturer: Stop capture: Waiting for session to open
  22:07:46.765 Camera2Session: Stop done

so the user sees the camera privacy indicator flash on hangup, and the teardown
blocks waiting for the capture session it just started. It also churned the
local video track and source through recreateCameraResources() purely to
dispose them a few lines later.

stopScreenShare() takes restoreCamera, defaulting to true so the user-initiated
stop is unchanged; dispose() passes false.

Verified on device. Hangup while sharing: camera opens once for the call, closes
when sharing starts, and is never reopened during teardown — no startCapture and
no "Opening camera" in the teardown window. Stopping the share with the button
still restores it (startCapture + CAMERA_STATE_ACTIVE, preview returns).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-27 00:28:24 -04:00
Vitor PamplonaandClaude Opus 5 8059dd7898 fix(call): release the screen-capture Surface instead of leaking it
Every screen-share session leaked one android.view.Surface, surfacing as a
StrictMode LeakedClosableViolation shortly after teardown:

  Explicit termination method 'Surface.release' not called
    at android.view.Surface.<init>
    at org.webrtc.ScreenCapturerAndroid.createVirtualDisplay(ScreenCapturerAndroid.java:193)

The library's ScreenCapturerAndroid builds the capture Surface inline and keeps
no reference to it:

  virtualDisplay = mediaProjection.createVirtualDisplay(
      ..., new Surface(surfaceTextureHelper.getSurfaceTexture()), ...);

so nothing can ever call Surface.release(). VirtualDisplay.release() does not
cover it — the Surface belongs to the caller — so it survived a clean in-app
stop and was reclaimed only whenever the finalizer next ran.
changeCaptureFormat() leaked another one per call (i.e. per rotation).

createVirtualDisplay() and the virtualDisplay field are both private, so this
cannot be fixed by subclassing. Replaces it with ScreenShareCapturer, a
derivative of the upstream class (© 2016 The WebRTC project authors,
BSD-style license) that holds the Surface and releases it together with the
virtual display, in stopCapture(), changeCaptureFormat() and — for the failure
path where startCapture() has no matching stop — dispose().

Verified on an SM-T220: two full share/stop cycles, two VirtualDisplay
create/destroy pairs, three forced GCs via `am dumpheap` (97MB dumps, so the
finalizer really ran) and zero LeakedClosableViolations. The same flow on the
previous build produced the violation three separate times. Screen sharing
still reaches the peer, confirmed by the remote rendering the shared screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-27 00:16:33 -04:00
Vitor PamplonaandGitHub 09cddc59f1 Merge pull request #3998 from vitorpamplona/claude/github-json-carmin777-mapping-2ofbxn
Add carmin777 to contributors list
2026-08-26 23:37:04 -04:00
Vitor PamplonaandClaude Opus 5 965d55779c fix(call): keep calls alive when the system destroys MainActivity
A call that was up died as soon as Android reclaimed the backgrounded
MainActivity — reproducible on a Samsung SM-T220 a few hundred ms after
CallActivity enters picture-in-picture on HOME. Any screen share went
with it. Two independent causes:

1. Call state was owned by an Activity-scoped ViewModel.
   AccountViewModel.onCleared() -> CallSessionBridge.clear() ->
   CallManager.reset() -> CallState.Idle -> CallSession.close().
   CallManager also ran on viewModelScope, so a surviving call would
   still have been half-dead (signaling publishes silently no-oping).
   CallSessionBridge.clear() assumed onCleared meant "logout or account
   switch"; it fires on every MainActivity destruction.

2. CallForegroundService.onTaskRemoved hung up on the wrong task.
   It fires for every task of the app, and MainActivity is
   singleInstance while CallActivity launches with FLAG_ACTIVITY_NEW_TASK
   — so they live in different tasks. The service treated the system
   reclaiming MainActivity's task as the user swiping the call away
   (transitionToEnded reason=HANGUP).

Fixes:
- Account owns callManager, built on account.scope, so it outlives the
  UI and dies with the account.
- AccountViewModel references account.callManager; onCleared only drops
  the ViewModel reference.
- CallSessionBridge exposes the app-scoped Account and splits teardown:
  clearViewModel() (activity destroyed) vs clear() (real logout/switch).
- CallActivity binds its session to the Account; only its UI uses the
  ViewModel.
- AccountSessionManager calls CallSessionBridge.clear() on switch/logoff,
  mirroring the existing NestBridge.clear() hooks.
- AccountCacheState.removeAccount disposes callManager, whose watchdog
  scope is independent of account.scope.
- onTaskRemoved only hangs up for CallActivity's own task; a null root
  intent still hangs up so a swiped-away app cannot strand a call.

Verified on device: HOME during a call now keeps the call up (it ends
only on the legitimate 30s ring timeout), and a connected call with
screen sharing keeps streaming to the peer after the sharing device is
backgrounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-26 23:24:57 -04:00
Vitor PamplonaandClaude Opus 5 1e26fb8d5a fix(call): make ScreenShareResources public so the module compiles
CallMediaManager.stopScreenShare() returns ScreenShareResources and
disposeScreenShareResources() takes it, but the class was declared
internal, so :amethyst:compilePlayDebugKotlin failed:

  'public' function exposes its 'internal' return type 'ScreenShareResources'
  'public' function exposes its 'internal' parameter type 'ScreenShareResources'

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Awrm1ro4eQXSaXDoH8EW3z
2026-08-26 23:24:39 -04:00
Claude 7ffb5a9556 docs(changelog): map carmin777 to their npub
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01St3rihb3T8PqW6USwo9zkt
2026-08-27 02:26:08 +00:00
Vitor Pamplona 551ded1cf9 Merge branch 'main' into test-3954 2026-08-26 21:30:13 -04:00
Vitor PamplonaandGitHub 3a057f47b7 Merge pull request #3994 from greenart7c3/fix/deleted-list-uuid-in-top-bar
fix(lists): stop deleted lists showing their UUID in the feed filter picker
2026-08-26 20:48:13 -04:00
Vitor PamplonaandGitHub 040c4c9f01 Merge pull request #3995 from vitorpamplona/fix/tor-bootstrap-stall-and-ondemand
fix(tor): stop fresh installs stranding on a Tor bootstrap, and start them on clearnet defaults
2026-08-26 20:46:13 -04:00
Vitor PamplonaandClaude Opus 5 18b0dafec6 feat(tor): route the app's stand-in relays like the user's own until their lists arrive
A brand-new install routes 100% of its relay traffic over Tor by construction,
and that is a chicken-and-egg rather than a preference: `trustedRelays` is empty,
so `TorRelayEvaluation` falls through to `newRelaysViaTor` (default true) for
every url — and the kind:10002 that would populate it can only be fetched over
Tor. Measured on a Samsung SM-T220, same account, same login timing, fresh
install each: the first relay socket opened 2.3-2.9s *after* Tor became ready,
whenever that happened to be, and Arti's directory download ran 12.6-51.7s.

While an account's own lists are unknown, the defaults the app is already
dialling are now also classified for Tor purposes — as `assumed` relays, the
last branch before `newRelaysViaTor`:

  first relay socket, vs when Tor became ready (n=3 each, counterbalanced)
    before:  login+5.87s / +7.89s   — always 2.3-2.9s AFTER Tor Active
    after:   login+1.21s / +1.24s / +1.29s — independent of Tor entirely
  events ingested by the 20s census, non-overlapping
    before:  0 / 892 / 1159 / 2590
    after:   3719 / 3997 / 4081 / 5311 / 6051

It resolves to `trustedRelaysViaTor`, not to a hardcoded false: the app's
stand-in for a list gets the policy the user chose for their own list, so
anyone who set that preference keeps Tor here with nothing new to discover. And
it sits below .onion, money-operation and DM in the precedence chain, so those
keep their own policy for free — the branch can only capture urls that would
have been treated as strangers.

The guess ends by itself. `assumedDefaults` keys on the *event* being absent —
never on a list being empty, which is a choice we honor — so each list's
contribution empties the moment that event lands, with no window, timeout or
per-account bookkeeping. Device log: `Guessed relays: 15 -> 10 -> 5 -> 0 (own
lists arrived; released to their real Tor policy)`, after which 28 relays
re-dialled and their connect latency moved from a median 116ms to 503ms — the
handover onto Tor circuits, visible in the timings.

Deliberately NOT merged into `TrustedRelayListsState`. That 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. Tor routing is the only consumer.

Two supporting changes, both of which pay for themselves here:

`RelayClassification` groups the four category sets into one value. The
reconnect trigger in `RelayProxyClientConnector` used to compare them field by
field, so a new category meant remembering another `||` — and I had forgotten
it, which is exactly the silent failure it invites: relays keep a socket on a
transport the policy has already moved them off. It is now one structural
comparison. That also removes a `Pair` that existed only to squeeze past
`combineTransform`'s five-source limit. Regression test covers the case that
made the omission reachable: an *empty* arriving list, where `trusted` does not
change while `assumed` empties.

`AccountsTorStateConnector.unionAcrossAccounts` replaces four ~30-line copies of
the same per-account fold. The copies had already drifted — two carried an
`if (isEmpty)` guard that could never fire, since `ifEmpty` had just guaranteed
otherwise.

Verified byte-identical to the build these numbers were measured on, and
re-measured after the refactors: first socket 1.24s median vs 1.21s before,
fully overlapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 20:24:08 -04:00
Vitor PamplonaandClaude Opus 5 e7bcb88d30 fix(relays): only substitute default relays when we have no event, not when the list is empty
There are three states, and two of them were collapsed:

  | we have                | effective list                        |
  |------------------------|---------------------------------------|
  | no event for the user  | app defaults — we do not know          |
  | an event, empty list   | **empty** — they told us: nothing      |
  | an event with relays   | those relays                           |

Every `WithBackup` helper keyed its fallback on the list being *empty* rather
than the event being *absent*, because `readRelaysNorm()`/`writeRelaysNorm()`
end in `.ifEmpty { null }` and the indexer/search helpers wrote
`?.ifEmpty { null } ?: DEFAULTS` outright. So a user who publishes a kind:10002
carrying only write relays silently acquired `Constants.bootstrapInbox` as their
*inbox* list, and a deliberately empty search or indexer list was replaced by
ours. That is the app overriding an explicit choice.

Only `normalizeNIP65AllRelayListWithBackup` was correct, and only by accident:
`relays()` has no `ifEmpty`, so its `?:` could fire only for a missing event.

The rule is now one named, tested primitive rather than an expression
open-coded at four call sites — three of which got it wrong the same way:

    relayListOrDefaultsWhenUnknown(event, defaults) { it.readRelaysNorm()?.toSet() }

`Account.indexRelays()` loses its `.ifEmpty { DefaultIndexerRelayList }` too;
it re-applied the substitution a layer up and would have undone the fix.

Two things deliberately left alone. The `Precached` variants keep substituting
defaults: they read only *already decrypted* tags, so empty there can mean "not
decrypted yet" — an unbounded window for a NIP-46 signer — rather than "the user
chose nothing", and the primitive's KDoc records that as a non-goal. And the
`NoDefaults` flows keep returning `emptySet()` for both cases, since their job is
to show what the user published.

Note for callers: the indexer and search flows previously documented themselves
as **never empty** and that contract is gone. A user who publishes an empty
kind:10007 now gets no search relays, which is what their event says. The same
applies to NIP-65 write relays, where the old fallback meant posts went to six
hardcoded relays; if a safety net is wanted there it belongs at the publish site
as a visible decision, not as a silent list substitution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 20:22:32 -04:00
Vitor PamplonaandClaude Opus 5 b266f1c403 fix(tor): keep retrying a stuck bootstrap, and stop calling a downloading Tor "Active"
A brand-new install could stop connecting to Tor entirely. Not slowly —
permanently: exactly two bootstrap attempts, then silence. Reproduced on a
Samsung SM-T220 (benchmark build, fresh install, log in, 150s offline, network
back): Tor never reached Active in the following 600s, no profile, no relay
lists, "Feed is empty." With the fix, the same scenario recovers at net+51s.

Root cause: on a native bootstrap timeout `TorService.start()` deliberately
leaves status at Connecting and delegates the retry to `TorManager`'s watchdog,
but that watchdog was `status.transformLatest { if (Connecting) { delay(45s);
emit() } }` — it fires once per Connecting *span*, and a timeout produces no
status change, so no new span ever began and the signal was never re-armed.
Nothing else covered it: `onNetworkChange` fires only on a networkId *change*
and `AppModules` drops the first non-null one, so even a network arriving from
offline did not rescue it.

Lifecycle fixes:
  - the watchdog re-arms while stuck instead of firing once per span;
  - it skips an attempt that is genuinely running, so a reset can no longer
    queue behind the blocking JNI call and tear down a client that just
    succeeded;
  - an install that has never bootstrapped retries on a 30s cooldown rather
    than the 5-minute one meant to protect working state;
  - `service.start()` is no longer awaited before `emitAll(service.status)`, so
    the app observes Connecting when the attempt starts rather than when it
    ends (on device the watchdog moved 105s -> 90s);
  - a hard init failure and port exhaustion no longer set the terminal Off,
    where neither the watchdog nor the failure dialog arms; both leave
    Connecting to be retried. The init path also no longer wipes all Arti data
    on any failure, which turned a transient "no network" into a lost guard
    sample — with an escalation after 3 fruitless gentle resets so corrupt
    state on a fresh install is still recovered.

Arti now bootstraps on demand. `create_bootstrapped` blocked the JNI call — and
the Kotlin lifecycle lock it holds — for the whole directory download (12.6s to
51.7s measured), during which `activePortOrNull` was null so every Tor-routed
dial fell back to 127.0.0.1:9050, the Orbot default, where nothing listens.
`create_unbootstrapped_async` + `BootstrapBehavior::OnDemand` returns in 124ms
and lets each stream wait for its own circuit. It does not make first paint
faster — the download is the real gate — but it removes the dead-port window
and the up-to-60s lock hold that also made "turn Tor off" appear frozen.

That forced a state split, and it is the load-bearing part. `Active` was
carrying two facts that used to coincide: "proxy routable" and "circuits
buildable". Android's `TorServiceStatus` gains `Bootstrapping(port)` plus
`socksPort` / `isFullyBootstrapped`, so callers state which they mean instead of
matching a variant that looks right for both. Commons gets the accessors only —
the desktop backend drives an external Tor and never sees the window, and a
variant nothing emits is dead weight.

Watchdogs are judged on forward progress, not elapsed time. Measured cold
downloads ran 12.6, 13.4, 14.0, 15.6, 17.9, 19.7, 19.8, 20.0, 34.4 and 51.7s on
one device and network, so no fixed patience separates slow from stalled: short
enough kills healthy downloads — and a reset discards the partial consensus, so
firing early can stop one ever finishing — while long enough sits uselessly on a
hang. A new `bootstrapProgressPermille()` exports `as_frac()`, and a download is
reset only after 60s with no movement at all, never with a state wipe. Device
run: a 51.7s download completed untouched where the previous code would have
reset and wiped its cache at 45s. `blocked()` is deliberately unused; Arti
documents it as best-effort and warns it misreports in both directions.

Readiness is read live (`bootstrap_status().ready_for_traffic()`) rather than
latching the one background `bootstrap()` result, which would report "not
bootstrapped" forever against a Tor that a later stream had already recovered.

`canDial` and `TorCircuitHealthTracker.isTorActive` gate on readiness, not
routability. Dialling on routability alone put ~190 relays into a backoff that
is never forgiven — the port is identical either side of Bootstrapping -> Active
so the transport never "changes" and `resetBackoff()` never runs — and it cost
nothing to wait: time-to-first-socket was unchanged by dialling early (n=3).

Both jniLibs ABIs rebuilt and verified reproducible from an upstream clone
(arm64 b53d20d2..., x86_64 36d41793...). `build-arti.sh`'s JNI symbol check
gained the new exports; it is a hardcoded list, and without them it silently
passed a stale .so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 17:26:26 -04:00
Vitor PamplonaandClaude Opus 5 4261124402 feat(logging): let the benchmark build emit the boot narrative
The `benchmark` build type is a release build (R8 + AOT) that exists purely to
be measured and is never shipped, but `DEFAULT_LOG_LEVEL` keyed on
`BuildConfig.DEBUG` and so pinned it to WARN. That dropped every INFO milestone
a boot narrative is made of — account load timings, Tor status transitions, the
BootRelayDiagnostics census — leaving the one variant whose numbers are
trustworthy as the one variant we could not read.

Key it on `isDebug` instead, which already covers the benchmark type
(DebugUtils.kt) and is what gates `BootRelayDiagnostics` itself, so the census
and the log level that lets it through can no longer disagree. Release is
unaffected and stays at WARN.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BKYGEp22uGSzWrBDg8fAQ9
2026-08-26 17:25:53 -04:00
greenart7c3 e0e2e427c4 fix(lists): stop deleted lists showing their UUID in the feed filter picker
Deleting a NIP-51 people list (kind 30000) or follow pack (kind 39089)
left a null-event AddressableNote behind — and the persisted per-screen
TopFilter that still pointed at the address re-created that shell on
every start via getOrCreateAddressableNote, so the deleted list kept
showing in the top-bar feed filter, its name falling back to the dTag
(UUID) once the event was gone.

- PeopleListsState / FollowListsState: exclude addressables without an
  event from the picker options (generalizes the earlier block-list-only
  filter to every list, and adds it for follow packs).
- deleteFollowSet() now resets any persisted default*FollowList that
  still points at the deleted address back to that screen's default, so
  no dangling filter survives a restart.

Fixes #3949
2026-08-26 18:23:23 -03:00
David KasparandGitHub ef4e7075be Merge pull request #3993 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 21:21:45 +02:00
davotoulaandgithub-actions[bot] 51d3485b53 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 19:20:42 +00:00
David KasparandGitHub 1b569eec7f Merge pull request #3992 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 21:17:46 +02:00
davotoulaandgithub-actions[bot] 3e459ec5ad chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 19:10:38 +00:00
davotoula 60d84b8d75 upgraded agp 2026-08-26 20:52:21 +02:00
davotoula deee5e5cde update cs,pt,de,sv 2026-08-26 20:50:19 +02:00
Vitor PamplonaandGitHub d06b83bd53 Merge pull request #3991 from vitorpamplona/claude/slow-image-loading-feed-9b5leo
Move Blossom read-auth signing off OkHttp threads
2026-08-26 11:37:29 -04:00
Vitor PamplonaandClaude Opus 5 8114f054d4 Merge PR: fix(desktop): surface macOS notification-permission OS errors + timeout the request
Merges nostr proposal 12762d29 into main:
- NotificationDispatcher gains lastRequestError so the OS's own message
  (e.g. UNErrorDomain "Notifications are not allowed for this application")
  reaches the settings UI instead of a generic "denied".
- NucleusNotificationDispatcher bounds requestPermission with a 90s timeout,
  so an auto-dismissed macOS permission banner no longer parks the coroutine
  and the "Requesting..." spinner forever.
- sendMac waits up to 10s for the UNUserNotificationCenter.add ack and
  reports Failed on a non-blank OS error instead of a phantom Delivered.
- NotificationSettingsScreen surfaces the error text and offers "Ask again".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PUzAFtZJYBUr8wvFdqM2Mb
2026-08-26 11:25:46 -04:00
David KasparandGitHub c291026d5a Merge pull request #3990 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 17:06:33 +02:00
vitorpamplonaandgithub-actions[bot] e7b7211625 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 14:45:29 +00:00
Vitor PamplonaandGitHub a3623ceffd Merge pull request #3987 from davotoula/fix/nwc-silent-refusals
Fix nwc silent refusals
2026-08-26 10:42:22 -04:00
David KasparandGitHub 00ccef7277 Merge pull request #3989 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 13:55:38 +02:00
davotoulaandgithub-actions[bot] b935f4c99c chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 11:35:46 +00:00
davotoula a1edb597bc update cs,pt,de,sv 2026-08-26 13:31:58 +02:00
David KasparandGitHub 8d79145d2a Merge pull request #3988 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-26 13:30:03 +02:00
davotoulaandgithub-actions[bot] 9aa4a9536c chore: sync Crowdin translations and seed translator npub placeholders 2026-08-26 10:11:37 +00:00
davotoula b9ff65290b refactor(napplet): remove inline FQNs and duplicated MIME literals.
remove inline fully-qualified names
drop the redundant Dns wrapper and IOException qualifier.
Define a constant instead of duplicating literals
2026-08-26 12:02:28 +02:00
davotoula e1df0f0c45 fix(nwc): disambiguate the replay warning, and localize the wallet error text 2026-08-26 11:05:25 +02:00
davotoula 22c170c510 Code reviews:
fix(nwc): close the last silent path and drop the success-type guessing hazard
refactor(nwc): fold the repeated failure-message logic into shared helpers
2026-08-26 11:05:12 +02:00
davotoula 24a8540ad9 fix(nwc): never let a NIP-47 refusal or timeout reach the user as silence
Field report (BrollyZapper, 2026-08-25): a QUOTA_EXCEEDED on pay_invoice and a
RESTRICTED on list_transactions both reached the phone and showed nothing at
all — no toast, no dialog, no error state. The action simply looked like it had
not happened. Three separate defects produce that symptom.

1. The zap path had no user-visible timeout. NwcSignerState's 60s safety net
   only dropped the relay subscription: it never cleaned the tracker entry and
   never told anyone. A response lost in transit (the same trip measured
   relay.damus.io refusing 40% of websocket upgrades) was therefore permanent
   silence. The timeout now retires the request and fires an onTimeout callback
   that every interactive caller renders. NwcPaymentTracker.cleanup returns
   whether it was the one to remove the entry, so a timeout racing a real
   response stays quiet rather than overwriting the wallet's own answer.

2. WalletTransactionsScreen never read walletViewModel.error. The ViewModel set
   it correctly on both the refusal and the timeout paths; the view branched on
   isLoading/isEmpty only and rendered "No transactions yet" over the top of it.

3. Consumers matched on PayInvoiceErrorResponse, which the deserializer only
   produces when result_type == "pay_invoice". NIP-47 does not require a wallet
   to echo result_type on an error, and an error for any other method takes the
   generic NwcErrorResponse branch — so those refusals were dropped without a
   word, and the DVM screen went as far as thanking the user for a payment that
   had just been refused. All of them now match IErrorResponseLike, and the
   remaining else branches report an unreadable response instead of nothing.

Also: errorMessage() falls back to the code name when a wallet sends `code`
without `message` (message is optional in NIP-47), and stale wallet errors are
cleared when a transaction fetch or page load succeeds.
2026-08-26 11:04:42 +02:00
Vitor PamplonaandGitHub fb0d8bd857 Merge pull request #3986 from vitorpamplona/claude/amethyst-file-upload-issue-hs9f7b
fix(browser): open a file picker for HTML file inputs
2026-08-26 01:00:54 -04:00
Vitor PamplonaandClaude Opus 5 baae40e5fc fix(browser): stop deleting the video a capture just returned
`accept="video/*" capture` handed the page a 0-byte file. The recording was
fine — we deleted it before the page could read it.

parseResult assumes a camera signals success by filling the EXTRA_OUTPUT file
and returning no URI. ACTION_IMAGE_CAPTURE does exactly that.
ACTION_VIDEO_CAPTURE on GoogleCamera does not: it writes the file *and* echoes
the output URI back in the result. That echo lands in `picked`, which makes
`captured` null, and the cleanup loop then treats every capture as unused:

    if (capture !== captured) NappletCaptureFiles.discard(context, capture.file)

So the one file whose URI was on its way to the page was the one file deleted.
The page opened it, found nothing, and a "successful" upload carried no bytes.

Captures whose URI is being returned are now excluded from the discard sweep,
whichever way they got there — echoed back in the result, or found by the
fill check. Untouched capture files are still deleted immediately, so a
dismissed or unused camera option leaves nothing behind.

An echoed URI is also no longer trusted on its face: if the file behind it is
empty the URI is dropped, and the request falls through to the same emptiness
rules as before rather than reporting a capture that never happened. URIs that
are not ours are never second-guessed.

Verified on device (Pixel 8 / Android 17), after the fix:
- video: 28,135,304-byte mp4 delivered and readable, was 0 bytes before
- image: 781,853-byte jpeg with EXIF intact — unchanged, no regression
- grants on the capture authority: 0 before, 1 while the camera holds it,
  0 again once the result is in, for both media

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-26 00:42:25 -04:00
Vitor PamplonaandClaude Opus 5 f11bdb8e49 fix(browser): stop the embedded file pick from killing the app
Completing a pick on either embedded surface crashed the whole app, every
time. parseResult resolved the picked URIs through
`WebChromeClient.FileChooserParams.parseResult`, which is a WebView *static*:
it boots Chromium in whichever process calls it. WebFileChooserActivity — the
main-process chooser host that exists precisely because the `:napplet`
providers are windowless and have no Activity to launch a picker from —
declares no `android:process`, so that call ran in main while `:napplet`
already held the WebView data directory. AwDataDirLock then threw

    Using WebView from more than one process at once with the same data
    directory is not supported

as a FATAL EXCEPTION on main. Reproduced on a Pixel 8 / Android 17: the
picker opens, the user selects, and on Done the process dies before the page
is ever handed its file.

The two Activity-owning hosts never hit it because they are themselves
`android:process=":napplet"`, where WebView is already initialised — which is
why the full-screen browser picked files correctly throughout. Cancelling did
not hit it either, so "the picker opened" was never enough to catch this.

The URIs are now read off the result Intent directly. The platform
implementation reads exactly the same two fields (ClipData items, else the
data URI, only on RESULT_OK), so behaviour is unchanged for the single-URI,
multi-select and camera shapes; it just no longer drags WebView into a
process that must not have it.

This also plugs a grant leak. releaseGrants runs *inside* parseResult, after
the line that was throwing, so every crashed capture left the camera apps
holding a live write grant on the capture URI that nothing would ever revoke.

Verified on device after the fix:
- embedded pick: no crash, page reads back all 94,976 bytes of the chosen
  PNG with its header intact — so a URI granted to the main process is
  readable by the WebView in `:napplet` with no re-granting, as designed
- camera capture: 892,681-byte JPEG with EXIF intact (full resolution, so
  EXTRA_OUTPUT is doing its job), delivered under the same name as the
  granted URI
- grant/revoke: 0 outstanding grants on the capture authority, 1 while the
  camera holds it, 0 again once the result is in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-26 00:05:13 -04:00
Claude f1c461dcfa fix(blossom): bring read-auth tokens into line with BUD-11
Two deviations from BUD-11, both predating the read-auth rework and both
carried forward by it.

The `x` tag defeated the per-host token cache. BUD-11 lists `x` as
optional for `GET /<sha256>`, but its Tag scoping rule is strict about
what including one means: "When `x` tags are present, the token is only
valid for operations on the specified blob hashes." Tokens are cached per
host and replayed for every blob on it, so from the second image onward we
were sending a token scoped to some other blob's hash. The old comment had
the reasoning backwards — it kept `x` "for servers that check it", which is
precisely the case that rejects a reused token. createGetAuth now takes a
nullable hash, and the read-auth path passes null: the `server` tag alone
scopes the token, which is what makes reuse legitimate. That widens the
grant from one blob to any blob on the host for the token's hour, which is
the inherent price of caching and is the shape BUD-11 sanctions.

The token encoding was standard Base64. BUD-11: "MUST be encoded as Base64
URL-safe without padding (Base64url, as used by JWTs)". In practice the
alphabets coincide — a token's JSON is printable ASCII and a sextet only
reaches 62/63 when the third byte of its group is `>`, `~`, `?` or DEL, so
`+` and `/` never appeared across 600 sampled tokens — but padding did, on
52% of them. NIP-98's encoder is deliberately left alone; it specifies no
variant.

Nothing in the tree decodes a Blossom auth header, so the encoding change
is client-side only.

Tests pin both rules at the event level and end-to-end on the token this
path actually mints, with several content lengths for the padding case
since whether padding appears depends on the JSON length mod 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
2026-08-26 03:32:13 +00:00
Vitor PamplonaandGitHub bdb2990846 Merge pull request #3985 from vitorpamplona/claude/pollresponsescache-deprecations-xaw9y2
Replace mutable collection methods with immutable equivalents
2026-08-25 23:05:57 -04:00
Claude 2f1e1d546c perf(images): stop blocking an OkHttp thread to sign Blossom read-auth
BlossomReadAuthInterceptor bridged the suspend signer with runBlocking so
it could retry an auth-gated blob with a signed BUD-01 token. intercept()
runs on an OkHttp dispatcher thread, so that wait held one of the 16
per-host slots for the whole signing window — up to the 8s timeout, and
with a NIP-55 external signer a real IPC round trip. A feed's first burst
against a gated host could occupy every slot and stall every other image
from it.

Interceptor.intercept() is synchronous by contract, so the wait cannot be
made cheap in place; it has to move to a caller that already suspends.
Coil's Fetcher.fetch() is that caller:

  - BlossomReadAuthTokenProvider.header() is now suspend, and signs on an
    injected scope. Concurrent callers collapse onto one CompletableDeferred,
    so a cold burst mints one signature instead of N — the token cache alone
    could not do that, being populated only after a signature returned.
    cachedHeader() stays a pure map read for callers that cannot suspend.
  - BlossomReadAuthFetcher carries the anonymous -> 401 -> signed retry,
    catching the HttpException that Coil's NetworkFetcher raises for a
    non-2xx and re-issuing with Authorization injected into options'
    httpHeaders. Wrapped around all three network-backed Coil factories.
  - The interceptor now only attaches an already-cached token for a
    known-gated host and fires the mint off-thread, so video and other
    non-Coil callers still pick a token up on their next request.

Measured on the same signer and host, signature latency 2000ms:
waiting for it cost 2003ms on the calling thread, intercept() now returns
in 0ms. With 16 concurrent callers and a 300ms signature: 1 signature,
all callers done in 303ms.

Behaviour for images is unchanged — anonymous first, signed retry, host
learned so later blobs are signed up front. The one narrowing: a gated
host reached first by the video datasource cannot mint its own token and
must wait for the warm to land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
2026-08-26 01:35:19 +00:00
Claude cc13664816 fix: clear compiler warnings in poll tally, relay auth and cache stub
Swap the deprecated persistent-collection mutators in PollResponsesCache
for their kotlinx-collections-immutable 0.5 replacements (add -> adding,
remove -> removing, put -> putting), drop two safe calls on receivers the
compiler already smart-casts to non-null, and rename the test stub's
override parameter to match ICacheProvider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014B22CbhBupxD3DZcac8jMi
2026-08-26 01:25:39 +00:00
Vitor PamplonaandGitHub de9a41ddb9 Merge pull request #3984 from vitorpamplona/claude/ime-padding-back-gesture-8xeyjn
Fix stranded IME inset that freezes keyboard padding
2026-08-25 20:54:04 -04:00
Claude cb5ddd7a77 perf: share one SafeImeInsets per window instead of one per call site
isStranded describes the window's insets listener, not any single layout, so
every call site in a window has to read the same flag. Each one built its
own, with its own IME_STRAND_GRACE_MS timer, and nothing made two of them
agree.

DisappearingScaffold is where that bit. It held two: one behind the root
modifier's padding and one whose value is subtracted from the nav-bar
reservation, with a comment asserting "the two have to agree" that the code
did not back. It also called imePaddingSafe() inside both arms of
`if (canHideBars)`, putting the call in two composition groups — so a
window-size-class change disposed and rebuilt the instance, dropping
isStranded back to false and putting the stale gap back on screen until a
fresh watchdog re-detected it.

SafeImeInsets is now cached per view, keyed exactly the way Compose keys
WindowInsetsHolder itself, and its constructor is internal so the cache
cannot be bypassed. Keying on the view is also what keeps a Dialog on its
own window's reading — a CompositionLocal would have handed it the host
activity's, which is why one was rejected earlier. The scaffold resolves the
instance once above the branch and passes it to ScaffoldLayout, so the value
it pads with is the same object the subtraction reads.

Call sites still park a watchdog each. They now write one shared flag from
the same two sources, so they cannot disagree; collapsing them to a single
watchdog would need either a scope outliving every call site (strongly
holding the view, defeating the weak cache) or a hand-off when the owning
site leaves the composition — both cost more than the coroutine they save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bqpAeyLAxHzw5XsnRUtjD
2026-08-26 00:50:22 +00:00
Claude 33a64ce73a Merge remote-tracking branch 'origin/claude/ime-padding-back-gesture-8xeyjn' into claude/ime-padding-back-gesture-8xeyjn
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HiddenWordsScreen.kt
2026-08-26 00:28:48 +00:00
Vitor PamplonaandClaude Opus 5 17c9357b2f fix: stop HiddenWords holding the keyboard gap open forever
This was the last screen still reading the raw animated IME inset, so it was
the one place `imePaddingSafe()`'s recovery could not reach. `union` takes the
max per side: with the inset wedged at the keyboard height and navigationBars
at ~48px, the union stays at the keyboard height and the bottom bar sits a
keyboard up with no keyboard on screen — permanently, because nothing else
pulls it back down.

The lift itself is correct and stays: `AddMuteWordTextField` has to clear the
keyboard. Only the source of the IME term changes.

Confirmed on a Pixel 8 that the wedge is real and does not self-correct: with
the workaround disabled the inset pinned at 957px for 85s while the window
reported the keyboard gone. See b/552500419 and SafeImeInsets.

No raw `WindowInsets.ime` reads remain in amethyst/ or commons/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-25 20:24:22 -04:00
Claude ed2b3ef8d7 fix: correct the IME inset union in HiddenWordsScreen too
The first pass swapped Modifier.imePadding() call sites, which missed this
one: it reaches WindowInsets.ime through a union with the nav-bar inset
instead. A stranded inset leaves the add-word bar floating a keyboard's
height above the navigation bar, the same symptom by a different route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bqpAeyLAxHzw5XsnRUtjD
2026-08-26 00:20:55 +00:00
Claude d28afe0677 Merge remote-tracking branch 'origin/main' into claude/ime-padding-back-gesture-8xeyjn 2026-08-26 00:09:55 +00:00
Claude 6becc6efbe fix(browser): close five holes found auditing the file-input path
Self-audit of the picker and camera work. Four correctness bugs and one
robustness gap, none of them reachable by the happy path, all of them reachable.

A malformed accept entry became the picker's filter. `accept="image/"` passed
the "contains a slash, so it is a MIME type" test and went straight into
Intent.setType, where it matches no provider — an empty picker with nothing to
choose and no way out. A slashed token is now only a MIME type when both halves
are actually present; otherwise it is unnameable and widens to everything, the
same as an unknown extension. Test first, watched it fail.

The main-process chooser host never reported when the system destroyed it
without finish() — a low-memory kill while the picker is on top. The page's
file input would then wait forever on a result nobody was left to send (dead for
the life of the page), and the coordinator would hold the reply callback, and
the controller behind it, for good. Reporting from onDestroy covers it. A
recreated host now releases the input immediately too, instead of silently
swallowing a pick it can no longer route.

A second file input asking before the first pick returned overwrote the
in-flight request. The page's own callback was already released, but the
superseded request still owned camera scratch files and the URI grants handed
to every camera app — nothing would ever come back for them, so they sat until
the daily sweep. Superseding now runs the cancel path on the old request, and
the same cleanup runs when a host is torn down mid-pick.

Capture filenames were built from a clock and a per-object sequence. The main
and `:napplet` processes each hold their own copy of that object, so the
sequences run independently and two picks started in the same millisecond could
name the same file, one capture silently overwriting the other. createTempFile
removes the question.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 23:25:22 +00:00
Claude ff1ecde496 feat(browser): offer the camera for HTML file inputs
Completes the file-input support: a page that accepts photos or video can now
reach the camera, not only files already on the device. `accept="image/*"` on a
mobile browser means "take one or pick one"; until now Amethyst could only do
the second half, which is the wrong half for the common case of uploading a
photo.

How it decides, mirroring a mobile browser: a bare file input offers stills and
video, an image-only accept offers just the camera, a document accept offers
neither. Resolved by FileChooserAccept.captureMedia, pure and unit-tested.
Unlike the type filter this does NOT widen on an extension the platform cannot
name — widening there would put a camera in front of a page that never asked
for one.

Permission handling is the part worth reading. ACTION_IMAGE_CAPTURE throws
SecurityException for an app that declares CAMERA without holding it, and
Amethyst declares it, so the grant has to exist before the chooser is built.
When the page set `capture` the permission is requested first — the user tapped
a control whose entire purpose is to take a photo. Without `capture` the camera
is offered only if permission is already held, so opening a document upload
never raises a camera prompt out of nowhere. A denial is not a failure: the
picker still opens, minus the camera.

A camera needs somewhere to put a full-resolution shot (EXTRA_OUTPUT; without
one it returns a thumbnail, useless as an upload), so each option gets an empty
scratch file in cacheDir behind its own FileProvider — a dedicated one with its
own authority and paths file, exposing a single subdirectory rather than the
everything the app's general-purpose provider exposes. It needs its own
subclass because the manifest merger keys providers by android:name and would
otherwise collide with the app's.

A chooser entry supplied via EXTRA_INITIAL_INTENTS is started by the system,
not by us, and the URI grant flags on it are not reliably carried across that
hop, so every resolved camera package is granted write access up front — none
of them can be ruled out before the user chooses. That grant is taken back the
moment the outcome is known, for the kept capture as well as the discarded
ones, revoked per package rather than per URI so it cannot clip this app's own
read of its own provider. Unfilled scratch files are deleted immediately; a
kept one cannot be (the page may not read it until the form is submitted) and
is swept on a later request instead.

The three Activity-owning surfaces — the full-screen browser, the full-screen
napplet/nSite sandbox, and the main-process host that serves both embedded
surfaces — now share one WebFileChooserLauncher, so filtering, multi-select,
capture and the permission flow cannot drift between them. The embedded
providers pass the input's `capture` flag across the existing Messenger
contract rather than having the main process re-derive it.

Every path still ends in exactly one call to the page's filePathCallback,
including a denied permission, a dismissed camera, and a device with no camera
app at all.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 23:00:57 +00:00
Claude 2587294c55 perf(images): share one de-dupe strategy so concurrent fetches collapse
DeDupeConcurrentRequestStrategy coordinates through a map of in-flight
fetches that the strategy instance owns. All three network-backed Coil
factories built a fresh one inside create(), i.e. one per image request,
so the map never held more than the current caller: shouldWait was always
false and the de-dupe was inert. Coil's own NetworkFetcher.Factory holds
it as a field for exactly this reason.

The cost showed up wherever a feed asks for the same URL twice at once —
an author's avatar repeated down the rows, an image carried by both the
original note and its boost, or a row scrolled off and back on before the
first fetch had written to the disk cache. Every one of those was a full
second download competing for the same link instead of a waiter that
reads the cache once the leader lands.

Hoists a single strategy into ImageLoaderSetup.setup() and threads it
through OkHttpFactory, BlossomFetcher.Factory and
ProfilePictureFetcher.Factory, so a blob reached as an https URL, as a
`blossom:` URI, or as a profile picture all coordinate on one key. The
per-create CacheStrategy.DEFAULT wrappers are hoisted alongside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYrDf5Z8TE4uivADuFwPFz
2026-08-25 22:47:59 +00:00
Vitor PamplonaandClaude Opus 5 9eed37c5f4 fix: cut the stranded-IME grace to 120ms and pin the upstream cause
The grace was 400ms, and once a window wedges its IME animation that full
400ms is paid on *every* dismissal, on every screen — which reads as the
padding lagging behind the keyboard rather than as a bug being corrected.

400ms was never protecting against anything real. `collectLatest` + `delay`
already means "no movement for X ms", because each animation frame emits a
new sample and cancels the pending wait. So the grace only has to outlast
the dead time between the target flipping and the animation's first
onProgress — not the animation. Measured over 12 real Gboard transitions on
a Pixel 8: that dead time is 17-36ms (closes 24-36, opens 17-23), and every
frame after it lands within 11ms across a ~264ms animation. 120ms clears the
worst case by ~3.3x. Set too low this degrades to a cosmetic snap, never to
wrong padding, since the target is always the truthful reading.

Also records what the workaround is working around. The defect is upstream:
a cancelled IME animation never delivers onEnd, so
`InsetsListener.runningAnimation` stays set, `onApplyWindowInsets` matches
neither branch, and `composeInsets.update()` is never called again —
`WindowInsets.ime` is dead for the life of the window. Bisected to
foundation-layout 1.4.0 (1.3.0 updated unconditionally and could not wedge),
still present in 1.12.0 and 1.13.0-alpha01. Compose's self-heal is scoped to
`SDK_INT == R`, and `WindowInsetsHolder.resetState()` only runs when the
holder's accessCount goes 0 -> 1 — which never happens in a single-Activity
app whose shell always reads insets. Filed as b/552500419.

Confirmed on-device that the bug is real and permanent underneath: with both
treatments disabled the inset pinned at 957px for 85s while the window
reported the keyboard gone, and `imeAnimationTarget` stayed correct
throughout — which is why reading it works.

ComposeImeInsetWedgeTest reproduces that upstream state deterministically in
~3s and is the repro attached to the bug. The failing half is @Ignore'd so
CI stays green; re-run it by hand after a Compose upgrade, and when it
passes, SafeImeInsets can be retired. The passing half is left enabled on
purpose: it guards the premise this fix depends on, so if a future Compose
release stopped keeping imeAnimationTarget current we would hear about it
instead of silently reading a second dead value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-25 18:19:23 -04:00
Claude 3a53e2b965 fix(browser): never narrow the file picker below what the page accepts
Two fidelity gaps in the accept handling, both of which hid files a real
browser would have let the user pick.

An extension Android's MimeTypeMap cannot name was silently dropped from the
filter. That is harmless when it is the only entry (the filter is already
`*/*`), but `accept=".png,.sqlite3"` resolved to image/png alone — the picker
then showed PNGs and no way at all to reach the .sqlite3 the page also asked
for. MimeTypeMap is a fixed table and does not cover every extension a page
might list, so one unresolvable name now widens the whole filter to `*/*`.
`accept` is a hint in HTML, never an enforced restriction, so showing more than
asked is always recoverable and showing less is not.

MODE_OPEN_FOLDER (a `webkitdirectory` input) fell through to a single-file
pick. Android has no picker that hands a WebView the contents of a directory —
ACTION_OPEN_DOCUMENT_TREE returns a tree handle, not the file URIs the page's
callback takes — so it now opens a multi-select instead. The page loses
webkitRelativePath, but the user can finish the upload rather than being
capped at one file. Resolved in one shared helper so the two Activity hosts
and the two embedded providers cannot drift on it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 21:48:24 +00:00
Vitor PamplonaandGitHub 98f09f29c0 Merge pull request #3983 from vitorpamplona/claude/trusted-lists-searchable-events-7jb8wx
Make TrustedListEvent searchable by title (NIP-50)
2026-08-25 17:34:09 -04:00
Claude f5f8f605ec feat(quartz): index Trusted List titles for NIP-50 search
The Trusted List family (30392-30395) shipped with a `title` tag and no
`SearchableEvent`, so a list published as "Podcaster" could not be found by
name -- the only way to reach one was to already know its address. Nothing
recorded that as a decision; the feature commit wired the kinds into
EventFactory and KindNames and never touched search.

Implements SearchableEvent on the TrustedListEvent base, so all four kinds
inherit it, and indexes the title alone:

    override fun indexableContent() = title() ?: ""

Nothing else in the family is human-authored prose. `metric` names a
computation and `d` identifies the list -- machine ids, kept out so a search
for a common word in one doesn't return every list that ran the same job. The
member tags are hex ids and `content` is a JSON echo of the same membership,
so indexing either would put thousands of identifiers into the full-text
index for no lookup a #p/#e/#a/#i filter doesn't already serve better. A list
with no title indexes the empty string rather than throwing, since
indexableContent() runs inside the store's insert transaction.

The kinds are already registered in EventFactory, so the store's kind
pre-filter and the reindex scan pick them up with no further wiring.

Covered by unit tests over all four kinds (including the titleless case) and
a SQLite store test asserting the title is searchable while the metric, the
list id and the membership are not. Documents the indexing rule in the
package README and adds the rows to the searchable-kinds reference table that
external search engines mirror.

Note for existing databases: rows written before this change keep their
missing FTS text until IEventStore.reindexFullTextSearch() runs (`amy store
reindex-fts` drives it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1vXqHYHWXeni4xim66vvf
2026-08-25 20:46:38 +00:00
Claude 111f3392b4 fix(browser): open a file picker for HTML file inputs
Tapping `<input type="file">` anywhere in Amethyst was a silent no-op: no
picker, no error, nothing logged. An Android WebView shows no chooser of its
own — the app must override `WebChromeClient.onShowFileChooser`, and none of
the four WebView hosts did. The base implementation returns false, and for a
target of API 21+ there is no legacy fallback, so every file upload in the
in-app browser, in nSites and in napplets was impossible.

All four hosts now open the picker:

- NappletBrowserActivity (full-screen browser) and NappletHostActivity
  (full-screen napplet/nSite sandbox) own an Activity, so they run the picker
  directly through an ActivityResultLauncher.
- NappletBrowserService and NappletHostService render an embedded surface from
  a windowless Service in the keyless `:napplet` process and have no Activity
  to launch from. They send the request's *description* — accept list,
  multi-select, title — to the main process over the existing Messenger
  contract; WebFileChooserCoordinator builds the Intent there and collects the
  result in the throwaway WebFileChooserActivity. Shipping data instead of a
  ready-made Intent keeps the sandbox able to ask the trusted process for a
  file picker and for nothing else. URI read grants are per-UID, so the picked
  `content://` URIs are readable by the WebView in `:napplet` with no
  re-granting, and allowContentAccess stays off.

Two details that decide whether this actually works in practice:

- The page's `filePathCallback` must fire on every path. WebView keeps a file
  input busy until it does, so a dropped callback (user cancelled, session torn
  down, no app to handle the Intent) leaves that input permanently dead for the
  life of the page. PendingFileChooser guarantees exactly-once delivery and
  carries a request id so a result that outlived its request is dropped rather
  than fed to whichever input is waiting now.
- Android's own FileChooserParams.createIntent() keeps only the first `accept`
  entry and drops multi-select, so `accept="image/png,image/jpeg" multiple`
  would offer PNGs only, one at a time. FileChooserAccept resolves the whole
  list — extensions included — into a type plus EXTRA_MIME_TYPES, widening to a
  family wildcard rather than narrowing below what the page asked for. It is
  pure and unit-tested in commonMain.

NappletHostService had no chrome client at all, so it gains one. Its WebView is
built from a Service context with no window token to attach a dialog to, so the
new client also dismisses JS alert/confirm/prompt instead of opting into the
default dialog handling.

Camera capture (`accept` with `capture`) and getUserMedia still fall back to
the picker; `onPermissionRequest` remains unimplemented and is left for a
separate change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FxfdHeR9Ry4qALXHT5Sf1Q
2026-08-25 20:44:50 +00:00
Vitor PamplonaandGitHub 58f144f0d1 Merge pull request #3982 from vitorpamplona/claude/picture-dialog-button-clickability-7q2whv
fix(viewer): full-screen viewer chrome — clickable buttons, PDF parity, and chrome that follows the system bars
2026-08-25 15:00:59 -04:00
Vitor PamplonaandClaude Opus 5 f4c130d7fe fix(viewer): follow the system bars instead of reserving a strip for them
The chrome reserved `systemBarsIgnoringVisibility` -- the space the bars would
occupy whether or not they were on screen. On a punch-hole device that is 142px
(54dp, not the usual 24dp: the status bar is sized to clear the camera), so the
controls sat ~64dp below the screen edge permanently, and the gap looked like a
bug because most of the time nothing was in it.

Reserving it was not gratuitous. `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE` paints
a peeked bar OVER the content and dispatches no insets at all: measured on a
Pixel-class emulator, `statusBars` reads 0 and `isVisible` reads false for the
entire time the bar is on screen, byte-identical to the hidden state. With no
signal to react to, permanently reserving the space is the only way to keep the
buttons from being covered -- which is why the previous code was written that
way, and why two attempts to shrink the inset while keeping transient bars both
failed on device.

So change the premise: ask for BEHAVIOR_DEFAULT. The bars then dispatch real
insets (statusBars 0 -> 142, navigationBars 0 -> 63, both `isVisible` flipping),
and the chrome can follow them:

- `animatedViewerChromeInset()` takes `systemBars` for the relevant edge, floors
  it at 16dp, and animates. Hidden: the row sits 16dp in (measured top=69).
  Shown: it moves clear of the bar (142). The floor is not arbitrary -- this
  display has 132px rounded corners, and a button whose left edge is x=39 needs
  y >= 38 to stay inside the visible area.
- The top display-cutout inset is dropped. Android reports it full-width, but
  the hole is `Rect(485,0,595,142)` -- 110px of 1080, dead centre. The
  edge-anchored buttons never overlap it; honouring it pushed them down by the
  height of a camera they are nowhere near. Horizontal cutout insets stay, for
  a landscape notch.

The animation snaps for 350ms after the chrome appears. Opening moves the inset
twice for reasons the user did not cause -- the window has not been told its
insets yet (they read 0, indistinguishable from "hidden"), and the immersive
effect hides the bars from a DisposableEffect that runs after composition --
and animating either played a slide on open.

Two things had to move because they were riding the same inset:

- The PDF page counter sat dead centre, which on a punch-hole device put it
  *under the camera*: measured overlap 56x36px against the lens circle. It now
  lives along the bottom edge, clear of the cutout, still screen-centred, and
  tracking the navigation bar.
- The image dialog's page dots used `navigationBarsPadding()`. That tracks the
  bar correctly but moves in a single frame, which read as a jump next to the
  top controls sliding. They now share the same animated inset.

`ViewerControlsRow`'s KDoc described the transient-bar behaviour and the
touch-swallowing it worked around. Neither is true of this code any more, so it
is rewritten rather than left to mislead.

One measurement that did NOT support this change, recorded so it is not
rediscovered as evidence: probing the reserved strip with injected taps found
12/12 points from y=8 to y=165 reaching the app, at all three button columns --
the "system swallows touches there" premise did not reproduce. But
`tappableElement` reports 142px, injected events are not a finger, and the
overlap problem above is reason enough on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-25 14:47:52 -04:00
Vitor PamplonaandGitHub 2ff7b7f199 Merge pull request #3981 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-25 11:46:53 -04:00
Claude 1c25bcb8d3 fix: repair the viewer chrome defects the audit turned up
Five fixes, all in the chrome the two viewers now share:

The PDF page swallowed its own tap. `zoomable` consumes the gesture before the
full-screen box underneath sees it, which is why the image path hangs its
toggle off `onTap` rather than a parent `clickable` -- so the page does too.
Without it the chrome auto-hid after two seconds and no tap could bring it
back, stranding the reader with no way out but the system gesture.

The auto-hide timer now races the controls going away instead of sleeping
through it: hiding and re-showing the chrome inside the two-second window used
to leave the original timer running, so it wiped controls the user had just
tapped back up. It also waits for the media to arrive (`armed`), because a PDF
that took longer than the delay to fetch rendered its first page with the
chrome already gone and nothing left to re-arm.

The save button ran on `rememberCoroutineScope` while living inside the
`AnimatedVisibility` that the auto-hide collapses two seconds later -- so the
chrome fading out cancelled the download it had just started, leaving no file
and no error. It now uses the view model's scope and the application context,
matching the download row in `ShareMediaAction`.

The page counter no longer slides sideways when the buttons fade: it sits in
its own centred row, anchored to the screen rather than to the space the
asymmetric button groups leave behind.

The back button also survives the loading and unreadable-PDF states, which had
inherited hidden system bars from the immersive effect without keeping a way
back out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PQscXLTMHXHwYyKh4xcKC
2026-08-25 15:19:03 +00:00
vitorpamplonaandgithub-actions[bot] 541b6116d7 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-25 15:14:41 +00:00
Vitor PamplonaandGitHub 3e383e77d7 Merge pull request #3980 from vitorpamplona/claude/pull-notification-card-expand-mg23fn
Make notification details opt-in via action button
2026-08-25 11:09:50 -04:00
Vitor PamplonaandGitHub c16e3f3c69 Merge pull request #3979 from vitorpamplona/claude/remove-ai-helper-post-zicyj2
feat: remove the AI writing helper from the post composer
2026-08-25 11:09:30 -04:00
Vitor PamplonaandGitHub 28e8af2564 Merge pull request #3978 from vitorpamplona/fix/homebrew-formulae
fix(homebrew): lint both formulae, and rename geode to geode-relay
2026-08-24 11:43:20 -04:00
Vitor PamplonaandClaude Opus 5 4ef3d12adf fix(homebrew): rename the geode formula to geode-relay to clear the name collision
`geode` can never be a homebrew-core formula: `formula_renames.json` maps
"geode" -> "apache-geode", so the token is permanently reserved and
`brew info --formula geode` resolves to Apache Geode. The previous commit
recorded that as a blocker; this removes it.

- `geode/packaging/homebrew/geode.rb` -> `geode-relay.rb`, `class Geode` ->
  `class GeodeRelay` (Homebrew requires the class to track the filename).
- `bump-homebrew-geode-formula.yml` follows the path, and the three sibling
  workflows' header comments now name the formula correctly.
- `geode/README.md` points at the new file and the new tap install line.

**The binary is still `geode`.** Users type `geode`, not `geode-relay`. That is
safe rather than sloppy: apache-geode installs `gfsh`, so nothing collides on
PATH. Formula token and binary name differ deliberately, which the header now
states so nobody "fixes" it later.

Verified: `brew style` clean on the renamed file (it validates class-vs-filename
agreement, so this catches a bad rename), `brew info --formula geode-relay`
resolves to this relay rather than Apache Geode, `ruby -c` passes, and replaying
the bump workflow's `sed` still changes exactly the two intended lines.

`geode/plans/2026-07-24-geode-release.md` is left alone — a dated design doc,
not live configuration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-24 11:32:15 -04:00
Vitor PamplonaandClaude Opus 5 e184db69c7 fix(homebrew): correct a style violation in both formulae, and record what blocks each submission
Checked while asking whether the amethyst-nostr cask's review feedback applied
to the two formulae. It does not — but running homebrew-core's own linter over
them turned up a real defect neither had been checked for.

**The style violation, in both files.** `brew style` flags

    Homebrew/FormulaPathMethods: Use formula_opt_prefix("openjdk")
      instead of Formula["openjdk"].opt_prefix

on the `write_env_script` line. Fixed in `amy.rb` and `geode.rb`; both now
report no offenses. It would have been raised on submission.

**A duplicated sentence.** amy.rb opened with "Reference Homebrew formula for
`amy`, the Amethyst CLI." twice, once on line 1 and again on line 3.

**Why they must NOT be made to match the cask.** The cask lost its `livecheck`
block and inline comments on review, so the obvious next step is to do the same
here. That would be wrong, and the header now says so with the evidence:
homebrew-cask and homebrew-core differ. Sampling the live core tap, 127 of 300
formulae with GitHub-release URLs declare `livecheck` (62 using
`:github_latest`), and 109 of 200 carry indented inline comments. `livecheck`
is load-bearing in core — it is what lets BrewTestBot open version-bump PRs, so
stripping it would disable exactly the automation the block exists for.

**geode cannot be submitted under that name.** homebrew-core's
`formula_renames.json` maps "geode" -> "apache-geode", so the token is
permanently reserved and `brew info --formula geode` resolves to Apache Geode.
Submitting needs a different token (`geode-relay`, `amethyst-geode`) plus a
matching change to bump-homebrew-geode-formula.yml. Recorded as a blocker in
the header rather than discovered at PR time.

**amy is unblocked but not ready.** The one-open-AI-PR limit that gated it is
cleared now the cask has merged; the ~70 MB bundle from `:commons` pulling
Compose/Skiko onto the CLI classpath is still the likely review objection, and
`brew audit --new --formula` has not been run end to end.

Verified the enlarged headers cannot confuse the bump workflows: both anchor on
`^  url ` / `^  sha256 ` at a two-space indent, each matches exactly once, and
replaying their `sed` changes those two lines only. `ruby -c` passes on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-24 11:14:36 -04:00
Vitor PamplonaandGitHub 7f0e1f2f90 Merge pull request #3974 from vitorpamplona/docs/sync-cask-reference
docs(homebrew): sync the reference cask to what actually merged upstream
2026-08-24 10:01:10 -04:00
Vitor PamplonaandGitHub 6ca19eab90 Merge pull request #3977 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-24 10:00:57 -04:00
vitorpamplonaandgithub-actions[bot] 36a74ae10c chore: sync Crowdin translations and seed translator npub placeholders 2026-08-24 13:22:46 +00:00
Vitor PamplonaandGitHub 78acc61318 Merge pull request #3976 from nrobi144/feat/desktop-live-media
feat(desktop): NIP-53 live streaming — consume & discover
2026-08-24 09:19:34 -04:00
nrobi144andClaude Opus 4.8 1190b55dbf fix(desktop): address code-review findings on live media
Blocker + high-severity fixes from multi-agent review:
- liveNowForBar: route through LiveActivitySorting.sortDescending so the
  comparator reads a snapshotted rank, not the live channel.info var — the
  previous inline comparator could hit TimSort's "contract violation" crash
  when a 30311 was swapped from a relay thread mid-sort.
- LiveWatchScreen: stop playback (GlobalMediaPlayer.stopVideo) on close via
  DisposableEffect — audio/decoding was leaking after the overlay closed.
- LiveNowBar: take the follow Set (stable identity) instead of a fresh .toList()
  per recompose, so its subscription + snapshot don't churn.
- Chat auto-scroll keys on the newest message id, not size (kept working once
  the 500-cap prune holds size flat).
- Remove the dead profile-nav affordance in the watch header/chat (was wired to
  a no-op); real profile nav from the overlay is a follow-up.
- generateSubId appends a per-process atomic counter so same-millisecond subs
  can't collide (one unsubscribe tearing down another's REQ).
- stopVideo also cancels the in-flight open job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 81d5add807 fix(desktop): make live playback start reliably + log failures
"Sometimes lives don't start" had no trace because kdroidFilter reports
playback state only via Compose state, never the log.

- GlobalMediaPlayer.playVideo now cancels any in-flight openUri before starting
  a new one, so two rapid track switches can't interleave openUri on the single
  shared engine (the race that left the surface stuck/black).
- Reuse the engine only when it's on the same URL AND had no error; a prior
  transient error (dead segment / 403 / just-went-live) now re-opens instead of
  showing a stuck surface.
- Log playVideo (REUSE/OPEN), playback errors (url + reason), and each watch
  open (address, status, streaming/recording URL) so failures are diagnosable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 e4adae05de feat(desktop): live-mode video controls in the watch screen
Addresses watch-screen player feedback:
- Hide the seek slider for live streams (the HLS is non-seekable, so a scrubber
  was inert/misleading). VOD recordings keep the normal seekable bar.
- Replace the "time / duration" readout with a single LIVE pill + one elapsed
  timer for live streams (no fixed end to show).
- Watch top bar: more top margin, less start margin (tighter to the X).

DesktopVideoPlayer/VideoControls gain an isLive flag; LiveWatchScreen sets it
from the 30311 status (live vs recording).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 c3f7bf52e9 feat(desktop): live watch screen (player + chat) + open-from-anywhere
Clicking a live stream (Discover card or the per-column bar) now opens a
full-window watch overlay: HLS player left, live chat right.

- LiveWatchController: app-level singleton holding the watched stream address;
  overlay rendered once at the composition root in Main.kt (mirrors
  GlobalFullscreenOverlay), so any live surface opens it without threading a
  callback through the deck/single-pane tree.
- LiveWatchScreen: DesktopVideoPlayer for the HLS stream + header (LIVE badge,
  host, viewer count, summary) + reactive kind-1311 chat (reverseLayout,
  auto-scroll at bottom) + composer that signs & publishes a 1311 with the
  stream's root `a` tag.
- FeedScreen/DiscoverScreen onOpenLive defaults now open the overlay.

UI polish from testing feedback: Discover "LIVE NOW" shows only genuinely-live
streams (no planned/ended), capped at 2 rows so "From the pack" stays visible;
feed live bar gets rounded inset + breathing room.

Follow-ups: live-vs-VOD seek suppression + stall watchdog, zap-the-stream,
mute/block chat filtering, online-probe downgrade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 daa6022adc docs(desktop): make live-media testing sheet build-state aware
Tags each section LIVE / PARTIAL / PENDING so it's usable against the current
branch, with concrete step→expected tables for the testable Discover + live-bar
surfaces and a "test right now" quick path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 18d28cf8ed feat(desktop): per-column "live now" bar on Following/Global feeds
Pins a compact bar at the top of the Following and Global feed columns showing
the single most-watched live host in that column's audience, with a "+N live ›"
dropdown for the rest. Hidden when nobody in scope is live.

- LiveNowBar: own 30311 subscription scoped to the column (follows for Following,
  global for Global); reads the shared liveNowForBar ranking (viewers-first);
  click opens the watch screen via onOpenLive.
- FeedScreen: pinned above the feed LazyColumn for FOLLOWING/GLOBAL modes only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:38 +03:00
nrobi144andClaude Opus 4.8 9c8c11a0d7 feat(desktop): Discover "Live now" section with ranking + search
Surfaces NIP-53 live streams in Discover: subscribes to kind 30311 while
visible, ranks via the shared LiveActivitySorting (live > planned > ended,
follow-participation, viewers), and filters client-side by title/host/hashtag.

- FilterBuilders.liveActivities / liveActivityChat + createLiveActivitiesSubscription
  / createLiveChatSubscription.
- LiveActivityRanking: maps channels to the shared snapshot rank; liveNowForBar
  (viewers-ranked) prepared for the per-column bar.
- LivesSection: subscription + search box + responsive card grid (thumbnail,
  LIVE/scheduled badge, host, viewer count). Card click -> onOpenLive(address)
  (wired to the watch screen in the next commit).

Online-probe downgrade (OnlineChecker) still to be wired; ranks treat all
status=live as online for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:37 +03:00
nrobi144andClaude Opus 4.8 16aae86791 feat(desktop): route NIP-53 30311/1311 into DesktopLocalCache
Stands up Desktop's first channel cache (liveChatChannels) so live streams
and their chat have somewhere to live (getAnyChannel returned null before).

- getOrCreateLiveActivityChannel + LiveActivitiesChannel per stream address.
- Route kind 30311: replaceable supersession in addressableNotes, attach info
  to the channel, bump liveActivityVersion (drives Lives grid / live bar).
- Route kind 1311: attach to its stream channel by root `a` tag; cap retained
  chat at 500 via pruneOldMessages (Desktop had no pruning).
- Skip 1311 write-through to the local relay store (avoid unbounded chat replay
  on next launch); 30311s still hydrate.
- getAnyChannel resolves a 1311/30311 note back to its channel.
- snapshotLiveActivities() for reactive recomputation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:41:37 +03:00
nrobi144andClaude Opus 4.8 6614b0bd60 feat(commons): add NIP-53 LiveActivitySorting + plan/testing docs
Foundation for Desktop Live Media (NIP-53) consume+discover feature.

- LiveActivitySorting: pure, CLI-safe status-order / freshness / ranking
  helpers with a snapshot-map sort API so Android + Desktop order live
  streams identically and no comparator reads volatile state mid-sort
  (avoids the TimSort "contract violation" the Android filters guard against).
- Unit tests (green): status ordering, offline-live downgrade, 15-min
  live-bar freshness, overdue-planned detection, multi-key sort + tiebreaks,
  and stability under concurrent key mutation.
- Deepened plan (7 review agents) + brainstorm + full manual testing sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 11:40:09 +03:00
Vitor PamplonaandClaude Opus 5 b1a03a083d docs(homebrew): sync the reference cask to what actually merged upstream
The cask went live in Homebrew/homebrew-cask on 2026-08-24. A maintainer
removed three things during review that this reference copy still carried, so
the file now documents a shape Homebrew rejected:

- the `livecheck do url :url; strategy :github_latest end` block, which is
  redundant — Homebrew infers the strategy from a GitHub release URL
- the inline `conflicts_with` comment
- the inline `zap` rationale comment

The body below the header is now byte-identical to upstream, so `diff`-ing the
two is meaningful again.

The removed rationale was worth keeping, just not upstream, so it moves into
the header — which `scripts/bump-winget.sh`-style stripping never applies here
anyway, because this file is only ever read, never copied. Notably the `zap`
paths, re-derived from source rather than trusted from the old comment:
`AccountManager.kt` for `~/.amethyst` (accounts and KEYS), `DesktopTorManager.kt`
for the Application Support path, and `DesktopImageLoaderSetup.kt` whose macOS
`cacheDir()` branch resolves to `~/Library/Caches`. Also why the shared Java
prefs plist is deliberately excluded: `java.util.prefs` writes every Java app's
preferences into that one file.

The header also corrects a scope claim. It implied this file is what ships;
it is not. `scripts/bump-homebrew-cask.sh` bumps upstream through
`brew bump-cask-pr`, which edits the upstream cask in place and only reads
version + sha256 from here.

Verified the enlarged header cannot confuse either bumper: both anchor on the
two-space indent (`^  version "` / `^  sha256 "`), each matches exactly once,
and replaying the workflow's `sed` against this file changes those two lines
and nothing else. `ruby -c` passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-24 00:39:59 -04:00
Claude 9dc209cdfe feat: align the PDF viewer chrome with the image viewer
Both viewers open the same way -- tap a media card in a feed -- so the
difference in how their chrome behaved was arbitrary from the user's side, and
a PDF is a reading surface where controls parked over the page cost more than
they do over a photo.

The PDF viewer now goes immersive, toggles its controls on tap, auto-hides
them, anchors the share sheet to its button instead of the window root, and
gains the save-to-gallery button the image viewer already offered for PDFs.

The page counter is wayfinding rather than a control, so it does not simply
vanish with the buttons: it also flashes on its own for a moment after every
page turn, which is why the shared row holds a button's height whatever it
carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PQscXLTMHXHwYyKh4xcKC
2026-08-23 21:26:41 +00:00
Claude a82035c601 fix: keep the full-screen viewer controls out of the hidden system-bar strip
The zoomable dialog goes immersive, which drops the status-bar inset to zero
and lands the back/share/save buttons against the top edge of the screen. That
strip stays owned by the system while BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE is
set -- it is the area watching for the swipe that peeks the bars back -- so
touches there never reach the buttons and only their lower halves respond.
There is no API to turn that region off, so reserve the space the bars would
occupy even while they are hidden (systemBarsIgnoringVisibility, unioned with
the display cutout for notched devices in landscape). As a bonus the controls
no longer jump when the user swipes the bars back in.

Extracts the chrome the PDF viewer is about to share: the immersive effect, the
auto-hiding visibility state (which collapses the dialog's two duplicate
auto-hide effects into one), the control row, and the three buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PQscXLTMHXHwYyKh4xcKC
2026-08-23 21:26:24 +00:00
Claude a5e2aae960 feat: remove the AI writing helper from the post composer
The on-device AI writing assistant (ML Kit GenAI proofreading/rewriting via
Gemini Nano) proposed tone rewrites under the text field on the new post,
reply and quote screens. Removes the feature end to end:

- deletes the WritingAssistant abstraction and its play (ML Kit) and fdroid
  (no-op) implementations, the mock, and the AiWritingHelp panel/button
- strips the AI state, precompute job and lifecycle wiring out of
  ShortNotePostViewModel and ShortNotePostScreen
- drops the genai-proofreading, genai-prompt and genai-rewriting
  dependencies, which nothing else used

The composer was the only reader of the "Propose text improvements"
setting, so that goes too: the Compose Settings tile, the
automaticallyProposeAiImprovements field in UiSettings/UiSettingsFlow, the
ui.propose_ai_improvements DataStore key, and the ai_writing_*/ai_tone_*
strings in every locale.

The ML Kit image-description service that backs alt-text suggestions lives
in the same package and is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvNaegVegSy5tZ4y3M7B4b
2026-08-23 19:32:33 +00:00
Claude 41fff6b9b3 fix: keep the always-on notification card at the bare relay count
Android auto-expands a notification when it is the only one in the shade,
and offers no way to opt out. The per-job relay breakdown was attached as a
BigTextStyle on every post, so for anyone whose shade was otherwise empty
the full list of what each relay is doing *was* the default view — the
opposite of the "expanded only" intent it was written with.

The breakdown is now opt-in: the notification is built with no expanded
style at all, so the card is always just "Connected to X relays", and a
"Show details" action posts it back with the breakdown plus a "Hide
details" action that returns to the bare count. As a side effect the
per-relay request walk only runs while the details are on screen, instead
of once a second whether or not anyone is looking.
2026-08-23 19:28:03 +00:00
David KasparandGitHub dad7fccaf2 Merge pull request #3972 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-23 06:15:06 +02:00
vitorpamplonaandgithub-actions[bot] c4babf0b6e chore: sync Crowdin translations and seed translator npub placeholders 2026-08-23 01:54:17 +00:00
Vitor PamplonaandGitHub 5d0802661b Merge pull request #3973 from vitorpamplona/docs/release-doc-corrections
docs: correct three release-doc claims the v1.14.0 release disproved
2026-08-22 21:51:29 -04:00
Vitor PamplonaandClaude Opus 5 1aeb194368 docs: correct three release-doc claims the v1.14.0 release disproved
All three were found by following the docs during the v1.14.0 release and
hitting reality instead.

1. The Homebrew cask bootstrap command cannot work. BUILDING.md told the
   maintainer to run `brew bump-cask-pr amethyst-nostr` for the *one-time
   initial PR*, but that subcommand updates an existing cask. Against a name
   not in the tap it fails outright:

     Error: Cask 'amethyst-nostr' is unavailable: No Cask with this name exists.

   Verified by dry-run. A first submission is a new-cask PR — `brew create
   --cask`, `brew audit --new --cask`, then a hand-opened PR — so the section
   now documents that flow, notes the notarized+stapled precondition Homebrew
   enforces, and says where `bump-cask-pr` *does* apply (the later bumps).
   This is plausibly why the bootstrap never happened.

2. RELEASE_OPS claimed the release holds 31 assets. It holds 47. The windows-
   arm64 and linux-arm64 legs added this cycle took desktop 8 -> 14, amy 5 ->
   10 and geode 5 -> 10. BUILDING.md had already been updated; RELEASE_OPS had
   not, in two places (the § 2 breakdown and the § 6 checklist). A maintainer
   following it would read a correct release as broken. The breakdown now
   points at BUILDING.md, which carries the per-leg detail and the reasons for
   the two gaps, rather than restating it and drifting again. Also drops the
   geode Docker image from the count — it goes to the registry, not the
   release.

3. RELEASE_OPS § 3 said to verify "Intel + ARM DMGs are both present" while
   § 2 and § 6 said macOS is arm64-only. Only the arm64 DMG exists, so § 3 was
   the wrong one.

Also replaces the "neither has ever been submitted upstream" line with a
per-channel table: Winget is now submitted (microsoft/winget-pkgs#422752,
pending CLA), both Homebrew packages are not. Since that is a snapshot that
will age, it carries the one-call check that answers it live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 20:31:53 -04:00
Claude 416cd32eb3 fix: recover IME padding when Compose's insets listener freezes
After a while, the back gesture would dismiss the keyboard but leave a
keyboard-sized gap behind it, app-wide and permanently — only killing the
activity cleared it.

Compose keeps one InsetsListener per window in WindowInsetsHolder. It sets
runningAnimation in onPrepare and clears it only in onEnd, plus an
onApplyWindowInsets fallback gated to API 30. While that flag is set,
onApplyWindowInsets deliberately skips update(insets) and waits for
onProgress instead. An IME animation that is prepared and then cancelled
without ever delivering onEnd — which the back gesture can cause, since the
predictive-back window animation races the IME's own close animation —
leaves the flag set for good, and every WindowInsets in the window freezes
at its last animated value.

Nothing recovers from that on its own: the listener is only reset when the
holder's access count goes 0 -> 1, and the app reads WindowInsets.ime
continuously, so the count never reaches zero while the activity lives.

Nav's ImeSettler already prevents this for in-app navigation, but the
system's own back gesture never reaches Nav — the first back press with a
keyboard up is consumed by the IME — so prevention alone can't close it.

The escape hatch is that onApplyWindowInsets publishes imeAnimationTarget
before it consults that flag, so the target keeps tracking reality while the
animated value is frozen. SafeImeInsets watches both: when they disagree and
then stop moving for longer than any real animation frame gap, the animated
value is stale and the target is the truth. That corrects the freeze in both
directions — a gap left behind by a keyboard that is gone, and missing
padding under a keyboard that has come back.

Modifier.imePadding() is replaced with imePaddingSafe() across the app, and
keyboardAsState(), rememberImeSettler() and DisappearingScaffold's nav-bar
subtraction now read the corrected inset too — the stuck reading also left
the bottom navigation bar hidden and made every navigation burn the full
settle timeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bqpAeyLAxHzw5XsnRUtjD
2026-08-22 23:08:21 +00:00
mstrofnone 94976dbe74 fix(desktop): surface macOS notification-permission OS errors + timeout the request
The "Enable OS notifications" button still fails on macOS even after
e9475dd0 + the auto-enable follow-up, and the failure mode gives the
user nothing to act on:

1. Nucleus's requestAuthorization callback carries the OS error string
   (UNErrorDomain), but the dispatcher discarded it ({ granted, _ -> })
   and mapped every non-grant to PermissionState.Denied. The settings UI
   then showed "Enable in System Settings → Notifications → Amethyst" —
   a dead end when macOS refused the request outright ("Notifications
   are not allowed for this application"), because a refused app never
   gets a System Settings entry.

2. On recent macOS the permission prompt is an auto-dismissing banner.
   If the user misses it, UNUserNotificationCenter may never invoke the
   completion handler, leaving requestPermission()'s
   suspendCancellableCoroutine parked forever and the UI stuck on
   "Requesting…".

Fixes:

- requestPermission() now captures the OS error string and exposes it
  via NotificationDispatcher.lastRequestError (new interface property,
  null-defaulted so other implementations are unaffected).
- The request is wrapped in withTimeoutOrNull(90s); on timeout the
  coroutine returns, the spinner clears, and lastRequestError tells the
  user to watch for the banner and retry.
- The Denied branch of NotificationSettingsScreen gains an "Ask again"
  button (re-request re-surfaces the banner) and both branches render
  the raw OS error when one is present.
- sendMac() now uses Nucleus's add(request, callback) overload and
  reports SendResult.Failed with the OS error instead of unconditionally
  returning Delivered for a request the notification center may have
  rejected. Timeout without an ack still counts as delivered (the
  request was queued).

Reproduced the hang + the silent-error path on macOS 26.4 with a
minimal Nucleus harness: first requestAuthorization call from a
freshly-installed bundle never fired its callback (30s timeout),
subsequent calls returned granted=false with "Notifications are not
allowed for this application" — neither observable from the Amethyst
UI before this change.
2026-08-23 08:35:14 +10:00
Vitor PamplonaandGitHub 10149d7150 Merge pull request #3968 from vitorpamplona/chore/bump-amy-formula-v1.14.0
chore: sync amy Homebrew formula to v1.14.0
2026-08-22 14:05:56 -04:00
Vitor PamplonaandGitHub 4ed50f5ec0 Merge pull request #3971 from vitorpamplona/chore/bump-winget-manifest-v1.14.0
chore: sync winget manifests to v1.14.0
2026-08-22 14:05:50 -04:00
Vitor PamplonaandGitHub 853d0c8be7 Merge pull request #3970 from vitorpamplona/chore/bump-amethyst-cask-v1.14.0
chore: sync amethyst-nostr cask to v1.14.0
2026-08-22 14:05:44 -04:00
Vitor PamplonaandGitHub 70e57abc08 Merge pull request #3969 from vitorpamplona/chore/bump-geode-formula-v1.14.0
chore: sync geode Homebrew formula to v1.14.0
2026-08-22 14:05:35 -04:00
vitorpamplonaandgithub-actions[bot] 9ab8af72dc chore: sync winget manifests to v1.14.0 2026-08-22 17:54:52 +00:00
vitorpamplonaandgithub-actions[bot] 2b25364c2c chore: sync amethyst-nostr cask to v1.14.0 2026-08-22 17:54:38 +00:00
vitorpamplonaandgithub-actions[bot] 00597751e4 chore: sync geode Homebrew formula to v1.14.0 2026-08-22 17:54:31 +00:00
vitorpamplonaandgithub-actions[bot] 7404f6db7b chore: sync amy Homebrew formula to v1.14.0 2026-08-22 17:54:29 +00:00
Vitor PamplonaandGitHub e1ba25df55 Merge pull request #3967 from vitorpamplona/chore/release-1.14.0
chore(release): v1.14.0 changelog and version bump
2026-08-22 13:13:30 -04:00
Vitor PamplonaandClaude Opus 5 68c1e4d1fe docs(changelog): credit the translators Crowdin recorded without a language
scripts/translators.sh buckets contributors whose sinceLastTag entry carries
an empty languages list under "(unknown language)". Dropping that bucket, as
the first draft did, silently uncredited 18 people who did translate this
cycle — the language is what is missing, not the contribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 13:04:48 -04:00
Vitor PamplonaandClaude Opus 5 43f0fcf038 chore(release): bump to 1.14.0
app 1.13.1 -> 1.14.0, appCode 456 -> 457. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven
version and geode's RelayInfo.VERSION. RELEASE_NOTES_ID is repointed, which
RELEASE_OPS notes happens on x.y.0 releases and not on patches.

Also syncs the docs that state a version rather than illustrate one. quartz
and geode both read libs.versions.app, so their install snippets were stale
claims about what Maven Central and the release assets actually carry:

- README.md, quartz-integration SKILL.md and its gradle-setup.md reference
  -> quartz 1.14.0
- geode/README.md install commands -> geode 1.14.0

The Homebrew/Winget "not bootstrapped" notes in RELEASE_OPS.md and
BUILDING.md were stamped v1.13.1. Re-verified before moving the stamp rather
than re-stamping blind: Homebrew/homebrew-cask has no amethyst-nostr.rb and
microsoft/winget-pkgs has no VitorPamplona/Amethyst, both still 404, so the
claim holds. The bump-script invocations beside them named v1.13.2, a tag
that never existed, and are now copy-pasteable.

Left alone deliberately: everything under */packaging/ and translators.json's
tag, which the bump workflows and the Crowdin job write after the tag exists
(bumping by hand would commit wrong hashes and a dead URL); BUILDING.md's
asset-name and git-checkout samples, which are illustrations; and the
"invisible until v1.13.1" line in RELEASE_OPS.md, which is history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 13:04:02 -04:00
Vitor PamplonaandClaude Opus 5 2d14e85bf3 docs(changelog): add v1.14.0 release notes
Assembled from everything in v1.13.1..main: 158 PR merges (124 substantive,
34 Crowdin syncs), 5 ngit proposals merged outside the GitHub PR flow, and
15 direct-to-main commits.

Coverage was verified in both directions rather than assumed — every PR
resolves to a bullet that literally appears in the file, and every bullet is
claimed by some commit. That audit caught five defects in the first draft:

- A bullet crediting BUD-01 Blossom read-auth, which is PR #3789 and shipped
  in v1.13.1 — the GitHub search window overlapped the tag date.
- #3819's Concord fix (entities pinned across a Refounding) missing; only the
  log-quieting half of that PR had been written up.
- #3855's per-host strike-out / unreachability tracking missing.
- #3818's re-probe on the local Blossom toggle missing.
- The mention notification icon reshape (6fac964d), which has no PR at all and
  so was invisible to a PR-only sweep.

Six PRs are deliberately omitted, each for a stated reason: #3801/#3802/#3805
sync packaging to v1.13.1 and belong to the previous release, #3845 is
test-only, #3878 is behavior-preserving renames, #3956 is a CI lint fix.

Also records npubs for nrobi144, dmnyc, dskvr, alexgleason and mstrofnone in
github.json, and corrects mstrofnone's key in TEMPLATE.md. The five already
published changelogs keep the key they shipped with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gs2gi3sZfQ7SHrVm2njLMw
2026-08-22 13:04:02 -04:00
David KasparandGitHub d585f0436d Merge pull request #3966 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-22 18:03:01 +02:00
vitorpamplonaandgithub-actions[bot] f73e8d2f0a chore: sync Crowdin translations and seed translator npub placeholders 2026-08-22 16:01:47 +00:00
Vitor PamplonaandGitHub 01892935c2 Merge pull request #3965 from vitorpamplona/claude/update-all-dependencies-2h0t1y
Upgrade dependencies: Firebase, Jackson, OkHttp, Gradle, and others
2026-08-22 11:58:52 -04:00
Claude 2c9f70e9e8 chore(deps): update dependencies and the Gradle wrapper
Sweep every dependency coordinate in the version catalog, the hardcoded
ones in the module build scripts, the Gradle wrapper and the GitHub
Actions against their upstream metadata, and take the newest release
that keeps each pin on the same stability channel it was already on.

Version catalog:
  firebaseBom           34.17.0     -> 34.18.0
  jacksonModuleKotlin   2.22.1      -> 2.22.2
  okhttp                5.4.0       -> 5.5.0
  sonarqubeGradlePlugin 7.3.1.8318  -> 7.4.0.8496
  spotless              8.9.0       -> 8.10.0
  vico-charts-compose   3.2.3       -> 3.3.0

vico had been held back at 3.2.3 because the only newer builds were the
3.3.0-next prereleases; 3.3.0 has since shipped stable, so the pin moves
without leaving the stable channel.

sonarqube-gradle-plugin is published on the Gradle plugin portal, not
Maven Central — the `3.3` that Central still serves for that coordinate
is a stale line unrelated to the current 7.x releases. It stays LGPL-3.0
and build-time only, gated behind the local.properties sonar opt-in in
the root build script, so nothing new enters a shipped artifact.

Gradle wrapper 9.7.0 -> 9.7.1, with distributionSha256Sum updated to the
checksum published for 9.7.1.

Already current, so untouched: every other catalog ref (AGP, Kotlin,
compose-multiplatform, the compose BOM, media3, coil, ktor, secp256k1,
camera, sqlite, ...), the hardcoded coordinates in the module build
scripts (tink-android 1.23.0, tracing-perfetto 1.0.1, opus-java 1.1.1,
jna 5.19.1, nucleus.notification-* 1.15.7, kotlinx-crypto-* 0.0.4), and
every GitHub Action — the floating major tags are all on their newest
major and setup-java is already pinned at v5.7.0, the newest release.

Left alone on purpose:
  - appfunctions stays at 1.0.0-alpha09: `appfunctions` and
    `appfunctions-compiler` publish alpha10 but `appfunctions-service`
    still stops at alpha09, and all three share the ref.
  - composeRuntimeAnnotation stays at 1.12.0 because it has to track
    whatever the compose BOM pins, and 2026.08.00 is still the newest.
  - The org.jetbrains.compose.material3 pin stays at 1.9.0 — everything
    above it is a 1.10/1.11/1.12 alpha.
  - The @moq/* npm pins in nestsClient/tests/browser-interop, which the
    directory's REV file ties to the moq-relay git rev in
    hang-interop/REV; bumping the 0.2.x (moq-lite-03) line is a
    wire-protocol change that has to move with the Rust relay pin.
  - quartz/tools/tsmls-vector-gen stays on ts-mls 2.0.0-rc.10. That
    generator emits the committed MLS KAT vector with fresh randomness
    each run, so moving it means regenerating and re-verifying the
    fixture, not a routine version bump. Its @noble/* deps already float
    on caret ranges.
  - The Docker base images (eclipse-temurin:21, rustc 1.95.0) are
    toolchain pins tied to the JDK target and the moq-relay pin.

No new dependencies are introduced, so no new licenses enter the build.

Verified: :amethyst:compilePlayDebugKotlin, :desktopApp/:cli/:geode/
:relayBench compileKotlin and spotlessCheck all pass on Gradle 9.7.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnezWP7LpA6amBCVxtGQ5b
2026-08-22 14:30:17 +00:00
Vitor PamplonaandGitHub 0e8a235bce Merge pull request #3964 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-21 18:05:19 -04:00
vitorpamplonaandgithub-actions[bot] 0d8d994609 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-21 21:23:12 +00:00
Vitor PamplonaandGitHub 4c524fd81b Merge pull request #3963 from vitorpamplona/claude/opengraph-preview-rendering-na1cul
Optimize MetaTagsParser: fix quote tracking in comments/scripts
2026-08-21 17:20:24 -04:00
Claude a7209a70b6 test: cover the meta-tag variants the suite never saw, and fix title/textarea
Reviewing what the tests actually reach turned up one live defect and a suite
that mostly did not run.

The defect is the comment bug again, in the elements whose content is text
rather than markup. `<title>5 < 6, that's math</title>` is ordinary HTML: the
`<` opened a phantom tag, the apostrophe opened a phantom attribute value, and
every tag after it -- the whole og: block -- was swallowed. Same for
`<textarea>`, and a `<meta>` written inside title text was parsed as a real one
and won over the page's own. Title and textarea now skip to their end tag like
script and style; the three tests for it fail without that change.

The suite: MetaTagsParserTest lived in `androidDeviceTest`, so every attribute
shape it covers -- unquoted values, single quotes, valueless attributes,
duplicate-attribute rejection, `</head>` inside a value -- was unguarded in CI.
Nothing in it is Android-specific, so it moves to commonTest. OpenGraphParser
and HtmlCharsetParser had no tests at all.

New coverage, all of it variants nothing exercised before:

- end of scan: `</HEAD>`, `</head >`, `</head>` inside an attribute value, and
  a document with no `</head>` at all.
- truncation: a body cut after a `/`, inside a quoted value, and right after a
  `<` -- the first is the crash the previous commit guarded and never pinned.
- tag shapes: uppercase `<META>`, `<meta/>`, `/>` inside a quoted value,
  `<![CDATA[]]>`, and meta tags inside `<noscript>` (which must still be read).
- attributes: a trailing valueless attribute, a value spanning lines, unknown
  attributes.
- character references: query-string `&` left intact (og:image URLs are full of
  them), astral references (`&#128512;`), unknown references left alone.
- laziness: the sequence stops when the consumer does.
- OpenGraphParser: property / name / itemprop sources, twitter and plain-name
  fallbacks, and that a field is taken in document order -- so a plain
  `<meta name="description">` above the og: one wins. That is why the
  brainstorm.world card shows the site description; pinned, not changed.
- HtmlCharsetParser / HtmlParser: charset attribute, http-equiv content-type,
  the UTF-8 default, the 1 KB sniff window, and the response-charset > BOM >
  document-declaration precedence.

49 tests in the package, from 8 that ran.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b
2026-08-21 20:16:12 +00:00
Claude 39797b2191 perf: drop the regex and the per-tag allocations from the meta scan
The tag-name check was a Regex match over a freshly cut substring, run for
every `<` in the document. Both are gone: names are compared in place against
the only four that matter (meta, head, script, style), ASCII-case-folded with
`code or 0x20`, so a non-meta tag now costs zero allocations -- no substring,
no Matcher, no RawTag. nextTag() reports a TagKind and leaves the attribute
span as two indices; only a real `<meta>` gets read, and parseAttrs() reads
that span straight out of the document instead of a copy of it.

The rest of the scan got the same treatment:

- `indexOf('<')` / `indexOf('>')` / `indexOf("-->")` instead of char-at-a-time
  predicate loops -- these are intrinsified and vectorized on the JVM.
- `Set<Char>.contains` for the attribute character classes boxed a Char per
  character of every meta tag; they are `when` branches now.
- one Pair, one Result and one lambda per attribute (`runCatching { add(Pair) }`)
  became a boolean-returning add -- a duplicate attribute no longer throws.
- `toImmutableMap()` rebuilt a persistent map for every meta tag; the Attrs
  builder is discarded at freeze(), so its own map is already private.
- the character-reference Regex only runs on values that contain an `&`.

Measured on a comment-free head, where this and the previous implementation
do identical work (same JVM, both warmed, `plainHead` corpus):

  1.1 KB head,  10 metas   12.5 us -> 5.1 us   ( 88 -> 215 MB/s)
   28 KB head, 204 metas    267 us -> 116 us   (105 -> 242 MB/s)

MetaTagsParserBenchmark joins the prodbench suite as the guard, on corpora
shaped like a Vite SPA head and a CMS head buried in analytics scripts: any
site we preview picks the input, so the scan has to stay linear in it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b
2026-08-21 19:58:08 +00:00
Claude b3d4cd924b fix: stop HTML comments and script bodies from hiding og: meta tags
MetaTagsParser's scanner treated `<!-- ... -->` like an element and ran the
attribute quote tracker over its text. A comment holding an odd number of
quote characters -- an apostrophe in "we don't" is enough -- left the scanner
inside a phantom quoted attribute value, so every tag that followed was
swallowed until the next matching quote character.

brainstorm.world hits this: its head opens with a theme comment containing
`don't`, `'dark'` and `'system'` (five apostrophes), and the scanner only
resurfaced at the apostrophe in `manifest's`, several comments later. The
whole og: block sat in between, so the parser saw 4 meta tags instead of 22
and none of them og:*. With no title/description/image, UrlInfoItem.fetchComplete()
is false, UrlCachedPreviewer stores Empty and the note renders a bare link.

Comments are now skipped to `-->`, declarations and processing instructions
(`<!DOCTYPE ...>`, `<?xml ...?>`) to the next `>` without quote tracking, and
script/style bodies to their end tag -- `for (i = 0; i < n; i++)` and quotes in
JS strings are raw text, not markup, and can hide the same way. Also guards a
peek() past the end of a body truncated right after a `/`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FumxeDJPEgPqX8mz3xkM6b
2026-08-21 17:08:21 +00:00
David KasparandGitHub 6560288fcb Merge pull request #3962 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-21 18:17:44 +02:00
davotoulaandgithub-actions[bot] 219f71bf17 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-21 16:16:49 +00:00
davotoula bd7e00c397 update cs,pt,de,sv 2026-08-21 18:02:08 +02:00
David KasparandGitHub 0c63687ad4 Merge pull request #3960 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-21 09:52:50 +02:00
vitorpamplonaandgithub-actions[bot] d92c94af95 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-20 22:31:03 +00:00
Vitor PamplonaandGitHub a2f0514b23 Merge pull request #3959 from vitorpamplona/claude/quartz-trusted-list-1wggln
Add Trusted Lists (kinds 30392-30395) implementation
2026-08-20 18:28:25 -04:00
Claude 509075abde fix(quartz): guard trusted-list member hints and drop member-scan allocations
Audit of the Trusted List family turned up one real bug, one indexing
inconsistency and two allocation problems.

Fabricated relay hints. RelayUrlNormalizer.normalizeOrNull("alice") returns
wss://alice/, so parsing index 2 of a member tag unconditionally turned any
non-url there -- a petname, a label, the empty-string padding's non-empty
cousins -- into a relay hint that then reached pubKeyHints()/eventHints() and
the hint indexer. Member tags now apply the same length + isRelayUrl gate PTag
uses on that slot. The trailing-field parsing that all four member types share
moves into MemberTagFields, which also removes the three copies of the score
parser.

AddressMemberTag.parseAsHint accepted any non-empty value, so a malformed `a`
tag produced an AddressHint keyed on a non-coordinate. It now requires the
value to look like a coordinate, matching ATag.parseAsHint.

memberValues() and memberCount() ran through members(), building one member
object per tag just to read a value or a length -- on lists that the spec
expects to carry thousands of entries. Both now read the tags directly via two
protected hooks each kind implements with its own isTag/parse-value pair, so
the objects are only built when a caller actually wants the hints and scores.
A test pins memberCount() == members().size, including over malformed tags,
since the two predicates have to stay in step.

Also aligns TrustedListContentMember.memberValue with the property form used
by TrustedListMemberTag, and emits list metadata ahead of the membership in
build() so a large list does not bury its own header tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f6dt4Zo3g8TAayhCU4tTD
2026-08-20 21:48:30 +00:00
Claude c0f812e468 feat(quartz): implement the Tapestry Trusted List family (30392-30395)
Adds the pre-NIP Trusted List kinds as a companion to NIP-85 Trusted
Assertions. A Trusted List is an addressable event that publishes a curated
set of members computed under a point of view -- the aggregate analog of an
assertion, which states a computed result about a single subject.

The family binds to NIP-85's subject-type convention by the +10 rule, so the
kind's last digit denotes the member type and a reader never has to inspect
the tags to know what a list contains:

  30392 (= 30382 + 10) pubkeys            `p`  UserTrustedListEvent
  30393 (= 30383 + 10) events             `e`  EventTrustedListEvent
  30394 (= 30384 + 10) addressable events `a`  AddressableTrustedListEvent
  30395 (= 30385 + 10) external ids       `i`  ExternalIdTrustedListEvent

All four extend TrustedListEvent, which carries what the family shares: the
list identity in `d`, the title/metric labels, the observer / source-tag /
cutoff / min-rank provenance, the completeness signal, the retraction marker
and the optional JSON echo of the membership in `content`. members() is
narrowed per kind but always satisfies TrustedListMemberTag, so kind-agnostic
readers can take memberValue and score without branching.

Notable semantics:

- Member tags are [<tag>, <value>, <hint>, <score>] for every kind, so a
  publisher with a score but no relay hint pads index 2 with an empty string
  (as the reference publisher does). On `e` members index 3 is the score, not
  a NIP-10 marker.
- isTruncated() keys off the *presence* of the `truncated` tag, since its
  absence is what promises the list is exhaustive; a tag with a missing or
  unparseable total still reads as incomplete.
- Single-letter tags that are not the kind's member tag are relay-filterable
  discovery metadata, read through aboutAddresses()/aboutPubKeys() and never
  through members().

Package layout and tag classes follow the nip88Polls structure. Kinds are
registered in EventFactory and KindNames; the package README documents the
wire format. Covered by tests over the reference pinned-tag list event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f6dt4Zo3g8TAayhCU4tTD
2026-08-20 20:45:59 +00:00
David KasparandGitHub 2b86db485c Merge pull request #3948 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-20 13:17:31 +02:00
vitorpamplonaandgithub-actions[bot] 37ab7e49b4 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-20 01:01:35 +00:00
Vitor PamplonaandGitHub 159afc0423 Merge pull request #3958 from vitorpamplona/claude/quartz-issue-evaluation-odhxjr
Fix registry leak in subscription lifecycle
2026-08-19 20:58:30 -04:00
Vitor PamplonaandGitHub 3261539920 Merge pull request #3953 from nrobi144/feat/desktop-chat-composer-enrichment
feat(desktop): enrich the DM chat composer + message UX
2026-08-19 19:05:44 -04:00
Claude 18bd078a10 fix(quartz): release the sub row on unsubscribe even while the client is inactive
Audit of every subscription teardown path in the app found the previous commit's
release was reachable only while the client was ACTIVE: NostrClient.unsubscribe
gated the whole flush on isActive(), and that flush is what releases the row.
The Android app backgrounds constantly (disconnect() -> isActive() == false) and
tears feeds down while it is down there, so every teardown in the background
leaked a row for the whole stretch, and all of them were then scanned on every
relay connect once the app came back.

The flush now always runs; only the wire traffic stays gated. While inactive the
pool is disconnected, so sending is both impossible and pointless (the relay
dropped the subscription when the socket died), and routing through the pool
would materialize a relay client for a relay we deliberately stopped talking to.

Also hardens the release against a subscribe of the same id racing an
unsubscribe: sendToRelayIfChanged now (re)creates the row when the sub is
desired, and re-creates it after a removal that a concurrent subscribe undercut.
An empty row means "nothing in flight", so the REQ is still decided and sent —
without this, the loser of that race could leave a wanted feed permanently
silent, which is far worse than a leaked row.

Adds NostrClientSubscriptionLifecycleTest: ten end-to-end cases driving a real
NostrClient through a recording socket, each asserting BOTH the wire traffic and
the registry size, because it is easy to fix the leak by dropping a frame. They
cover subscribe/unsubscribe, teardown while inactive, background churn,
re-subscribing the same id (what SubscriptionController does when a feed's relay
set empties and refills), dismissal through SubscriptionController, filter
changes on a live sub, replay after reconnect, COUNT queries, multi-relay
CLOSEs, and survival of the relay-wide refusal block across re-subscribe cycles.

Nine of the ten fail on the pre-fix code; the tenth (a filter change on a live
sub) passes both ways and is there to catch the opposite regression. Verified
side by side that fixed and unfixed produce identical frames on the wire —
REQ, REQ, CLOSE, then only the live sub replayed on reconnect — with the
registry going from 2 requests + 1 count to 1 + 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHsz9fXDYysZcKZXhff4XY
2026-08-19 22:30:29 +00:00
Vitor PamplonaandGitHub 1ae9faacb5 Merge pull request #3957 from vitorpamplona/fix/geode-ci-sync-corpus
ci: shrink MirrorSyncThroughputTest's corpus to 100k on CI
2026-08-19 18:28:55 -04:00
Claude ca89b30fbe fix(quartz): release pool request/count state rows when a sub is removed
PoolRequests.remove() dropped the sub from `desiredSubs` and its listener, but
left its RequestSubscriptionState in `relayState` forever. Since
onConnecting/onDisconnected/onCannotConnect scan that whole map on every relay
lifecycle event, each connect was O(all subs ever created) rather than O(live
subs). A long-running pool driving many relays therefore spends more and more
dispatcher time in the connect path, starving everything else sharing the
client (reported downstream in NosFabrica/vespa-relay#154, where the monitor
plane completed zero passes in 3h while ~95% of dispatcher CPU sat in the
registry scan).

PoolCounts had the same leak, plus a cross-registry one: NostrClient fans every
frame out to both registries, so each REQ CLOSE materialized a permanent COUNT
row (and each COUNT id a permanent REQ row via sendToRelayIfChanged).

The registry deliberately models the *believed relay state*, separate from the
desired state, so a row must outlive `remove()` — the CLOSE frame is decided
from the filters it still holds. Dropping the row inside `remove()` looks like a
one-line fix but silently stops CLOSE being sent, leaking the subscription on
the relay instead. So the row is released after the CLOSE has been handed to the
socket, and the write paths no longer resurrect it:

- remove(): keeps the row (comment says why); drops it when the id isn't ours.
- sendToRelayIfChanged(): never creates a row; releases it once the sub is no
  longer desired and every affected relay has been told.
- onSent(): non-creating lookups. Both send paths pre-mark the row under the
  lock before the frame leaves, so a null means the sub is already gone.
- PoolCounts: same, via a liveState() helper gated on the query still existing.

While a sub is still desired nothing changes — its row survives connect,
disconnect and filter changes, so late events still link to the filters the
relay was running. Once removed, remove() has already dropped the listener in
the same call, so the surviving row had no consumer left anyway.

Adds INostrClient.registrySizes() so a host can alarm on registry growth;
registry size multiplies the cost of every relay lifecycle event, so a leak
shows up as connect-path CPU long before it shows up as memory.

Tests cover both directions: the registry must stay bounded across churn, and
CLOSE must still be sent for every removal (the guard that catches the naive
one-line fix).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHsz9fXDYysZcKZXhff4XY
2026-08-19 21:27:15 +00:00
Vitor PamplonaandClaude Opus 5 8bf1ac27d8 ci: shrink MirrorSyncThroughputTest's corpus to 100k on CI
test-geode has been dying at its 30-minute timeout on most runs since at least
18 Aug — six consecutive cancellations on main, both runs on #3955 — while the
runs that pass finish the whole job in under five minutes. It is not a job that
outgrew its budget; nothing lands in between.

The cause is MirrorSyncThroughputTest, which preloads a 1,000,000-event corpus.
The sink ingests slower than the in-process source serves, and MirrorWorker's
intake channel is deliberately unbounded (its listener callback cannot suspend,
so it trySends rather than block the shared OkHttp reader — see its kdoc). The
backlog therefore grows until the runner's heap is gone. From the CI log:

    …116777/1000000  (10,225 ev/s inst)
    …121961/1000000  ( 1,507 ev/s inst)
    …122793/1000000  (   247 ev/s inst)
    …123113/1000000  (    86 ev/s inst)
    Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler
               in thread "kotlinx.coroutines.DefaultExecutor"

That is a GC death spiral, then OOM. What turns it into a *timeout* rather than
a failure is where the OOM lands: on coroutine threads, reaching the
UncaughtExceptionHandler instead of the test thread. JUnit never sees a failure,
the JVM never exits, and the job produces no further output until GitHub kills
it — which is why the check has been red without ever saying why.

The test already takes -DsyncN (default 1,000,000) and geode/build.gradle.kts
already forwards it to the test JVM, so this is a workflow-only change. 100k
keeps a real ev/s measurement while bounding the worst-case backlog to a tenth
of what died. -DsyncN is unset everywhere else, so local and manual runs still
measure the full 1M.

This does not fix the underlying fragility — a bulk backfill can still exhaust
the heap, and an OOM on a coroutine thread will still wedge rather than fail.
Both are worth addressing separately; the MirrorWorker half is already noted in
relayBench/plans/2026-07-04-sync-throughput-1m.md.

Verified: :geode:test --tests "*MirrorSyncThroughputTest*" -DsyncN=100000 passes
in 7s and logs "preloaded 100000"; the full suite passes locally in 1m39s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:21:30 -04:00
Vitor PamplonaandGitHub e4184a3806 Merge pull request #3955 from vitorpamplona/claude/relay-auth-cache-91pa8f
Add session grants for relay auth to survive reconnects
2026-08-19 16:19:32 -04:00
Vitor PamplonaandGitHub df13abe1c4 Merge branch 'main' into claude/relay-auth-cache-91pa8f 2026-08-19 15:40:59 -04:00
Vitor PamplonaandGitHub 4e5b56d558 Merge pull request #3956 from vitorpamplona/fix/channel-invite-lint
fix: collect buzzWorkspaces instead of reading .value in composition
2026-08-19 15:39:43 -04:00
Vitor PamplonaandClaude Opus 5 80806df45f fix: collect buzzWorkspaces instead of reading .value in composition
Lint's StateFlowValueCalledInComposition fails the build on main, so
:amethyst:lintFdroidBenchmark aborts and takes test-and-build-android with it
on every branch.

Both sites read the workspace set as a snapshot inside a @Composable:

  val workspaces = accountViewModel.account.buzzWorkspaces.flow.value

That is not just a lint preference. The set is populated asynchronously — the
kind-13534 roster can land after the row is first drawn — and a .value read
subscribes to nothing, so the composable never recomposes when it arrives. An
invite drawn before its workspace was known keeps the value it saw, which for
these two is null from toMembershipNotice, and the renderer returns early: the
row simply never appears. Collecting fixes the staleness as well as the lint.

Both files already imported collectAsStateWithLifecycle and getValue, and it is
what the surrounding code uses (42 of 43 collections under ui/note/types).

The other buzzWorkspaces.flow.value reads — BuzzDmListViewModel,
BuzzDmDiscovery, BuzzMembershipEoseManager — are ViewModel and service code, not
composition, and are correct as they are.

Note CI only ever reported the first of these: lint prints "First failure" and
stops, so ChatroomHeaderCompose was invisible in the log until ChannelInvite was
fixed. Verified with :amethyst:lintFdroidBenchmark locally, which now passes with
0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:56:51 -04:00
Claude 4f8a887a7b fix(test): pass the now-required sessionGrants in RelayAuthReadFollowsTest
A semantic merge conflict: no textual overlap, but the two sides do not compile
together.

Making RelayAuthPermissionLedger.sessionGrants required (no default) updated
every ledger construction its author could see. RelayAuthReadFollowsTest was
not one of them — it arrived independently from main, so neither branch was
broken on its own and git had nothing to flag. Merging them produced
"No value passed for parameter 'sessionGrants'".

Passes a fresh RelayAuthSessionGrants() like the sibling suites. A private set
is right here: this test never exercises grants, it only needs a ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-19 17:14:12 +00:00
Claude 5a4313ba11 Merge remote-tracking branch 'origin/claude/relay-auth-cache-91pa8f' into claude/relay-auth-cache-91pa8f 2026-08-19 17:10:27 +00:00
Vitor PamplonaandClaude Opus 5 30b48de304 fix: keep the session-grant invariants with the state they protect
Three follow-ups from reviewing the session-grant feature. Each is a case where
the rule was right but lived somewhere it could not hold.

1. "Never log in" clears the grants, but that pairing was written in the
   settings screen's onClick, which made it a property of one screen rather
   than of the account. Any other caller of
   AccountSettings.changeDefaultRelayAuthPolicy would silently reintroduce
   grants that outlive the switch-it-all-off answer — and a grant outranks the
   policy, so they would authenticate. Moved to
   Account.changeDefaultRelayAuthPolicy, which owns both the setting and the
   grants; the screen now calls that. Stored Always/Never exceptions are still
   left alone, since those outrank the policy by design and are listed.

2. RelayAuthPermissionLedger.sessionGrants defaulted to a fresh instance.
   Account passes the shared one, so nothing was broken, but the default meant
   a ledger built without it got a private set instead of failing — and this is
   shared state by construction: the foreground screen and the background
   notification consumer decide off one ledger, so a split set would lose
   answers between them and bring the dialog back. Now required.

3. Blocking a relay did not drop its session grant. Blocking outranks
   everything while it is in force, so nothing leaked, but lifting the block
   resumed authenticating off an answer given before it — and the weaker "never
   allow" already drops the grant, so the stronger signal not doing so was
   backwards. Account now observes the block list and revokes through the new
   RelayAuthPermissionLedger.revokeSessionGrantsFor. Observed rather than hooked
   onto the local block action because kind 10006 is shared: a block published
   by another client arrives as a flow update with no call of ours behind it.

Verified on an emulator for 1 (selecting "never log in" still clears the grants
through the new path) and by unit test for 2 and 3. The block list has no editor
screen in this build — it is rendered from a published kind-10006 note — so 3's
wiring is covered by its test plus the fact that both sides key off
NormalizedRelayUrl.url, not by an end-to-end run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 13:04:55 -04:00
Claude 5ef59b1cec Merge remote-tracking branch 'origin/claude/relay-auth-cache-91pa8f' into claude/relay-auth-cache-91pa8f 2026-08-19 16:56:59 +00:00
Claude 99698d10ff Merge remote-tracking branch 'origin/main' into claude/relay-auth-cache-91pa8f
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-08-19 16:49:25 +00:00
Vitor PamplonaandClaude Opus 5 99dc8c57ff fix: don't let undo restore a session grant under "Never log in"
The undo on "forget this login" re-granted unconditionally, so this sequence
put a grant back that the user had just switched off globally:

  forget a session grant -> select "Never log in" -> tap undo

The snackbar is the reason the window is wide enough to matter. It is shown
with an action label, and Material3 defaults that case to
SnackbarDuration.Indefinite, so it sits on screen until acted on — plenty of
time to change the policy above it first. Selecting NEVER clears the grants
that exist, but nothing stopped a new one being written afterwards, and the
grant is ranked above the policy in RelayAuthResolver, so the relay
authenticated again. That is exactly the claim the previous commit made about
NEVER being the switch-it-all-off answer.

Guarded in the ledger rather than in the composable that found it: the ledger
already owns globalPolicy and the precedence this protects, so every caller is
covered, not just this screen's undo.

The guard stops at the policy on purpose. A stored override written during the
same window is self-protecting — it outranks the grant, so an ALLOW or DENY
decides the relay whether or not the grant comes back — and so is the block
list. Only the policy sits below the grant, so only the policy could be
silently overridden.

grantForSession now reports whether it took, and the screen says so instead of
leaving a tapped undo looking like it did nothing.

Verified on an emulator against a NIP-42 relay that logs every frame: the
sequence above now leaves the policy on NEVER, no "Just for now" row, and no
AUTH on the wire; undo with the policy untouched still restores the grant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:29:31 -04:00
nrobi144andClaude Opus 4.8 84a031b931 feat(desktop): enrich the DM chat composer + message UX
Brings the desktop DM experience up to par with a modern chat client,
reusing existing commons/quartz infrastructure throughout.

Composer:
- Emoji picker (org.kodein.emoji, MIT) with search; unicode + NIP-30
  custom-emoji ":shortcode:" autocomplete in one strip
- @mention autocomplete (avatar + display name) inserting nostr:npub
- Reply-quoting: composer bar + a quoted chip on reply bubbles that taps
  to scroll-and-highlight the original (bounce + flash)
- Per-message image quality selector; send-in-progress spinner

Rendering:
- DM bubbles render rich text (mentions resolve to names; image/GIF/
  custom-emoji inline) instead of raw text

GIF search (Nostr-native, no third-party API):
- Query NIP-94 kind-1063 GIF metadata from a configurable relay list
  (nos.lol + relay.damus.io), dedup + client-side filter, animated preview

Uploads / privacy:
- Encrypted DM files upload as opaque application/octet-stream by default
  so the media server can't learn the media type; optional "Reveal media
  type" toggle for strict servers. Only reencode jpg/png/webp; GIF/video/
  AVIF/HEIC pass through. Actionable errors for servers that reject
  private uploads
- Reply/glyph: add a real Reply icon to the Material Symbols subset

Robustness:
- Preserve Messages state (selected room, draft, attachments) across the
  device lock by hoisting it above the lock gate
- Thread the send so the composer always clears and errors surface

Adds unit tests for the GIF merge/filter and caret insertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 10:59:21 +03:00
Vitor PamplonaandGitHub 89835da7e5 Merge pull request #3952 from vitorpamplona/claude/relay-auth-permission-bug-zesbwe
Make the "reading someone I follow" relay-auth toggle reachable, and scope Buzz per-account state to the account
2026-08-19 00:19:53 -04:00
Vitor PamplonaandClaude Opus 5 41fa4538c2 fix(buzz): open v2 invite links
The invite server now mints `v2.<opaque>` tokens, and the client could not
open them at all. BuzzInviteLink.parse requires `<payloadB64url>.<sigB64url>`
and reads the community out of the payload; for a v2 token the payload segment
is the literal `v2`, which decodes to one byte and fails the JSON parse, so
parse returned null.

Every entry point is gated on that one call, so the failure was total and
silent: the deep link fell through to the external browser (where nothing can
sign the claim with the user's key — the reason the in-app flow exists), a
pasted link in search did nothing at all, and a link inside a note rendered as
a plain url instead of an invite. `amy buzz join` refused it too.

Nothing is lost by admitting the shape. The join needs the host and the code,
both carried by the url itself: relayUrl() is `wss://$host`, httpBase() is
`https://$host`, and the claim response returns community_id and role — which
is why the screen never reads communityId. Expiry is the relay's call for a
token it alone can interpret.

Matched on the literal `v2` prefix rather than by relaxing the decode, so
`…/invite/anything.else` still fails to parse and a Concord naddr invite (no
dot) is still rejected. Tests cover the real v2 token end to end plus both
guards; all three fail against the unpatched parser.

Verified on device against a live workspace: the link now opens the join
screen, hands off to the window.nostr browser, and the claim enrolls the key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 00:07:08 -04:00
Claude 1c8968e737 Merge origin/main into claude/relay-auth-permission-bug-zesbwe
Resolves the conflict main's Buzz DM work created with the per-account stores.

BuzzDmDiscovery: took main's side wholesale. It rewrote discovery to derive the
whole membership picture from LocalCache observers each pass, which removed the
joined-relay set as a restart trigger — so the `joined` val this branch had
ported to `account.buzzWorkspaces` is simply gone, along with the import that
fed it.

The rest is the port main could not have known to make: three new call sites
reached for `BuzzWorkspaces` as a singleton, which no longer exists.

- BuzzMembershipEoseManager now reads `key.account.buzzWorkspaces`. This is the
  one that mattered — it is a PerUserEoseManager, so fanning each account's
  `#p=me` membership REQ across the *device-global* joined set (and
  pre-approving NIP-42 on every relay in it, per account) was the bystander leak
  this branch exists to close, reintroduced in a new file.
- membershipRelay/toMembershipNotice/toMembershipNotices/membershipNotices take
  the workspace set as a parameter instead of reading a singleton: which
  workspaces to prefer when resolving a notice's relay is a per-account
  question, so only the caller can answer it. No default — every caller has an
  account in hand, and a silent emptySet() would quietly degrade to "whatever
  relay delivered it".
- ChannelInvitesState takes the account's store and uses it as both the combine
  trigger and the data, keeping main's reasoning that a late restore-from-disk
  has to re-resolve which relay a notice belongs to.
- The two single-notice renderers (ChannelInvite, ChatroomHeaderCompose) read
  the set off their AccountViewModel. Faithful to main, including its staleness:
  the value is read at remember time, so a set restored afterwards does not
  re-resolve the row. Pre-existing, not introduced here.

Verified: 2874 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest (main adds ~68); spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-19 02:24:16 +00:00
carmim777 7594d826a8 feat: add Android screen sharing to calls 2026-08-18 21:20:36 -03:00
Vitor PamplonaandGitHub 0fcfb853e8 Merge pull request #3951 from vitorpamplona/feat/invite-actions-in-relay
feat: keep invite replies, reactions and zaps inside the relay that sent them
2026-08-18 20:12:08 -04:00
Vitor PamplonaandClaude Opus 5 d93abf26fc feat: keep invite replies, reactions and zaps inside the relay that sent them
An invite card's actions behaved like actions on a public note: the reply routed to
a generic kind-1111 whose broadcast reached the account's outbox, and the like and
zap did the same. On a Buzz relay all three are room content — the room is the only
place they mean anything, and for a private or closed group publishing them
elsewhere advertises who is in which room.

- Group-scoped events (anything carrying an `h` tag) now publish to the room's host
  relays and nowhere else. `EventBroadcaster` resolves the hosts from the cached
  channels for that group id and returns them outright instead of unioning them
  with the outbox and broadcast lists; a room this cache doesn't know still stays
  off the broadcast list. This is the rule the group reply composer already applied
  on its own path, applied to every group-scoped event.
- Zap requests copy the room's `h` tag (reactions already did) and name the room's
  host as the relay for the receipt, so the kind-9735 lands where the message it
  pays for lives — and matches the recipient's `#h` notification query.
- Tapping an invite row opens the reply page. The room block inside the card keeps
  its own click and opens the room, so the two destinations each have a target.

The reply itself needed no change: it was already a kind-1111 carrying the room's
`h` tag, rooted on the kind-44100 (`E`/`K`/`P`) — only its delivery was wrong.

Device-verified against a local Buzz relay. Before: the comment reached nos.lol and
nostr.mom. After: the comment and the reaction exist on the workspace relay only,
absent from all three public relays checked, and the row-tap opens the composer
while the room block still opens the room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:40:49 -04:00
Vitor PamplonaandGitHub 75cec56a4a Merge pull request #3950 from vitorpamplona/fix/invite-roster-line-label
fix: give the invite roster line's member count its unit
2026-08-18 18:27:07 -04:00
Vitor PamplonaandClaude Opus 5 c4d68922bc fix: give the invite roster line's member count its unit
The line read "5 · 10.0.2.2:7447". Sitting immediately after the faces of people
you follow, a bare number reads as counting those faces — "5 people you follow" —
which is the one thing it does not mean.

Use the same `relay_group_member_count` plural every other group surface uses (the
workspace channel list, discovery, the parent picker), so it reads "5 members ·
10.0.2.2:7447" and matches the row the same channel gets elsewhere. Singular falls
out of the plural: "1 member".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:22:53 -04:00
Vitor PamplonaandGitHub 24be287bb4 Merge pull request #3940 from vitorpamplona/claude/concord-nip29-invitations-vx2wgj
Fix Buzz channel invite flickering by deriving from cache
2026-08-18 18:11:27 -04:00
Vitor PamplonaandClaude Opus 5 7c47a0495b fix: read the invite map the New Requests rebuild is driven by
Accepting or ignoring an invite from the Messages row left the row on screen: the
answer was applied (a manual pull-to-refresh dropped it, and the channel showed up
under Known) but the automatic rebuild did not see it.

`AccountFeedContentStates` invalidates dmNew when `pendingByEventId` emits, while
the filter read `channelInvites.flow` — a second StateFlow mapped off that one. At
the instant the rebuild runs, the derived flow can still hold the previous value,
so the answered invite is rebuilt right back in, and nothing emits again.

Read `pendingByEventId` directly. Ordering is irrelevant here — the feed sorts by
its own comparator afterwards.

Device-confirmed: Add to Messages and Ignore now clear the row immediately, and a
live invite still appears on both surfaces within seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:05:42 -04:00
Vitor PamplonaandClaude Opus 5 dac7bde2e1 fix: classify a buzz channel from the metadata event, not the channel it fills
An invite whose kind-39000 arrives after its kind-44100 never surfaced. The card
stayed hidden on the Notifications tab and in Messages > New Requests until an
unrelated membership notice happened to arrive, or the app was relaunched.

LocalCache.consume(GroupMetadataEvent) loads the event onto its addressable note
and wakes the cache observers FIRST, and only then copies it into the
RelayGroupChannel. So the recompute that the arriving directory triggers reads a
channel that is still empty, concludes UNKNOWN, and — with nothing left to emit —
never runs again. (The channel is also skipped entirely when the event arrived
without relay provenance.)

The trigger now carries the classification instead of a note count: the flow maps
the observed kind-39000 notes to their Buzz types, and classifyBuzzChannel falls
back to that map when the channel has not been filled in yet. Reading the event
that caused the emission cannot race with itself.

Device-confirmed on an emulator against a local Buzz relay: a kind-44100 followed
by its kind-39000 produced no card at all before this, and one unrelated kind-44101
made it appear instantly; after, the card appears on its own within seconds, on both
surfaces. BuzzDmDiscovery classified off the same racing read and gets the same fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 18:05:42 -04:00
Claude 554685dbb1 feat: load channel invites through the New Requests DAL
The invites were a pinned header above the New Requests list. That put them
outside the ordering: an invite from last week sat above a DM request from a
minute ago, and the list had two sections that were the same kind of thing.

ChatroomListNewFeedFilter now emits the kind-44100 itself as a row, so invites
sort in among the unaccepted DMs by when they landed. ChatroomHeaderCompose
matches the kind and draws it as the invited group's row — before the generic
group-scoped fallback, which would otherwise render the relay keypair's npub with
the raw JSON body as the preview and offer to leave a group never joined.

The state stays on the account. LocalCache is a process-wide `object` shared by
every logged-in account, and a kind-44100 is #p-gated — it is addressed to one
viewer. Hanging "pending invite" off the shared channel would show account A's
invite on account B's Messages; everything the channel does hold (39000 metadata,
39002 roster) is genuinely global, which is why membershipOf() takes the pubkey
as an argument rather than knowing who is asking. "Pending" also is not a
property of the notice alone: it means no later 44101 withdrew it, it is not on
my kind-10009, and I have not dismissed it — two of those are account state. That
is the same shape as joined groups, which the Known filter reads off
account.relayGroupList and sorts into the feed exactly this way.

Removes ChannelInvitesSection and the headerContent plumbing it needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 21:24:59 +00:00
Claude 5898dbde1c feat: render pending invites as group rows in Messages › New Requests
New Requests is a list of rooms awaiting a decision, and a pending invite is
exactly that — but it was drawn as a full note, a card the height of five rooms
sitting on top of a list of rooms. It got that shape by inheritance, not by
choice: the prompt was built for Notifications, where the unit of the feed
genuinely is a note, and Messages reused the same composable.

Extracts RelayGroupRow out of RelayGroupRoomCompose: one NIP-29 group as a
Messages row, with the "last message" line and the long-press menu as the only
two slots. Everything else — picture fallback to the host relay's NIP-11 icon,
the unread rule, where a tap goes — is fixed there, because every list that
shows a group has to agree on it.

A pending invite then renders as that row with the invitation as its newest
line: "Alice added you to this channel". The actor names it, not the signer — a
kind-44100 is signed by the relay keypair reporting the change it made, so the
author would be an npub. Deciding happens where it does for every other group:
tap opens the channel so it can be read first, and its top bar already offers
Add to Messages; long-press brings Add to Messages / Ignore / Leave to the row.

Notifications keeps the note card — same state holder, so the two surfaces still
cannot disagree about which invites are open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 21:12:21 +00:00
Claude 402bf412d5 feat: redesign the channel-invite body as a cover block
The kind-44100 body was a plain text line plus three buttons. It sat inside a
NoteCompose whose header, for this kind, is a bare npub — a kind-44100 is signed
by the relay keypair, which has no kind-0 and no NIP-05, so line one falls
through to npub1… and line two renders nothing. Everything that says what the row
is about therefore has to live in the body.

- Actor line: who added you, with their avatar, since the header names nobody.
- Cover block: the channel picture edge to edge at 84dp with its name reversed
  out over a lower-half scrim and a visibility badge. The picture draws over a
  gradient hashed from the group id rather than falling back to one, so a channel
  with no picture and a channel whose picture fails to load land on the same
  stable colour with no placeholder branch.
- Roster line: faces of people you already follow who are in the channel — a far
  better answer to "do I want to be here" than three strangers — plus the member
  count and host relay.
- Description, dropped entirely when blank.
- The whole block opens the channel, so it can be inspected before answering.

Accept keeps the fill but drops to text-button padding at 34dp: the stock 40dp
Button dominated the two choices beside it on a row that offers three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 20:32:28 +00:00
Claude eacb855bc2 Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj 2026-08-18 19:35:01 +00:00
Claude 8c1d75f354 refactor: render channel invites through NoteCompose like every other row
The invite row was a hand-assembled NoteComposeLayout with all five slots
filled in by hand. That predates this branch — promoting invites into the Card
pipeline renamed it and wired it into RenderCardItem without ever looking
inside it, so the plumbing became standard while the row itself stayed
bespoke, and it was missing everything NoteCompose provides: reply, boost,
like, zap, share, the 3-dot menu, and click-through to open what it is about.

NoteCompose already owns all of that chrome and RenderNoteRow is a `when` on
the event kind supplying only the body — the same extension point
RenderBadgeAward and ~30 others use. So kind 44100 gets a branch there,
RenderChannelInvite supplies the body, and the card and the Messages section
both just call NoteCompose. Nothing about the chrome is re-implemented.

The body carries what the row is actually about: the channel's picture, name,
member count, host relay and description, tappable through to the channel so
the viewer can look before deciding (Route.RelayGroup opens a group that is
not on kind-10009 yet, which is exactly this case), and the
Leave / Ignore / Add to Messages actions with Accept promoted to a filled
Button.

It also names the actor inline. A kind-44100 is signed by the relay keypair —
the relay is reporting a membership change it made — so NoteCompose's author
header shows the relay, which is correct but does not say who added you. That
moves into the body with their avatar and name; the hand-built row had cheated
by putting the actor in the author slot.

Note this now offers boost/zap/share on a relay-signed, `#p`-gated event:
boost republishes a membership notification to your followers, zap pays the
relay keypair rather than the actor, and share yields an nevent nobody else
can fetch. Raised before implementing; kept deliberately for consistency with
every other notification row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-18 19:29:19 +00:00
Vitor PamplonaandGitHub f257646915 Merge pull request #3947 from vitorpamplona/perf/concord-guestbook-fold-memo
perf: memoize guestbook envelope opens so the Concord fold stops re-decrypting the buffer
2026-08-18 14:29:57 -04:00
Vitor PamplonaandClaude Opus 5 dea37b3046 perf: memoize guestbook envelope opens so the fold stops re-decrypting the buffer
`refoldGuestbook()` re-projected the entire guestbook wrap buffer on every
arriving guestbook wrap, and projecting means opening each envelope from
scratch: two NIP-44 decrypts plus the wrap and seal signature verifies. The nth
arrival therefore re-opened all n wraps, making a boot quadratic in decryptions.

The Control Plane already avoids this via `editionByWrapId` ("a wrap is only
ever decrypted once no matter how many folds it participates in"); the guestbook
had no equivalent. This adds it, keyed on wrap id — safe because `guestbookKey`
is derived once at construction and never rotates in place (a rekey builds a new
session).

Measured on an emulator cold start (Soapbox Community, 12 channels), counting at
`Nip44v2.decrypt` — the choke point every NIP-44 caller funnels through:

    before   12,281 decrypts / 7,516 KB    6,229 opens over  448 unique (13x)
    after       724 decrypts /   705 KB      449 opens over  447 unique ( 1x)

94% fewer decrypts for the same set of envelopes, and the avoided opens skip two
signature verifies apiece on top. This was effectively all of the app's NIP-44
traffic at boot: giftwrap/NIP-17 DMs measured 0 calls and the NIP-51 private-list
"settings" 10 calls / 3 KB, so Concord refolding was the whole of it.

`guestbookMembers(wraps, key)` keeps its signature for existing callers and is
now the composition of the two halves it was split into, `guestbookEntry` (the
expensive open) and `projectGuestbook` (the trivial last-writer-wins fold).

Verified on device: the community still folds all 12 channels with unread counts,
and the Members roster renders Owner/Admins/Moderators plus the plain guestbook
members.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:39:02 -04:00
Vitor PamplonaandGitHub c89eb2e86b Merge pull request #3946 from vitorpamplona/fix/painter-res-cache
fix: cache the painter on its first miss in painterRes
2026-08-17 22:29:32 -04:00
Vitor PamplonaandClaude Opus 5 3587a4c858 fix: cache the painter on its first miss in painterRes
`painterRes` keeps an LruCache of per-size Painters per drawable, but on the first
miss for a resource it installed the inner per-size cache and returned `loaded`
without ever putting it in. A resource therefore had to be requested three times
before it could hit: once to install the empty inner cache, once to populate it,
once to read it -- so every drawable paid two extra `painterResource` loads.

Found while profiling Home<->Notifications tab switching; it is a correctness fix,
not a measurable win. The extra loads are two per (resource, size) pair for the
life of the process, which does not show up next to the GC and lock-contention
costs that actually dominate that switch.

Not unit-tested: `painterRes` is @Composable and the module has neither Robolectric
nor compose-ui-test, and `unitTests.isReturnDefaultValues = true` makes
android.util.LruCache a no-op on the JVM, so a test would need either new test
dependencies or a global android.util.LruCache stub affecting the other 154 test
files. Verified by inspection plus the existing suite (1282 tests green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:26:56 -04:00
Claude b8b7be6e74 Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj 2026-08-18 01:37:48 +00:00
Vitor PamplonaandGitHub 4d7886a05d Merge pull request #3944 from vitorpamplona/perf/nip19-icu-free-scanner
perf: scan NIP-19 entities without ICU to stop the cold-start native-heap OOM
2026-08-17 21:36:33 -04:00
Vitor PamplonaandGitHub 2490c2fa97 Merge pull request #3945 from vitorpamplona/perf/hashtag-icu-free-scanner
perf: scan hashtags and #[n] references without ICU
2026-08-17 21:36:07 -04:00
Vitor PamplonaandClaude Opus 5 6d57f30307 perf: scan hashtags and #[n] references without ICU
`findHashtags` and `forEachIndexTag` jumped between `#` candidates with indexOf
and then anchored `Regex.matchAt` at each. On Android `java.util.regex` is
ICU-backed, and `Matcher.region()` -> `reset()` -> `MatcherNative.setInput()`
copies the ENTIRE input into native memory per call, so every candidate cost a
full native UTF-16 copy of the note's content. This is the same defect fixed for
the NIP-19 scanner in the OOM work, and measured over 2588 notes pulled off
production relays it is considerably worse, because a whitespace-preceded `#` is
far more common in prose than a NIP-19 prefix:

  scanner        Matchers   native bytes copied   worst single note
  nip19             7,871              1,752 MB    62.8 MB
  findHashtags     43,626              9,639 MB   279.6 MB  (a 119KB note)
  findIndexTags         0                     0    -

Both grammars are small, so they are matched directly instead. `hashtagSearch`
and `tagSearch` stay as the specification the scan is tested against.

Case handling is deliberate: `(?:\s|\A)` is Java's `\s`, which without
UNICODE_CHARACTER_CLASS is space plus 0x09..0x0D and nothing else, so the new
`isAsciiRegexSpace` is used rather than Char.isWhitespace() — the latter is
Unicode-aware and would accept U+00A0 before a `#`, which the regex rejected.
The same asymmetry runs the other way inside a tag: the excluded punctuation
class is entirely ASCII, so non-ASCII always continues a tag, and a tag made
only of U+00A0 is non-empty to the regex but still dropped by `isNotBlank()`.

Speed, medians of 5 RegexContentBenchmark runs (ns/op, lower better):

  case              bytes     regex   ICU-free    delta   ranges overlap
  hashtags m=5       4050      3267       1035   -68.3%      yes
  hashtags m=40     68072     56956      16033   -71.9%      yes
  hashtags m=120   767072    656535     185675   -71.7%      no
  TOTAL                      717550     203400   -71.7%
  idxTags TOTAL             112932.5    99937.5   -11.5%

Equivalence is pinned by a new test running both original regexes over a corpus
covering the punctuation class, ASCII-vs-Unicode whitespace either side of the
`#`, non-ASCII tag content and the minimum-one-character rules. Two mutations
(dropping `.` from the terminators, and swapping in Char.isWhitespace) fail both
it and the pre-existing ContentScanTest. Against 2588 real production notes the
scanners agree with the regexes on every one, 32,075 hashtags parsed.

`findIndexTags` shares the defect but never fires on real data — `#[0]` is the
legacy citation form no current client emits — so it is fixed for consistency
rather than impact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 20:22:04 -04:00
Vitor PamplonaandClaude Opus 5 86a9d3b780 perf: jump NIP-19 candidates with indexOf instead of testing every char
The scanner walked the content one character at a time looking for a candidate
prefix. `findHashtags` already showed the better shape for this: jump between
candidate positions with `indexOf`, which is an intrinsified, vectorised char
search, and only do real work where one lands.

'n'/'N' are two separate searches, so both are tracked and each is only
re-searched once consumed, amortising to about one indexOf per candidate.

Medians of 6 RegexContentBenchmark runs per arm (ns/op, lower better):

  case                 bytes   char-loop    indexOf    delta   overlap
  0 mentions             152       774.5      419.5   -45.8%     no
  0 mentions             608      1048.0      422.5   -59.7%     no
  0 mentions            4104      4704.5     2443.5   -48.1%     no
  0 mentions           68096     75678.0    38936.5   -48.5%     no
  0 mentions          767144    867870.5   434141.0   -50.0%     no
  m=120               767072   1097200.5   808300.5   -26.3%     no
  m=40                 68072    150933.5   125125.0   -17.1%    yes
  m=5                   4050     12317.0    10835.5   -12.0%    yes
  m=1                    222      3185.5     3367.0    +5.7%    yes
  m=2                    698      4453.5     7211.0   +61.9%    yes
  TOTAL                        2218165.5  1431202.0   -35.5%

Every no-match case is ~2x faster with non-overlapping ranges, and that is the
path that matters: of 2588 real notes sampled off production relays, only 160
contain a NIP-19 prefix at all, so 94% never leave the scan loop. The 767KB
mention-heavy tail -- the shape behind the OOM -- is 26% faster too.

The one arguable regression is a ~700B note with 2 mentions, +2.7us in absolute
terms with overlapping ranges across six runs. Accepted deliberately: it is
microseconds on the rarest shape, against halving the case that runs on nearly
every event.

Still 0 mismatches against both original regexes over the 2588-note production
corpus (7742 entities parsed), plus the synthetic equivalence corpus and
Nip19ScanTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 19:32:37 -04:00
Claude adca6e9666 fix: don't resurrect a removed NIP-OA attestation from the legacy store
Audit of this branch. The per-account migration added in the previous commit
made "removed" indistinguishable from "never migrated", so removing a held
attestation lasted exactly until the next launch.

persist(null) removed the account's key. restoreFromDisk treats an absent key as
"never migrated" and falls back to the pre-namespacing device-global list — which
nothing ever clears — so the credential the user just deleted was put back. It
survives every restart, because every restart repeats the same seeding.

The joined-workspace and starred-channel stores are not affected, but only by
luck of type: they persist a Set, and an empty Set reads back present, so their
cleared state suppresses the fallback on its own. Verified both halves of that
against a real PreferenceDataStore before fixing — a removed key reads back null,
an empty set does not. Comments now say so at both persist() sites, since the
correctness is entirely implicit and a later "cleanup" to remove() would be
silent.

The attestation store has no empty value to lean on, so it writes an explicit
tombstone. The restore decision moves into a pure internal restoreFrom(saved,
legacy, agent) — the store needs a Context and cannot be unit-tested on the JVM,
and this is the part with the sharp edge. Five tests cover the precedence,
including the regression (verified failing against the pre-fix logic).

Also from the audit: AgentAttestationScreen ignored put()'s new boolean. It is
unreachable today — parseHeldAttestation verifies against the same key the store
does — but a rejected paste would have cleared the field and shown success while
storing nothing. It now surfaces the shared failure message.

Verified: 2806 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest; spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 23:24:22 +00:00
Claude 479b0a3e6d Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt
2026-08-17 22:53:24 +00:00
Claude 6e8dd621fe refactor: move the held NIP-OA attestation onto Account and collapse its map
Not a leak — unlike the joined workspaces and the starred channels, this store
was already keyed by the agent pubkey each attestation authorizes, so no account
could ever read another's credential. It was a per-account store with extra
steps, and the steps were hiding a real gap.

Every caller only ever touched the entry for the account doing the AUTH:
AgentAttestationScreen put/removed `myPubkey`, AuthCoordinator read
`authTagFor(accountPubKey)`. So `Map<agentPubKey, OwnerAttestation>` was a
single-entry map behind a lookup that could not miss. BuzzHeldAttestations
becomes a class holding one nullable attestation for the key it is constructed
with, held as Account.buzzAttestation. The two CAS loops go with it — they
guarded concurrent writers to a shared map, and a single slot is last-write-wins
either way.

Owning the agent key lets put() do the verification its KDoc used to delegate
("The caller must have already confirmed attestation.verify(agentPubKey)"). That
obligation was discharged in two places and is now discharged in one, on the only
door into the store, so the paste path and the on-disk restore are gated
identically. BuzzAttestationPreferences drops its own re-verify loop as a result.

That gap is worth naming: the old tests stored `sig = "c".repeat(128)` and
asserted it came back out as an auth tag — an assertion that the store would hold
a credential no relay would accept, which is exactly what the store promises not
to do. They now sign real attestations with OwnerAttestation.sign and cover both
rejection paths (issued to another key, tampered conditions), including that a
rejected put leaves the held one intact.

Persistence is per account with the key namespaced by pubkey. The migration is
exact rather than best-effort: the legacy device-global list was already
agent-keyed, so this account picks out its own entry and no other can match.

Verified: 2801 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest; spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 22:45:30 +00:00
Vitor PamplonaandClaude Opus 5 c5a6aa2090 refactor: move the bech32 alphabet test into Bech32 as isDataChar
The scanner added in the previous commit carried its own copy of the bech32
alphabet, duplicating `Bech32.ALPHABET`. "Is this a bech32 data character" is the
codec's own question, so it belongs on `Bech32` next to the alphabet it derives
from -- the same way `Hex` owns its parsing helpers.

`Bech32.map` could not be reused for it: it is an `Array<Byte>`, i.e. a boxed
`java.lang.Byte[]`, and this runs per character over whole note contents (up to
~767KB), so it would unbox on every char. `isDataChar` gets its own primitive
`BooleanArray` built from the same ALPHABET constants in the existing init block,
so there is still one source of truth.

Kept at parity with the private lookup it replaced, checked in the bytecode:

- as a plain member it compiled to an `invokevirtual` per character and measured
  ~1-2% slower across the scan benchmark, consistently signed across 8 of 10 cases
- `inline` removed that call, but property access to the table then compiled to a
  `getDATA_CHARS()` getter `invokevirtual` per character instead
- `@JvmField` on the table makes the call site `getstatic; iload; baload` -- the
  same three instructions the private array produced

Benchmark, medians of 3 runs of RegexContentBenchmark (nanoseconds, lower better):

              bytes    before   inlined
  0 mentions   4104      4473      4518
  0 mentions  68096     73398     74167
  0 mentions 767144    836400    835772
  m=120      767072   1029977   1030072
  TOTAL                2102097   2104899   (+0.1%)

The 767KB cases -- the tail that caused the OOM -- overlap run to run
(before [855512, 828137, 836400] vs inlined [833456, 839112, 835772]). The two
sub-microsecond cases swing ±80% between repeats of the *same* build, so they
carry no signal.

On-device native heap is unchanged from the previous commit: plateaus at
~169-182MB over two cold starts, zero lmkd kills.

Adds Bech32DataCharTest, which sweeps the whole BMP and requires isDataChar to
agree with ALPHABET exactly. Mutating the shared ALPHABET (adding 'b') now fails
both it and the NIP-19 equivalence test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:13:13 -04:00
Vitor PamplonaandClaude Opus 5 d13a54d832 perf: scan NIP-19 entities without ICU to stop the cold-start native-heap OOM
On a 2.9GB SM-T220 the release build grew to ~1.9GB RSS during a cold start and
was lmkd-killed ~32s in ("to free 1871628kB rss, 375492kB swap"). The growth was
entirely in the NATIVE heap -- the Java heap plateaued at its 512MB largeHeap
ceiling and GCed back down, while native ran 45MB -> 1372MB.

Cause: `forEachNip19Match` called `Regex.matchAt` once per candidate position.
On Android `java.util.regex` is ICU-backed, and `Regex.matchAt` builds a fresh
Matcher whose `region()` -> `reset()` -> `MatcherNative.setInput()` copies the
ENTIRE input into native memory. So scanning one note allocated a full native
UTF-16 copy of its content *per candidate `n`*, and note content reaches 767KB in
the tail. The Java Matcher object is tiny, so Java-heap-driven GC had no reason to
reclaim them promptly and native memory grew unbounded. heapprofd's top malloc
stack was exactly this path, under LocalCache.justConsume -> updateHintIndexes.
The file's own KDoc had already recorded the symptom from an earlier pass --
"2,541 of 4,573 live Matchers were running this regex" -- but that pass optimized
speed (the 9-23x anchoring win) and left the native retention in place.

The grammar is prefix + bech32 payload + trailing non-space, so it is matched
directly with char compares instead. Case folding is deliberately ASCII-only:
RegexOption.IGNORE_CASE maps to Pattern.CASE_INSENSITIVE, which is ASCII-only
unless UNICODE_CASE is set, so Kotlin's Unicode-aware `ignoreCase = true` would
have accepted inputs the regex rejected (U+212A folding to 'k'). Reusing a single
Matcher would NOT have fixed this: region() re-copies the input on every call.

Only the ingest hot path changes. `uriToRoute`/`tryParseAndClean`/`hasAny` still
use the regexes -- they run on short user input, not per ingested event.

Measured on device (release codegen, 3 runs), native heap RSS:
      t~9s   t~14s   t~22s   t~28s    t~43s
  before   45M    497M    626M   1372M   (killed at 31.9s)
  after    48M    127M    170M    175M    172M
Native now plateaus at ~170MB, total RSS falls back to ~500MB instead of climbing
to 1.88GB, and the process survives past 45s with zero lmkd kills.

Equivalence is pinned by a new test that runs both original regexes over the same
corpus and requires identical entity lists, targeting the exact-58 boundary, the
excluded bech32 chars, ASCII-only case folding and what `[\S]*` swallows. Both
mutations tried against it (58 -> 57, and admitting 'b' into the alphabet) fail
the test. The pre-existing `Nip19ScanTest` (23 tests) and commons'
`nip19MatchesReferenceScan` guard also still pass; full quartz suite 4288/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:33:04 -04:00
David KasparandGitHub c38124aac7 Merge pull request #3943 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-17 22:34:49 +02:00
vitorpamplonaandgithub-actions[bot] d334139923 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-17 20:16:58 +00:00
Vitor PamplonaandGitHub 151b5a1259 Merge pull request #3942 from vitorpamplona/feat/worker-thread-priority-governor
feat: demote relay/ingest worker threads below the UI thread (~40% faster cold-start first paint)
2026-08-17 16:14:11 -04:00
Vitor PamplonaandClaude Opus 5 c914787257 feat: enable the worker-thread priority governor by default at nice 10
Measuring on a release-codegen build (:amethyst:installPlayBenchmark -- R8 +
baseline-profile AOT) reverses the earlier debug-build conclusion: demoting the
relay/ingest workers is worth ~40% off time-to-first-paint there.

SM-T220, 5-round round-robin, 22s window, every run valid:

  workers at | starvation | rq wait | first paint | spread
  nice 0     |     26.7%  | 2300ms  |    11.1s    | 5.63s
  nice 5     |     22.0%  | 1774ms  |     8.0s    | 4.05s
  nice 9     |     17.1%  | 1280ms  |     8.4s    | 3.05s
  nice 10    |     14.6%  | 1234ms  |     6.2s    | 1.55s

nice 10 won 5/5 paired rounds (median 5.0s faster) and collapsed the run-to-run
spread from 5.6s to 1.6s, so DEFAULT_NICE is 10 and the governor now starts
without any setting. Re-validated end to end in the shipped configuration
(default-on vs explicitly disabled): 4/4 paired wins, first paint 10.4s -> 6.4s,
starvation 31.3% -> 20.5%.

The effect exists only in release. In a debug build the same sweep changes
nothing measurable, because there the main thread is ~70% busy saturated with
ART interpretation and scheduling was never the constraint (starvation 15% debug
vs 27% release). R8 collapses main's own work while leaving the relay storm
untouched, which is what promotes starvation to the binding constraint. Recorded
in the class doc so this is not re-validated on the wrong build type.

Settings.Global is now an override rather than the gate: it replaces the default
nice level, and any value <= 0 disables the governor entirely.

Also halves the governor's own cost, 7.1% -> 3.6% of one core over a cold start
(measured from the sweep thread's own utime+stime):
- each thread is now touched once for its lifetime, not once per sweep --
  denylisted threads are remembered instead of re-reading their comm every pass
- the interval backs off when a sweep finds nothing new, and resets when it does
- list() instead of listFiles() to avoid ~650 File allocations per sweep on an
  already GC-pressured heap
- exited tids are pruned so a recycled tid is re-evaluated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 16:05:03 -04:00
Vitor PamplonaandClaude Opus 5 9d243b2420 feat: add opt-in worker-thread priority governor for cold-start diagnostics
A cold start dials ~190 relays at once and grows the process to ~500 threads
(OkHttp's TaskRunner pool, OkHttp dispatchers, the kotlinx scheduler, Arti's
tokio workers), every one of them born at nice 0. This adds a governor that
demotes them below the UI thread so the relay storm cannot starve main out of
its frames.

Disabled by default. It only runs when the `amethyst_worker_nice` global
setting exists, so it is inert as shipped:

    adb shell settings put global amethyst_worker_nice 9
    adb shell settings delete global amethyst_worker_nice

It sweeps /proc/self/task rather than installing thread factories because the
largest pool is OkHttp's TaskRunner backend, a process-wide singleton whose
factory OkHttp does not expose per client. A nice value is per-OS-thread and
survives renaming, so seeing a thread once is enough. A denylist protects the
threads that must keep their scheduling: main, RenderThread, hwuiTask, the ART
daemons (demoting HeapTaskDaemon would deepen the GC stalls this is meant to
reduce) and binder threads.

Measured on two rigs (4-round round-robin sweeps). The mechanism works
everywhere -- main-thread starvation tracks the CFS weight monotonically and
roughly halves (emulator 50.2% -> 24.8% at nice 9; SM-T220 15.2% -> 8.1%) --
but it does NOT reliably shorten time-to-first-paint on real hardware, which
is why it ships off. On a 4-core emulator main is only 27% busy and genuinely
starved; on the SM-T220 it is 70% busy and saturated with its own work, so
scheduling was never the constraint there. Raising priority on device handed
main more CPU (41.9s -> 47.6s on-cpu) and the stall did not move.

Kept as a diagnostic knob for the scheduling half of the problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:54:43 -04:00
Vitor PamplonaandGitHub 3a29bcf00d Merge pull request #3939 from davotoula/feat/mute-public-chats
Mute public chats
2026-08-17 12:47:16 -04:00
Vitor PamplonaandGitHub 3564e989b2 Merge pull request #3941 from vitorpamplona/claude/cashu-wallet-balance-bug-gaik1b
Fix Cashu wallet balance truncation via proof backfill and history paging
2026-08-17 12:38:24 -04:00
Claude 64cb648447 fix: scope starred Buzz channels per account
Same class of bug as the joined workspaces, spotted by reading the neighbour:
BuzzChannelStars was a process-wide singleton on one device-global preference
key. Its own KDoc calls a star "personal" and "the client's own bookkeeping",
and the only justification offered for sharing it was "like BuzzWorkspaces,
this is a process-wide singleton" — which stopped being true one commit ago.

A star reorders and badges the community channel list, so while the set was
shared, one account pinning a channel reordered every other logged-in account's
list, and switching accounts silently rewrote the set they had in common. No
AUTH exposure here — this one is cosmetic — but it is the same mistake and the
plumbing was already in place.

BuzzChannelStars becomes a class held as Account.buzzChannelStars, and
BuzzChannelStarPreferences namespaces its key by pubkey with the same one-time
fallback to the pre-namespacing key, so upgrading doesn't unpin everything.
BuzzPinDropdownItem takes an AccountViewModel to read and toggle the right set.

AccountCacheState's per-account Buzz hook collapses from
startBuzzWorkspacePersistence(pubKey, workspaces, scope) to
startBuzzPersistence(account): two features needing the same wiring is the point
at which passing the account beats threading each piece of state through.

Verified: 2800 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest, incl. 3 new BuzzChannelStars tests (one
pinning the cross-account isolation); spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 16:25:42 +00:00
Claude 4a1204ae57 refactor: delete the unused AuthDecisionResolver
AuthDecisionResolver has never had a production caller. Searching every commit
that touched amethyst/src/main for the symbol outside the object's own file
returns nothing, and `git log -S` against AuthCoordinator.kt is likewise empty:
it arrived unused and stayed that way, kept alive only by its own test.

The live equivalent is the per-account block in AuthCoordinator, which covers
every branch it modelled — ALLOW/DENY/ASK, and the full UserAuthChoice mapping
including the setDecision writes behind "always allow" and "never allow".

Two things made it worse than merely dead. It folded every logged-in account
into a single verdict, whereas the coordinator decides per account because one
socket is shared and an answer given for @a must not reveal @b. And its
"no verdicts -> authenticate" branch encoded the old any-account-allows fold
that was deliberately removed to fix the over-AUTH bug; there is no random-key
fallback any more. Left in place it reads as a template for policy the codebase
has since rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-17 16:24:24 +00:00
Claude ff71e3c674 fix: scope joined Buzz workspaces per account, correct the venue toggle label
Two follow-ups from auditing the rest of the isFirstParty path against what the
relay-auth screen options actually promise.

**Joined Buzz workspaces are now per account.** BuzzWorkspaces was a process-wide
singleton persisted to one device-global preference key. Everything else feeding
AuthCoordinator.isFirstParty is read off the account, so one account redeeming a
workspace invite made *every* other logged-in account first-party on that relay —
the bystander-account AUTH leak the per-account gate exists to prevent,
reintroduced on the line above the call to RelayAuthFirstParty.hasReason (which
is why RelayAuthFirstPartyTest could not catch it: the Buzz clause sits outside
the pure function it tests). Joining is a per-user act — the invite was redeemed
by one key and the relay grants membership to that key alone.

BuzzWorkspaces becomes a class held as Account.buzzWorkspaces; the dialect mark
stays global, since which protocol a relay speaks is a property of the relay and
not of who is asking. BuzzWorkspacePreferences namespaces its key by pubkey and
is constructed per account, mirroring the same move the relay-auth overrides made
from an app-wide file to a per-account one. AccountCacheState takes no Context, so
it gets a startBuzzWorkspacePersistence lambda the way it already takes
rootFilesDir and geolocationFlow. Restore falls back to the pre-namespacing key
once per account so an upgrade doesn't empty the workspaces hub — that set is
what every account already saw, and the first join after the upgrade writes to
the account's own key and takes over.

**The venue toggle's label was wrong, not its code.** "…it's my relay, or a room
I joined" undersold isTrustedVenue, which also covers venues reached through the
follow graph — the intent is joined, subscribed to, or favorited. Reworded to
"…it's my relay, or a room I joined or follow". Renamed the key rather than
reusing it (relay_auth_auto_my_relays → relay_auth_auto_my_relays_and_venues) so
a stale Crowdin translation cannot bind to the changed copy, and dropped the 7
now-orphaned translations.

Not addressed here: the write categories ("…I'm messaging …") are derived from
pending events with the author discarded, so they read as "somebody is messaging"
and lean on isFirstParty as an approximate stand-in. Fixing that needs purpose
attribution by event.pubKey and is left for a separate change.

Verified: 2797 tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest, incl. 2 new BuzzWorkspaces isolation tests;
spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 16:05:01 +00:00
Claude 06b49acf06 fix: make the "reading someone I follow" relay-auth toggle reachable
Under the CUSTOM ("decide per relay") policy, RelayAuthResolver AND-gated
every toggle behind isFirstParty:

    if (inputs.isFirstParty && customAllows(inputs)) ALLOW else fallThrough

isFirstParty (RelayAuthFirstParty.hasReason) is true only when we publish to
the relay, the relay is on our own list, or it hosts a room we joined. A
follow's outbox relay is none of those — it is theirs — so the readFollows
branch of customAllows could never be reached. With "…I'm reading someone I
follow" explicitly on, every follow's outbox relay still fell through to ASK,
producing one login prompt per follow. The only challenges the category ever
granted were ones myRelaysAndVenues already covered.

customAllows now checks readFollows ahead of the gate. Exempting just that
category keeps what the gate is for: the follow graph it consults is this
account's, so another account's traffic cannot conjure a match, and the other
three categories still require first-party — which is what stops a bystander
account being auto-authenticated (and billed) on a paid inbox relay because
another logged-in account's outgoing DM happened to name someone we follow.
Those three lose nothing by keeping it: our own relay list and our joined
rooms' hosts are first-party by definition, and a pending event of ours makes
its destination first-party too.

RelayAuthResolverTest pinned the old behaviour as intended
(nonFirstPartyAsksInsteadOfAutoAllowing), which is why this went unnoticed;
that assertion is replaced by readFollowsGrantsOnTheFollowsOwnOutboxRelay plus
readFollowsExemptionDoesNotLeakIntoTheOtherCategories, and a new
RelayAuthReadFollowsTest covers the same case end-to-end through the ledger.

Verified: 80 relay-auth tests green across :commons:jvmTest and
:amethyst:testFdroidDebugUnitTest; spotlessApply clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UmCeSWetuKmHdrWcZkWDR
2026-08-17 15:16:12 +00:00
Claude 91a3cd9fff fix: close two gaps in the relay auth session grant
Audit follow-up to the previous commit.

1. setDecision revoked the in-memory grant before awaiting the store write,
   but RelayAuthPermissionCache only publishes an override to memory after
   its disk write returns. A challenge landing between the two saw neither
   the grant nor the override, fell through to the policy, and re-prompted —
   a fresh dialog for a user who had just pressed "Always", which is the
   exact prompt the feature exists to remove.

   Fixed with asymmetric ordering, because the two decisions want opposite
   bias: ALLOW revokes last so the grant covers the window, while DENY
   revokes first so the window asks or denies but never signs — someone who
   just pressed "never allow" must not get one more AUTH out of the grant
   they are replacing. clearDecision needs no change: the old override stays
   readable across its window, so no gap exists.

   Covered by a gated store that suspends mid-write; the ALLOW case fails
   without the reorder.

2. Switching the global policy to "Never log in" left previously granted
   relays authenticating, since the grant is checked before the policy.
   Stored exceptions outranking the policy is deliberate and documented, but
   a casual one-tap grant surviving the switch-it-all-off answer is not the
   same claim. Clearing grants on NEVER also puts RelayAuthSessionGrants.clear()
   to use, which was otherwise unreferenced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-17 14:48:54 +00:00
Claude cb7ba7dbf7 feat: remember relay auth "log in" for the rest of the session
A NIP-42 challenge is not a one-off: relays re-challenge on every reconnect,
and the client reconnects constantly (network changes, doze, app switches).
Answering the prompt without the "remember" switch authorized only the single
in-flight challenge, so the same relay asked the same question again minutes
later — the prompt fatigue that pushes users into "always allow" on a relay
they only wanted to try once.

Adds RelayAuthSessionGrants: a per-account, in-memory set of relays approved
during this run of the app. It lives on Account, so it dies with the process
and at logout — that is what keeps it distinct from the stored ALLOW the
"remember" switch writes to disk.

Wiring:
- RelayAuthInputs/RelayAuthResolver gain hasSessionGrant, ranked below the
  stored override so a later "never allow" takes effect immediately, and
  below the block list which still wins outright. Not gated on isFirstParty:
  an explicit answer for this relay outranks any inference about it.
- AuthCoordinator records the grant on ALLOW_ONCE.
- setDecision/clearDecision drop the grant, so "follows your rules again"
  after removing an exception is true rather than silently still allowed.
- Relay auth settings lists the grants under "Just for now", each row
  promotable to a real exception or forgettable with an undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rado2dnqpbCuCUyCd3trQz
2026-08-17 14:21:40 +00:00
Claude 6f14ddceba Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj 2026-08-17 13:45:02 +00:00
Claude 7106d7639b Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-balance-bug-gaik1b 2026-08-17 13:45:00 +00:00
davotoula e087ae8921 Manual testing and fixes:
test: cover the null-vs-empty mute merge, not just its decode
fix: make muting a public chat silence engagement too, and stop "Mute thread" from nuking a channel
docs: correct the one-way-filter comment in NotificationFeedFilter
2026-08-17 14:59:59 +02:00
davotoula eef95eeb6e Code review fixes:
fix: seed the row unread dot so it is right on the first frame
fix: widen public-chat unread/mute matching to metadata and create events
fix: announce muted public-chat state to screen readers
2026-08-17 14:59:59 +02:00
davotoula 1200519fe8 Mute button:
feat: add a mute button to the public chat header
feat: add mute notifications to the public chat row menu
feat: drop muted public chats from the Notifications feed
feat: stop push notifications from muted public chats
2026-08-17 14:59:59 +02:00
davotoula a23781de21 Initial commit
feat: suppress the unread dot for muted public chats
feat: expose the public-chat mute toggle on Account and AccountViewModel
feat: sync muted public chats via the NIP-78 settings blob
feat: persist muted public chats as local device state
feat: add the public-chat mute predicate
fix: make muted public chats a reactive signal in rowHasUnreadFlow
2026-08-17 14:59:59 +02:00
Vitor PamplonaandGitHub be2ed3b7f4 Merge pull request #3937 from vitorpamplona/fix/relay-auth-always
fix: honour "always log in" for relays the account does not use itself
2026-08-16 22:51:54 -04:00
Vitor PamplonaandGitHub c2584e856d Merge pull request #3929 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-16 22:51:46 -04:00
Vitor PamplonaandGitHub 210ff10141 Merge pull request #3936 from vitorpamplona/fix/memory-leaks-and-relay-auth
fix: reclaim memory under heap pressure and stop leaking sandbox Activities
2026-08-16 22:51:30 -04:00
Vitor PamplonaandClaude Opus 5 3426b83739 fix: honour "always log in" for relays the account does not use itself
RelayAuthResolver gated the ALWAYS policy behind isFirstParty:

    RelayAuthPolicy.ALWAYS -> if (inputs.isFirstParty) ALLOW else fallThrough(inputs)

so "Always log in" only auto-authenticated relays the account had its own
reason to be on. Any relay reached only through somebody else's traffic — a
followed author's outbox, another logged-in account — fell through to a prompt.
With the outbox model dialling 250+ relays and no stored per-relay decisions
yet, that is one prompt per third-party relay on a fresh install.

Narrowing to "only the relays I use" is what the "decide per relay" option
(CUSTOM plus RelayAuthCustomToggles) exists to express; applying it to ALWAYS
as well left no way to say "just authenticate everywhere", and contradicted
both the enum's own KDoc and the setting's description ("Every relay that
asks."). Make ALWAYS unconditional and keep the first-party gate on CUSTOM,
where it belongs.

Blocked relays (kind 10006) and explicit per-relay overrides still take
precedence — they are resolved before the policy.

The old behaviour was pinned by a test that documented it as intentional;
replaced with one asserting ALLOW either way, plus a new test keeping the
first-party gate covered under CUSTOM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:47:09 -04:00
Vitor PamplonaandClaude Opus 5 85cee362f0 fix: handle uiMode/fontScale/density in MainActivity instead of recreating
MainActivity declared only orientation|screenSize|screenLayout|smallestScreenSize,
so a dark-mode toggle or a font-size/display-size change destroyed and rebuilt
the Activity. Each rebuild strands one embedded browser session: the outgoing
SandboxedSdkView is dropped while privacysandbox still holds its
RemoteSessionClient over binder, which pins the old Activity's ViewRootImpl (and
the provider's WebView + SurfaceControlViewHost + EGL surface in :napplet) for
the life of the process.

Measured 1:1 on an emulator — four recreations, four leaked Activities and four
leaked WebViews, surviving repeated forced GCs. On a device that had been
running 3.4 days this showed up as 15 retained Activity objects against only 3
ActivityRecords, alongside 17 WebViews and 793MB of EGL surfaces in :napplet.

Compose handles these configuration changes natively (it reads
LocalConfiguration and recomposes), and NappletBrowserActivity in this repo
already declares the same wider set. Adding them removes the recreations
entirely: the same test goes from 1->5 to flat at 1, with zero session re-arms.

This does not fix the underlying privacysandbox retention — a session is still
stranded by recreations this does not prevent (locale change, account switch) —
but it removes the triggers users actually hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:35:59 -04:00
Vitor PamplonaandClaude Opus 5 018c693077 fix: release the broker Messenger so a destroyed sandbox Activity can be freed
A full-screen napplet/browser surface ran onDestroy cleanly and was gone from
ActivityManager, yet the :napplet process kept the Activity, its window and its
WebView alive through repeated forced GCs. A heap dump gives the chain:

  ROOT(JNI_GLOBAL) android.os.Handler$MessengerImpl
    -> MessengerImpl.this$0  = android.os.Handler
    -> Handler.mCallback     = <lambda>
    -> lambda.f$0            = NappletBrowserActivity

`replyMessenger = Messenger(Handler(mainLooper, ::onBrokerReply))` makes the
Activity the handler's callback (a bound method reference captures `this`), and
a Messenger sent over IPC is a binder — so while the broker holds it, ART keeps
a JNI global reference to that Handler here in the sandbox. One retained
Messenger therefore pinned Activity -> PhoneWindow -> DecorView -> WebView, and
no GC in the sandbox could reclaim it; only killing the process could.

The broker keeps replyTo in long-lived structures (incBus subscriptions,
liveSubscriptions, identityWatch, foregroundLeases) and onDestroy only called
unbindService, which releases none of them.

Fix both halves:
  - MSG_RELEASE_CLIENT, sent first thing in onDestroy, so the broker drops the
    Messenger's inc-bus subscriptions and this surface's foreground lease. It
    goes directly on brokerMessenger rather than through sendToBroker, which
    queues while unbound — a queued release would never be sent.
  - The reply handler now holds the Activity through a WeakReference, so even a
    broker that never processes the release cannot pin a surface again.

Verified on an emulator: opening one full-screen page and pressing back left
Activities:1 WebViews:2 across three forced GCs before, and settles to
Activities:0 WebViews:1 after. Heap dump: NappletBrowserActivity instances drop
from 13 (the leaked Activity plus its captured lambdas) to 1 — the Companion,
which is a static singleton and correctly retained.

Note it takes two GC cycles to settle; one of the reference paths runs through
a Cleaner chain, so a single forced GC still shows the old numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:35:33 -04:00
Vitor PamplonaandClaude Opus 5 6420117f04 fix: reclaim memory on heap pressure instead of waiting for the OS
MemoryTrimmingService.run has exactly one caller, Application.onTrimMemory,
and since API 34 the OS only delivers two levels — UI_HIDDEN(20) and
BACKGROUND(40) — both of which require the app to be backgrounded. Every bulk
reclaim we have (Tier 2 pruning, feed trimming, the hard cache trims) is gated
on BACKGROUND, so two situations get no reclaim at all:

  1. Foreground use. The deprecated RUNNING_* levels are never delivered, so a
     long session simply grows until the heap is full.
  2. The always-on notification service. A process hosting a foreground service
     can never enter the cached state, so BACKGROUND is unreachable even while
     backgrounded — ActivityManager refuses it outright ("Unable to set a
     background trim level on a foreground process").

Measured: a 3.4-day session sat at 492MB of a 512MB heap (3% free), paying
685ms mark-compact GCs every ~10s with dozens of threads blocked in
WaitForGcToComplete, until an input-dispatch ANR. Reproduced independently on a
second device with no foreground service at all, where the app was simply in
the foreground.

Watch our own occupancy instead. Above 70% of maxMemory, run the app's existing
BACKGROUND reclaim — deliberately the same path rather than a parallel policy,
because at that occupancy "real reclaim pressure" is simply true. A 120s floor
between runs keeps a low-yield prune from spinning.

Verified by temporarily lowering the thresholds on an emulator: the watchdog
fires and drives the real Tier 2 functions (pruneHiddenEvents,
pruneHiddenMessages, pruneOldMessages, pruneRepliesAndReactions) that had never
once executed on a foreground or always-on install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:35:11 -04:00
Claude 1fc45e4bf6 feat(cli): amy cashu sync — page the wallet off the relays into the store
amy's cashu reads project the local event store and never touch the
network: that is the contract, and it is why `cashu balance` is instant
and works offline. The gap was that nothing in amy ever put the NIP-60
kinds INTO the store. CashuContext.snapshot() queries kinds 17375/7375/
7376/7374/10019/38000 out of ctx.store, and a grep for CashuTokenEvent.KIND
across cli/ returns exactly that one call site — a store query. So a
wallet created on the phone read as an empty wallet here, and `cashu
wallet show` answered "no kind:17375 wallet — run `cashu wallet create`",
which for a replaceable kind is advice that would have overwritten the
user's real wallet.

Adds `amy cashu sync`, plus `--sync` on `cashu balance` and `cashu wallet
show` for the common case. Kept opt-in rather than folded into the reads:
an implicit network round-trip inside a command documented as a local
projection is a contract change, and the offline read is worth keeping.

It pages rather than issuing one REQ, for the same reason the Android
backfill does: a relay answers an unbounded REQ with its own cap applied
to the newest matching events, and kind:7376 history outnumbers the
kind:7375 proofs by an order of magnitude on a wallet with any history,
so what falls off the bottom is the proofs at mints the user hasn't
touched lately — the balance reads low with nothing to indicate it.
drainAllPages walks each relay on its own until cursor to exhaustion.
Relay sets are split exactly as the Android subscription splits them:
own events from the outbox, inbound nutzaps from the inbox.

The filter builders move to commons per the thin-assembly rule, and
generalize what the Android backfill already had: cashuProofBackfillFilters
is now the kind:7375 narrowing of cashuOwnEventBackfillFilters, which
defaults to every kind the account authors. cashuInboundNutzapBackfillFilters
covers the #p half. A test pins that the own-event backfill covers every
kind the live subscription authors, so the gap can't reopen by someone
adding a kind to one and not the other.

Verified against a real binary, not just the compiler: `cashu sync`
emits its six JSON keys and exits 0 with no relays configured, `--sync`
parses on both readers, an unknown flag still exits 2, and the reworded
no_wallet error goes to stderr with exit 1.

New --json keys (additive): events_downloaded, token_events,
history_events on `cashu sync`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-17 01:14:15 +00:00
Claude 7a5193bcec feat: page the Cashu transaction list backward instead of showing a relay's suffix
The kind:7375 fix made the balance whole; the transaction list was still
whatever one uncapped REQ returned. CashuWalletEoseManager asks six kinds
of one relay in a single REQ with no limit, and kind:7376 history is the
most numerous of them, so the list every device shows is a recent-N
suffix chosen by that relay's cap — and no later REQ asks for the rest,
because the EOSE moves `since` forward.

Proofs and history want opposite fixes. A balance summed over a partial
proof set is wrong rather than incomplete, so those are walked to
exhaustion in one shot. History is display-only and unbounded, and the
user reads it newest-first, so pulling all of it at launch would be a
large download for something they may never scroll. That is exactly the
shape until+limit paging is for.

Built on the existing machinery rather than a new one: BackwardRelayPager
with cursors on the Account (Account.cashuHistory, beside
notificationHistory), modelled on AccountNotificationsHistoryEoseManager
for the loader and on the NIP-29 thread list for the two UI drivers —
a bootstrap that fills the first screen and a look-ahead that pulls
another page only while the user is scrolling toward the bottom. Nothing
is fetched while the wallet is off screen, and a relay that finished a
page parks at its cursor so another relay advancing doesn't re-REQ it.

One deliberate divergence from the DM and notification pagers: they floor
at `now - liveTail` because a separate live loader provably covers
everything newer. The wallet has no such guarantee — its live REQ carries
neither `since` nor `limit`, so how far back it reaches is whatever the
relay decided, which is the bug being fixed. Flooring at a fixed tail
would leave a band between the relay's cap and the tail boundary that
neither loader ever asks for. This pager floors at `now` and overlaps the
live subscription completely; duplicates are free (both LocalCache and
CashuWalletState.historyEvents are keyed by event id) and gaplessness is
worth more than the overlap.

The footer splits on stalledCount rather than reporting `exhausted` as
"all loaded": exhausted means nothing more is reachable right now, and a
relay that answered an auth CLOSE or went silent is stalled, not done.
Telling someone their transaction history is complete when part of it was
never served is a lie about their own money.

Page limit is 100, not the notification pager's 500 — every kind:7376 row
costs a NIP-44 decrypt to render, which on an external signer is an
out-of-process round-trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-17 00:43:56 +00:00
Claude 954560666a perf: stop the wallet paying per-bundle signer round-trips and per-mint rescans
Audit of the paths the proof backfill makes hot, plus two bugs it makes
reachable.

/v1/checkstate went out unchunked. scrubStaleProofs checks every proof
held at a mint in one call and that set is unbounded — it grows with the
wallet's history, and a client that pages its whole proof set back off
the relays reaches four figures in one sweep. Mints run the same Pydantic
list caps there that they do on /v1/restore, which this file already caps
at 500 for exactly that reason, so the sweep failed with a validation
error at the moment the wallet had the most to reconcile. Chunked, and
the hash-to-curve derivation now happens once per proof instead of twice
(it was computed separately for the request list and the response
lookup — a discarded EC operation per proof, every sweep).

The auto-redeem sweep paid two NIP-44 decrypts of kind:17375 before
checking whether it had anything to redeem, and p2pkPubkeyHex re-decrypts
the same event walletPrivkeyHex just read. That sweep fires from every
relevant cache bundle, so a wallet whose nutzaps were all redeemed months
ago still paid two out-of-process round-trips per bundle on a NIP-46
bunker or a NIP-55 external signer. The candidate filter needs no key, so
it now runs first, and the pubkey is derived from the privkey in hand.

A kind:7375 we cannot decrypt hides money exactly as effectively as one a
relay never delivered, and looked identical to an empty wallet.
recomputeUnspent caches only successes, so failures are retried — but
only when something else marks tokens dirty, which in a quiet wallet may
be never. Failures are now counted and logged, and a forced resync
retries them even when the relay walk found nothing new.

Two quadratic scans that were invisible while truncation kept the entry
list tiny: peekNutzapFunding filtered the whole entry list once per
shared mint, allocating a list each time, from inside a composable
remember (so per rendered note); and cleanupDuplicateProofs compared all
pairs before every Resync. Both are single-pass/indexed now — a superset
of B must share all of B's secrets, so only entries indexed under B's
first secret can cover it.

Finally, scanning every keyset made Resync N times slower by
construction: each keyset costs at least three /v1/restore round-trips
with 500-item bodies, so a mint that has rotated ten times turned a
three-request scan into thirty run end to end. The walks are independent
and read-only, so they run three at a time — bounded to stay polite to
the mint's rate limiter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-17 00:09:40 +00:00
Claude 3cb5b56ea2 fix: recover the whole Cashu balance instead of whatever a relay served
The NIP-60 balance is a pure function of the kind:7375 events the client
holds, and nothing ever checked that it held all of them.

The live wallet subscription sends one REQ per outbox relay with no
`limit`, asking for six kinds at once. Relays answer an unbounded REQ
with their own cap applied to the newest matching events, and kind:7376
history outnumbers the proofs by an order of magnitude on any wallet with
a few hundred transactions — so the proofs that lose that race are the
ones at mints the user hasn't touched recently, which is exactly the
balance they'd forgotten they had. Nothing recovers afterwards: a capped
page and a complete page both just EOSE, and PerUserEoseManager records
that EOSE as the `since` for every later REQ to the relay, so the events
below the cap are never asked for again. The subset is stable across cold
starts and differs per device, which is how one account reads three
different balances on three phones with none of them right.

Page the proof set instead of taking one REQ's word for it: a one-shot
fetchAllPagesFromPool walk over kind:7375 on the outbox relays, run at
startup once the relay list is known and again (forced) when the user
opens the wallet. Only kind:7375 — history and quotes are display-only,
and paging them would multiply the download without moving a balance.
Because a relay that ignores NIP-09 will hand back proofs the mint
already burned, a walk that recovers anything new finishes with the
NUT-07 scrub so the mint, not the relay, decides what is still unspent;
a walk that finds nothing new skips it and costs no mint traffic.

The seed-based recovery that should have been the fallback was blind in
the same direction. NUT-13 derives a counter chain per keyset, and
scanRecoverableProofs only ever scanned the mint's *active* keyset, so
proofs minted before the mint's last rotation sat on a derivation path
nothing walked — the restore reported an empty wallet rather than an
incomplete scan, since a scan that never asks looks like one that found
nothing. It now walks every keyset the mint lists for the unit, active
first, skipping (and logging) any that errors. fetchKeysetById resolved
ids through /v1/keys, which lists active keysets only, so an inactive
keyset was unresolvable even when asked for by id; it now tries NUT-01's
/v1/keys/{id} first and keeps the active-list lookup as fallback.
Counter bookkeeping still tracks the active keyset alone — an inactive
keyset can never receive another mint, so advancing its counter would
protect nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaZ8RprmKC3sidsq6W8dKY
2026-08-16 23:01:33 +00:00
vitorpamplonaandgithub-actions[bot] 34dafb07f3 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-16 22:38:33 +00:00
Vitor PamplonaandGitHub c8a5d21ad3 Merge pull request #3935 from vitorpamplona/claude/pow-miner-memory-gc-xjilp3
Optimize PoWMiner hot loop for Android runtime
2026-08-16 18:35:45 -04:00
Claude de7e63ff58 perf(nip13): keep the PoW hot loop allocation-free without relying on the JIT
The nonce search enumerated a `List<Byte>` alphabet, which compiles to an
`ArrayList$Itr` allocation per recursion level plus a `Number.byteValue()`
unbox per candidate, and re-read `buffer.bytes` / `buffer.nonceEnds` through
their getters on every single candidate.

Measured on HotSpot, none of that showed up: escape analysis erased the
iterator and the loop already allocated ~0 B/hash. That is the problem — the
hot loop's allocation behaviour was left to the JIT, and mining runs on ART,
whose escape analysis makes no such guarantee.

Switching the alphabet to a ByteArray and hoisting the payload and the
last-index test out of the per-candidate path removes the iterator, the
unboxing and the getter calls from the bytecode outright, so the loop is
allocation-free by construction on every platform.

Verified: the iterator, `byteValue()` and per-candidate `MiningBuffer` getters
are gone from the disassembled `runDigit`; steady-state allocation stays at
~0.0002 B/hash and throughput went from 1.70M to ~1.75M h/s on a 510 B
payload. All 25 nip13Pow tests pass, including the determinism and created_at
invariants that pin the search order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vgXumByZ7E7ftfiLZCsRx
2026-08-16 20:01:21 +00:00
Claude c57108f46c fix: correct the invite projection's cache read and card removal
Audit of the branch turned up one root-cause bug and three consequences.

LocalCache.filter only yields addressables plus notes whose kind isRegular()
— and isRegular() is `> 0 && < 10_000`, so a Buzz 44100/44101 matches none of
its branches and observeNotes' initial snapshot for them is always empty.
Live arrivals were fine (the observer's new() applies no such gate), which is
why a cold start looked correct: the observer registers before the relay
answers. What broke was any projection built after the events had landed —
switching account and back builds a fresh one, and consumeRegularEvent never
re-notifies a duplicate, so it stayed empty for the session and no invite
could surface.

The observer is now only the arrival signal; the notices come from
LocalCache.membershipNotices(), the same shape NotificationFeedFilter.feed()
already uses over the same map. That scan is kept out of the projection's
combine so a dismissal or a kind-10009 edit doesn't re-walk the whole cache —
only a new verdict or a workspace-set change does.

Also fixed:

- Answering an invite could not remove its card. invalidateData() takes the
  additive path, finds no new notes (the 44100 *left* the feed) and bails on
  `if (newCards.isNotEmpty())`, leaving the answered invite pinned at the top
  by the invites-first order. The projection collector now clear()s first so
  the refresh rebuilds the whole list, which is the only branch that can
  shrink.
- BuzzDmListViewModel dropped every channel absent from a single observer
  pass, wiping rows the seed and the one-shot fetch had legitimately added.
  It now removes only channels with an actual kind-44101.
- leaveChannelInvite no longer records a persisted dismissal. That cleared
  the card a round-trip sooner but, being keyed by channel id and kept
  forever, would have swallowed a later legitimate re-add to the same
  channel. Ignore is the "don't ask again" action; Leave waits for the
  relay's own withdrawal.

Two comments corrected: NOTIFICATION_KINDS does not gate push
(NotificationDispatcher has its own set), and pendingInvites is a per-note
StateFlow read, not a once-per-conversion one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 19:46:56 +00:00
Claude 3603ddabb1 feat: render pending channel invites as notification Cards
The invite prompts reused NoteComposeLayout, so they looked like feed rows,
but they were not part of the feed: a bespoke composable in the card feed's
header slot, off a state holder hanging on AccountFeedContentStates. They
went through none of the Card pipeline, which is the structure the rest of
the tab is built on.

A pending invite is now a ChannelInviteCard, built by convertToCard and drawn
by the same RenderCardItem dispatch as every other row, so it inherits dedup
by id, last-read, backward paging, scroll-to-event from a push intent, and
trimToSize for free.

Ordering is a DAL concern: NotificationFeedOrderCard sorts unanswered invites
ahead of the dated rows, then newest-first as before. A plain created_at sort
would let a week of reactions bury a decision the user still has to make, and
page it off the end past limit(). Answering one drops it from the projection,
so nothing lingers at the top.

The projection moves from AccountFeedContentStates to Account.channelInvites
because the DAL reads it: acceptableEvent resolves a cached 44100 to "is this
still a live question" with a map lookup keyed on the event id, and
convertToCard attaches the resolved invite to the row. The 44100 kind joins
NOTIFICATION_KINDS — the contract test's envelope and subscription-coverage
rules both still hold, and push is unaffected since NotificationDispatcher
keeps its own kind set.

ChannelInvitesSection stays for Messages > New Requests, which is not a Card
feed. Its Notifications header slot is gone; the cards cover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 19:25:43 +00:00
Claude 97218b43ad refactor: derive Buzz channel invites from the cache, not a registry
The "somebody added you to a channel" prompts appeared and disappeared on a
loop. They were fed by BuzzChannelInvites, a process-wide mutable registry
that the DM discovery pass recorded into and its classification step deleted
from. The deletion was remembered nowhere, so any re-delivery of the same
kind-44100 re-added an invite that had already been withdrawn, and the two
steps fought each other. Both halves were derived from events LocalCache
already held, so the registry was only ever a second source of truth able to
drift from it.

Subscription. BuzzMembershipEoseManager joins the account loaders and owns
one `#p=me` REQ per joined workspace relay for 44100/44101 plus the 30622
hidden-DM snapshot. It needs a subscription of its own — the filter is
channel-less by nature (it is the query that discovers which channels exist),
and buzz downgrades a subscription carrying a channel-less filter to "global",
which is right for these kinds but wrong for anything channel-scoped sharing
the subscription. It pre-approves NIP-42 on each workspace relay; the
authenticator re-signs on the `auth-required:` refusal and syncFilters
re-drives the REQ, so no warm-auth one-shot is needed.

State. BuzzChannelInvites is now a pure projection: newest verdict per
channel, minus self-joins, dismissals, joined groups, and anything not yet
classified as a named channel. Withholding the unclassified case is what stops
every new DM flashing a channel-invite card until its kind-39000 lands. The
44101 handling moves out of LocalCache ingest and into the projection, which
makes out-of-order replay produce the same answer as ordered delivery.

Subscriptions removed. BuzzDmDiscovery and BuzzDmListViewModel each opened
their own identical `#p=me` 44100 REQ; both now observe LocalCache. Discovery
also recomputes and declares its whole DM set (BuzzDmChannels.replace) instead
of accumulating deltas it later has to undo.

21 new tests cover the projection and the filter shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 18:45:27 +00:00
Claude 81f852ad5d fix: keep the notifications header on screen in every feed state
The Notifications header slot carries standing prompts — pending channel
invites and the "you have no inbox relay" warning — but RenderCardFeed drew
it only in the Loaded branch. Arriving on the screen re-runs
checkKeysInvalidateDataAndSendToTop, and any refresh that momentarily
computes an empty list flips the feed through Empty/Loading, so the prompts
blinked out and back on every visit.

Draw the slot in the Empty, Loading and FeedError branches too. The padding
is applied at that point because only the Loaded branch has a LazyColumn to
carry the scaffold's inset as content padding — same shape
ChatroomListFeedView already uses for its own empty state.

This also fixes the relay prompt being hidden in the one state that needed
it: a missing inbox relay is the most likely reason the feed is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KYibeoSVdEVotM3xU1heoW
2026-08-16 18:45:08 +00:00
Vitor PamplonaandGitHub 3ce6dbb82c Merge pull request #3932 from vitorpamplona/claude/ime-padding-browser-window-ksa26a
Handle IME insets in full-screen napplet hosts
2026-08-14 20:54:50 -04:00
Claude 582b7dfe2e fix: inset the full-screen browser and napplet host for the IME
windowSoftInputMode=adjustResize no longer resizes the window: it is a
no-op for apps targeting SDK 35+ on Android 15+, where edge-to-edge is
enforced and Theme.Amethyst does not opt out. The full-screen browser
padded its root by the system bars and display cutout only, relying on
that resize to keep focused inputs visible, so the soft keyboard simply
covered the bottom of the page.

Pad the root by the IME inset as well — max(bars, ime) on the bottom,
since an open IME sits over the navigation bar — and zero the consumed
types before they reach the WebView, which would otherwise apply them to
its own web content a second time. Shared as applyFullScreenHostInsets
between NappletBrowserActivity and NappletHostActivity, which carried the
same listener and the same defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwZuom4fFXjr85aGajJhSo
2026-08-15 00:51:25 +00:00
Vitor PamplonaandGitHub dfeefc4849 Merge pull request #3931 from vitorpamplona/claude/auth-default-new-logins-5j9pkj
feat: default new accounts to always AUTH with relays
2026-08-14 19:58:08 -04:00
Vitor PamplonaandGitHub 9eba7b9296 Merge pull request #3930 from vitorpamplona/claude/disable-high-usage-warning-asef8l
feat: disable the automatic high resource usage prompt
2026-08-14 19:47:15 -04:00
Claude c86f76b23e feat: default new accounts to always AUTH with relays
New logins started on RelayAuthPolicy.CUSTOM, which auto-authenticates only
for own relays/venues and follows and prompts for everything else. Start them
on ALWAYS instead, so a fresh account answers every relay that asks (still
gated by the first-party check and the blocked-relay list).

Only the AccountSettings constructor default moves, so this applies to
accounts created from here on. Existing accounts keep their saved policy, and
prefs written before the key existed still fall back to CUSTOM.
2026-08-14 23:24:05 +00:00
Claude 8139c3774b feat: disable the automatic high resource usage prompt
The "High resource usage detected" dialog no longer appears on app open.
It is gated behind a single flag in DisplayResourceUsageAlert instead of
being deleted, so it can be turned back on with a one-line change.

The ledger keeps recording and Settings > App resource usage still shows
the numbers and offers the same review-and-send report, so users who want
to send a usage report to the developers can still do so on demand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AxtcLx9ErKXUowC6obLaJv
2026-08-14 23:23:49 +00:00
David KasparandGitHub 120887e596 Merge pull request #3928 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-14 21:09:33 +02:00
vitorpamplonaandgithub-actions[bot] abb86357a8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 16:25:35 +00:00
Vitor PamplonaandGitHub bec81ba6cb Merge pull request #3927 from vitorpamplona/claude/dependency-updates-hzw0u0
Bump dependencies and Gradle to latest stable versions
2026-08-14 12:22:52 -04:00
Claude 10336d8ed4 chore(deps): update dependencies, Gradle wrapper and CI actions
Sweep every dependency coordinate in the version catalog, the hardcoded
ones in amethyst/build.gradle.kts, the Gradle wrapper and the GitHub
Actions against their upstream metadata, and take the newest release
that keeps each pin on the same stability channel it was already on.

Version catalog:
  appcompat                1.7.1         -> 1.8.0
  benchmark                1.5.0-alpha07 -> 1.5.0-rc01
  biometricKtx             1.2.0-alpha05 -> 1.4.0-alpha02
  composeBom               2026.06.01    -> 2026.08.00
  composeRuntimeAnnotation 1.11.4        -> 1.12.0
  composemediaplayer       0.11.3        -> 0.11.4
  firebaseBom              34.16.0       -> 34.17.0
  fragmentKtx              1.8.9         -> 1.9.0
  ksp                      2.3.10        -> 2.3.11
  ktor                     3.5.1         -> 3.5.2
  media3                   1.10.1        -> 1.11.0
  secp256k1KmpJniAndroid   0.23.0        -> 0.24.0
  uiautomator              2.3.0         -> 2.4.0
  webkit                   1.16.0        -> 1.17.0

composeRuntimeAnnotation is not an independent choice: the 2026.08.00
BOM pins runtime/foundation at 1.12.0, so the standalone annotation
artifact has to move with it.

A shared version ref can only advance to the lowest release available
across every artifact that uses it. `appfunctions` is the one ref here
where the artifacts do not publish in lockstep: `appfunctions` and
`appfunctions-compiler` are at alpha10 but `appfunctions-service` stops
at alpha09, so the ref stays at alpha09 and a comment now records the
cap. Every other multi-artifact ref (media3, secp256k1, ktor, benchmark,
coil, camera, ...) agrees across all of its artifacts.

Hardcoded in amethyst/build.gradle.kts:
  tink-android              1.17.0 -> 1.23.0
  tracing-perfetto(+binary) 1.0.0  -> 1.0.1

Gradle wrapper 9.5.0 -> 9.7.0 (distributionSha256Sum updated to the
checksum published for 9.7.0), and actions/setup-java v5.6.0 -> v5.7.0
across build, create-release and smoke-test-desktop. Every other action
already floats on its current major tag.

Left alone on purpose:
  - vico stays at 3.2.3; the only newer build is the 3.3.0-next.2
    prerelease and the current pin is stable.
  - negentropy-kmp stays at v1.2.0, already the newest; the 1.0.1 that
    shows up in Maven metadata is an older artifact under a different
    tag scheme.
  - AGP, Kotlin, compose-multiplatform and the JetBrains material3 pin
    are all already the latest stable; newer builds are alphas/RCs.
  - The @moq/* npm pins in nestsClient/tests/browser-interop, because
    that directory's REV file ties the 0.2.x (moq-lite-03) line to the
    moq-relay git rev pinned in hang-interop/REV. Bumping to 0.3.x is a
    wire-protocol change that has to move with the Rust relay pin.

No new dependencies are introduced, so no new licenses enter the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UYmEazSQkEwsVCvrG96mB
2026-08-14 16:05:52 +00:00
David KasparandGitHub e4ea86fe02 Merge pull request #3925 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-14 17:51:45 +02:00
vitorpamplonaandgithub-actions[bot] 509656a726 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 15:39:25 +00:00
Vitor PamplonaandGitHub 313bb059f5 Merge pull request #3923 from davotoula/fix/community-comment-parent-context
fix: blank parent card above a top-level community post
2026-08-14 11:36:47 -04:00
Vitor PamplonaandGitHub 7e500f4266 Merge pull request #3926 from vitorpamplona/fix/dedup-cache-idle-trim
perf: bound the dedup decoder cache in size and in time
2026-08-14 11:33:22 -04:00
Vitor PamplonaandClaude Opus 5 b1f1190919 docs: record the tick sweep that picked 30s for the dedup age-out
30s was the one number in this change I picked rather than measured. Swept it
on device (10/30/60/120s).

The naive comparison is invalid: runs pull 3.7k-32k frames depending on what
the relays serve, so hit rate and heap at the end of a run are not comparable.
Comparing at a matched ~19k frames instead:

  tick    hit rate   parses   ids still cached at rest
   10s      44.9%    10,663      ~2
   30s      58.5%     7,569      ~2
   60s      60.4%     7,520      ~3
  120s      60.6%     7,555     7,438

Hit rate saturates by 30s, so a shorter tick buys only re-parses (10s costs 41%
more) and a longer one only holds memory -- at 120s just one tick fires in three
minutes, so the burst never drops at all. The knee is where the tick stops being
the binding constraint and capacity takes over: at ~400 frames/s that is
8192/400 ~= 20s, and 30s sits just past it.

Keeps 30s; documents why, and what to re-measure if capacity or frame rates
change. No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 ccebc3d344 fix: age the dedup cache on a clock, not on idleness
The idle-triggered release in the previous commit never fired. Measured on the
emulator: 240s of a completely flat heap, and every tick returned false.

Cause: relays keep pushing events down open subscriptions forever, so the
decoder is never idle in the sense of "saw no frames". Counting cache hits as
activity -- which the tests asserted, and which is right in isolation, since a
stream of pure duplicates is when the cache is earning its keep -- guaranteed
the idle clock was refreshed indefinitely. The unit tests proved the intended
behaviour; only the device showed the intent was wrong.

Replaced with unconditional clock-based aging: ageOutCache() retires the live
generation, so an id survives one tick and dies on the next, and a 30s tick
bounds any id's lifetime at ~60s. clearCache() still releases everything when
the host disconnects. Capacity keeps bounding cost during a burst; this bounds
how long it costs anything afterwards.

On-device A/B, same account and duration:
  idle-based (never fired):  flat 127-128 MB for 240s
  clock-based, run 1:  130 MB -> 71 MB when 7,436 ids were dropped
  clock-based, run 2:  143 MB -> 121 MB (3,153 ids) -> 96 MB (4,530 ids)
Each run contains its own control: the first tick only retires a generation,
drops nothing, and frees nothing.

Note ~7-8 KB released per cached id, an order above the ~600 B/entry the
recorded corpus suggested -- live traffic carries big kind-0/kind-3 events the
capture's first 150k frames under-represent. So capacity 8192 was costing
tens of MB on a real account, not the 4.7 MB the corpus implied.

Tests rewritten for the new semantics (no clock needed now, so they are fully
deterministic) and mutation-checked: a no-op ageOut fails 4, an ageOut that
clears both generations at once fails 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 1b8e264706 fix: release the dedup decoder cache once relay traffic goes quiet
CachingEventDecoder bounds what it holds with `capacity`, which caps the cost
during a burst but never ends it: the two generations only rotate inside an
insert, so a client that stops receiving frames pins up to `2 * capacity`
events for the rest of the process's life. Their dedup value decays within
seconds -- a duplicate is the same event arriving from another relay -- but
the memory did not decay at all. Raising capacity to 8192 doubled that cost
(4.7MB -> 9.4MB retained, worst case), which is what surfaced it.

Adds MessageDecoder.trimIfIdle(idleMillis, nowMillis), a no-op for stateless
decoders, and drives it two ways from NostrClient:

 - disconnect() trims eagerly, so backgrounding releases immediately instead
   of leaving a timer to do it later;
 - while active, a 30s tick releases anything idle for 60s.

The trim loop suspends on isActiveFlow exactly like keepAliveJob, so no timer
fires while the client is down.

Idleness counts cache HITS, not just inserts: a stream of pure duplicates
inserts nothing yet is precisely when the cache is earning its keep, and
keying off inserts would trim it out from under that traffic.

Racy by the same design as rotation -- clearing concurrently with an insert
can only lose an id, and a lost id costs a re-parse, never a wrong message.

Tests are in commonTest with an injected clock (no target needs a real one)
and were mutation-checked: dropping the hit-path refresh fails 1, ignoring the
idle threshold fails 2, never releasing fails 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 be330df1bc perf: size the dedup decoder cache from measured reuse distance (2048 -> 8192)
Connecting to every relay and pulling everything at once is the design; the
duplicate frames it produces are the price of that redundancy. Paying a full
JSON parse for each copy is not.

CachingEventDecoder already avoids that, but shipped at capacity 2048. Against
the recorded multi-relay startup capture (150k frames, 82% duplicates, median
reuse distance 1,430) that caught only 58.3% of duplicates. 8192 catches 80.5%
and halves offline decode time (354ms -> 169ms); 32768 would reach 96.5% but
for 4x the retained entries.

On-device A/B (emulator, cold start, ~15-18k frames): the share of frames
needing a full parse fell from 55.2% at 2048 to 38.7-44.2% at 8192. Process CPU
was NOT a usable signal at this sample size -- cold-start variance swamped it.
The gain scales with duplication, so accounts with many relays gain most.

No behaviour change: same relays, same aggression, same dispatch semantics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 f2eaa682e4 test: measure the dedup decoder cache at its production capacity
Connecting to every relay and pulling everything at once is the design, and the
82% duplicate frames it produces are the price of that redundancy. What is not
wanted is paying a full JSON parse for each copy -- which is what
CachingEventDecoder exists to avoid.

DedupDecodeBenchmark proves the mechanism, but at capacity = UNIQUE * 2 (40,000)
and with duplicates spaced 20,000 frames apart. Production ships the default
capacity of 2048. Measured against the real multi-relay capture in recorded
order, that catches only 58.3% of duplicates; 8192 catches 80.5% and halves
decode time (354ms -> 169ms over 150k frames).

Uses reuse distance so one pass yields the hit rate for every candidate capacity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 054c665696 test: pin that live websockets hold their OkHttp dispatcher slot forever
The app dials ~190 relays in one burst from RelayPool.connect() and spikes to
~640 threads, so capping the relay client's Dispatcher.maxRequests looks like a
one-line throttle.

It is not. Against a real relay, maxRequests=4 with 20 dials opens exactly 4;
the other 16 queue forever and never fail, so nothing surfaces the stall.
Setting maxRequests=N would cap the app at N relays permanently.

Pins the behaviour so the knob is not reached for again. Throttling has to
happen above OkHttp, in RelayPool, where a settled dial can release its permit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 e17f63428a test: profile ingest allocation rate against the real startup capture
Every prior memory measurement here looked at retained heap, which is blind
to garbage that never survives a GC. This measures allocated bytes per stage
with ThreadMXBean.getThreadAllocatedBytes, replaying the checked-in capture
of an account cold start.

Result: parse allocates 5.5x the wire bytes and retains 0.87x -- 4.8 KB per
event, 5.3 bytes of garbage per byte kept. Useful as a regression guard, but
it also rules the parse path out as the cause of the ingest CPU saturation:
device-wide allocation during ingest measures ~28-44 MB/s, which is not a
rate that stresses a GC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
Vitor PamplonaandClaude Opus 5 66d43e73b3 test: measure the String.intern() memory/CPU tradeoff on ingest
`String.intern()` runs on every event id, pubKey and tag value, and an
on-device profile put art::InternTable::InternWeak at 3.5% of the ingest
workers' CPU. Measures what that CPU is buying, so the question does not
get re-litigated from intuition.

On 60k events / 840k strings: interning cuts retained heap 69.2MB -> 17.7MB
(~4x) for +200ms. Keeping it is the right trade on a memory-bound device.
Skipping the hex-shaped fields is not a shortcut (half the memory for half
the CPU -- referenced ids repeat via `e` tags). An app-level pool is the
only variant cheaper on CPU, but it holds strong references where ART's
table is weak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:26:01 -04:00
David KasparandGitHub 1be3657237 Merge pull request #3924 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-14 17:22:20 +02:00
vitorpamplonaandgithub-actions[bot] 1b905c8521 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 13:57:07 +00:00
Vitor PamplonaandGitHub 332878b553 Merge pull request #3922 from nrobi144/feat/desktop-settings-search-accordion
feat(desktop): searchable settings accordion
2026-08-14 09:54:21 -04:00
davotoula dfcfbc3645 fix: decide top-level community posts from the parent, not the kind tag alone
isTopLevelCommunityPost() fell back to the root kind whenever no parent kind
(`k`) was present. That fallback exists for bridged posts that carry only the
uppercase root set, but it also swallowed nested replies that omit `k` -- those
name a parent *event*, so they answer a post inside the community, not the
community, and would have lost their parent card to a community card.

Decide from what the comment points at instead: an explicit `k` is
authoritative; failing that, a parent address that is a community means top
level and a parent event means it is not; only a comment naming no parent at
all falls back to the root kind.

Also pin two things the earlier commits changed but did not cover:

- communityAddress() against its pre-rewrite implementation as a reference
  oracle across eight tag shapes. Seven call sites outside this branch depend
  on it being unchanged, and nothing asserted that.
- isCommunityDefinition() and the lastOrNull selection RenderRepost now shares,
  which is the logic that changed there. The composable itself would need an
  instrumented test; the predicate is where the bug lived.

All three suites mutation-checked: reverting the predicate or reversing the
address scan order fails them.
2026-08-14 12:11:48 +02:00
davotoula 15327073cd refactor: name the resolved parent note, not the function that found it
The local shadowed the top-level replyingDirectlyTo it calls, which reads as
accidental recursion even though Kotlin resolves it correctly.
2026-08-14 11:48:42 +02:00
davotoula b03af026e5 Code review:
- refactor: tighten the community parent-context fix
2026-08-14 11:48:23 +02:00
davotoula 9954d2bc7d fix: blank parent card above a top-level community post
A NIP-22 comment posted to a NIP-72 community answers the community itself,
so there is no parent note to render. The reply-context resolver excluded the
community with `note.event?.kind != CommunityDefinitionEvent.KIND`, which only
holds once the definition event is in the cache: an uncached AddressableNote
has a null event, and `null != 34550`. The community shell was then handed to
ReplyNoteComposition, which rendered an empty card where the parent belongs.
2026-08-14 11:47:59 +02:00
nrobi144andClaude Opus 4.8 136983f130 feat(desktop): search + reveal for settings accordion
Add a pinned, auto-focused search field above the Settings accordion. Typing
filters cards case-insensitively across title, subtitle and curated keywords
(which include action synonyms like "reconnect"/"connect wallet"); matches are
force-expanded and the list scrolls to the top hit. Esc and the clear button
reset the query and collapse everything. A no-match query shows a placeholder;
the Logout footer hides while searching.

- Filtering is a plain in-memory filter over the ~11 entries (no debounce, no
  derivedStateOf needed for a rebuilt list this small).
- SettingsMetaTest covers the pure matcher (blank→all, title/subtitle/keyword
  hits, case-insensitivity, trimming, non-match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 11:03:02 +03:00
nrobi144andClaude Opus 4.8 0f0157b8f0 feat(desktop): settings accordion of collapsible cards
Replace the single long-scroll Settings screen (RelaySettingsScreen) with a
searchable-ready accordion of labeled cards. Each setting is a collapsible card
(icon + title + subtitle + chevron); Expand all / Collapse all toggle every card;
cards start collapsed, multiple can be open at once, and state resets each visit.

- New SettingsEntry/SettingsMeta (pure, testable matcher) + SettingsAccordionCard
  (slot-based header, hover + hand cursor).
- Rename RelaySettingsScreen -> SettingsScreen; drive it from an ordered entry
  list built each recompose (tiny list; avoids stale content-lambda capture).
- Extract the inline NWC and Relay blocks into WalletConnectSettingsSection and
  RelaySettingsSection; the relay list is now a plain Column (not a nested
  LazyColumn) so it can live inside the outer accordion LazyColumn.
- Drop now-redundant internal section titles (card header owns the title) from the
  five desktop-only sections; NamecoinSettingsSection (shared with Android) is
  untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 10:51:21 +03:00
davotoulaandClaude Opus 5 ebd3a68e3b docs: teach the translation skill the lint gate it was missing
A translation pass last night cleared every check the find-missing-translations
skill prescribes - no duplicate keys, well-formed XML, a green
convertXmlValueResourcesForCommonMain, a green compileFdroidDebugKotlin - and
still took CI red with three lint errors. The plural rules live in Android lint,
not in the resource compiler, and the skill never ran it. Six additions, each
from a failure in that pass:

- Step 6 now runs :amethyst:lintPlayBenchmark and reads the SARIF for zero
  errors, with a table of the rules that gate: MissingQuantity and
  ImpliedQuantity are errors, StringFormat* are warnings, and there is no lint
  baseline so abortOnError bites on the first one.
- Converting a <string> to <plurals> needs every locale's full CLDR category
  set. The "use other only, Crowdin fills the rest" shortcut fails
  MissingQuantity before any sync happens. res/CLAUDE.md step 3 advised exactly
  that shortcut, so it is corrected here too, and the declension trap is called
  out - the retained text is the plural form, so reusing it for "one" yields
  "1 odpowiedzi".
- tools:ignore belongs on the source entry, never a locale file. Crowdin
  propagates source attributes into its exports; an attribute added only to
  values-xx is absent from the next one. The tools:ignore="Typos" copies in
  cs/de/ar/eo/bn are the result of that propagation, not evidence that locale
  attributes survive - mistaking one for the other is what broke main.
- A new format-specifier parity and empty-item audit, for the class where the
  key is present and looks translated but the placeholder was dropped or
  escaped. The (?<!\\) lookbehind is mandatory: without it the scan matches
  %2$d inside \%2$d and certifies a broken string clean.
- The Crowdin section now states the actual rule: a repo-side edit to a
  translated value sticks only where Crowdin's database does not contradict it,
  including when it holds an empty string. One sync demonstrated both outcomes
  at once - pow_estimate_minutes[few] survived while nest_listener_count[many]
  was reverted to empty. Source-file changes are the exception and do stick.
- Six Common Mistakes entries, each with its date and concrete failure.

Docs only; no product code or resources change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uQksy5spXR8gC8Z5QfsRB
2026-08-14 08:13:43 +02:00
Vitor PamplonaandGitHub cdde6c4ef2 Merge pull request #3917 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-13 20:41:54 -04:00
Vitor PamplonaandGitHub 808770ee54 Merge pull request #3919 from vitorpamplona/build/baseline-profile-generator
build: add a baseline profile generator (:baselineprofile)
2026-08-13 20:39:37 -04:00
vitorpamplonaandgithub-actions[bot] 1cf1ff396f chore: sync Crowdin translations and seed translator npub placeholders 2026-08-14 00:24:18 +00:00
Vitor Pamplona 0e104e6843 Merge branch 'main' into build/baseline-profile-generator 2026-08-13 20:21:44 -04:00
Vitor PamplonaandGitHub 41c7f525a2 Merge pull request #3920 from vitorpamplona/perf/baseline-profile-flags
perf: drop the S (startup) flag from the hand-authored baseline profile
2026-08-13 20:21:32 -04:00
Vitor PamplonaandClaude Opus 5 a0166367cd perf: drop the S (startup) flag from the hand-authored baseline profile
The wildcards added in #3918 were written HSPL — hot + startup + post-startup —
on whole packages (quartz, LocalCache, Jackson, okhttp, okio, coroutines). The
S was wrong and potentially harmful.

S drives DEX layout: startup-flagged classes are grouped into classes.dex for
locality, and Android's docs warn that if startup code does not fit there it
"will overflow into the next DEX files". Claiming thousands of ingest methods
are startup-critical can push genuinely startup-critical code out of the first
DEX — hurting the thing the profile is meant to help. For scale, the generated
profile marks 32 of its 31,497 rules HSPL; this file claimed it for all 25 of
its rules, each covering an entire package.

Ingest runs AFTER startup, so HP is what this file actually knows. Startup
layout is left to the generated profile (#3919).

Re-measured on device (SM-T220, release build, simpleperf --app), share of
DefaultDispatcher worker CPU:

                     no profile     HSPL      HPL
  nterp                   42.9%     4.2%     4.0%
  app compiled            11.0%    20.8%    21.9%
  GC read barriers         9.4%     6.7%     6.6%
  class/method lookup      2.8%     0.3%     0.1%

Ingest throughput (RSS growth per unit of CPU):

  no profile   8.9 MB per core-second
  HSPL        14.5
  HPL         15.8   (n=4, range 14.6-18.4)

So dropping S costs nothing — as expected, since S affects DEX layout rather
than which methods get compiled. The compiled profile is marginally smaller
(16,341 -> 15,532 bytes).

The HPL-vs-HSPL numbers are within the noise of these arms (the HPL range alone
spans 14.6-18.4), so read this as "no regression", not as an improvement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 19:03:25 -04:00
Vitor PamplonaandClaude Opus 5 4c606c2571 build: add a baseline profile generator (:baselineprofile)
Follow-up to the hand-authored amethyst/src/main/baseline-prof.txt, which was a
stopgap: whole-package wildcards that compile methods which never run. This adds
the machinery to produce a real profile from a recorded journey.

  ./gradlew :amethyst:generateBaselineProfile

New :baselineprofile module (com.android.test + androidx.baselineprofile) with a
BaselineProfileRule journey, and :amethyst applies the plugin and consumes it.
Notes on the wiring, since three things needed working out:

- com.android.test must be applied WITHOUT a version. AGP is already on the
  buildscript classpath, so `alias(libs.plugins.androidTest)` fails with
  "already on the classpath with an unknown version".
- targetSdk belongs in defaultConfig, not testOptions, for a test module.
- :amethyst has a `channel` dimension, so the test module needs
  missingDimensionStrategy("channel", "play") or the dependency is ambiguous.

The generator ran on a connected device: Macrobenchmark 1.5.0-alpha07 supports
non-rooted generation on API 33+, so no root or AOSP emulator was needed. It
produced 31,497 rules (3.1 MB).

WHAT THE GENERATED PROFILE DOES AND DOES NOT COVER — it captures cold-start on a
LOGGED-OUT app, not the ingest burst. The generator installs the release
applicationId (com.vitorpamplona.amethyst), which is a fresh install with no
account, so the journey recorded a login screen. Measured on the output: zero
rules for justConsume and only 33 of 31,497 rules marked hot. Capturing ingest
needs a journey against a logged-in app, which needs a seeded test identity —
a design decision (a key in the repo is not acceptable), so it is left open.

Both profiles are therefore kept, because they cover different things: the
hand-authored one covers ingest (measured: nterp 42.9% -> 4.2% of ingest worker
CPU, ~1.6x more ingest per core-second), the generated one covers startup and
class loading.

Verified they both reach the shipping artifact by inspecting assets/dexopt/
baseline.prof across variants:

  11,425 bytes  neither profile
  16,341 bytes  hand-authored only        (benchmark build type)
  25,541 bytes  hand-authored + generated (RELEASE variant)

The generated profile only lands in `release`; the custom `benchmark` build type
gets just the hand-authored file, because the plugin wires generated profiles
into release variants. That is why the earlier ingest measurements — taken on
the benchmark build type — could not see it. The runtime effect of the generated
half is NOT measured here: the release APK is unsigned and could not be
installed on the test device.

The 3.1 MB generated file is committed on purpose (saveInSrc), so release builds
do not need a device attached at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 18:37:14 -04:00
Vitor PamplonaandGitHub f26b12958d Merge pull request #3918 from vitorpamplona/perf/baseline-profile
perf: AOT-compile the relay ingest path with a baseline profile
2026-08-13 18:11:01 -04:00
Vitor PamplonaandClaude Opus 5 d39298a9f4 perf: AOT-compile the relay ingest path with a baseline profile
A symbol profile of a release build during cold-start ingest (SM-T220,
speed-profile AOT, simpleperf --app, 15k samples) found the DefaultDispatcher
workers spending:

  42.9%  nterp — ART's interpreter
  10.5%  compiled app code
   9.4%  GC read barriers
   1.2%  secp256k1 signature verification

The app was burning 36x more CPU interpreting bytecode than verifying
signatures, and only 2.2% of samples landed in its own base.odex.

Cause: the APK shipped an 11,425-byte assets/dexopt/baseline.prof consisting
only of the androidx/Compose profiles AGP merges in automatically. There is no
baseline-prof.txt in the source and no baseline profile plugin, so none of the
relay client, decoder, LocalCache or Jackson paths were AOT-compiled on a fresh
install. They ran interpreted through exactly the burst that matters — the app
re-downloads the whole feed on every launch and saturates all 8 cores for
minutes doing it.

Adding a profile for those packages:

                        before    after
  nterp                  42.9%     4.2%
  compiled app code      10.5%    20.4%
  GC read barriers        9.4%     6.7%
  class/method lookup     2.8%     0.3%

Paired A/B on ingest throughput (same session, ingest volume measured as RSS
growth per unit of CPU): 8.9 -> 14.5 MB per core-second, i.e. ~1.6x more
ingest for the same cores, while using slightly less CPU. Arms had real spread
(619-1056 MB/10s), so treat 1.6x as approximate.

Cost: baseline.prof 11,425 -> 16,341 bytes, APK unchanged at 69.6 MB. A forced
full recompile went 12s -> 15s (n=1 each). The on-disk compiled-code footprint
could not be measured — reading the odex needs root and neither test device is
rootable.

This does NOT conflict with Play's Cloud Profiles. Per Android's docs the two
cooperate: "Play uses Baseline Profiles during app installs to optimize the APK
and Cloud profiles—if available", and Cloud Profiles "take several hours to
days after an update to be distributed". So this covers fresh installs and the
window after every release, and local ART profiling still refines both.

It matters most for **fdroid**: that flavor gets no store-side profile delivery
and no Cloud Profiles ever, so the shipped profile is the only AOT those users
get. Hence the explicit androidx.profileinstaller dependency (Apache-2.0,
verified from the published POM; it was already arriving transitively at 1.4.1,
now pinned) — it is what writes the profile into ART on first run when there is
no .dm from a store. Verified on device: "ProfileInstaller: Installing profile
for com.vitorpamplona.amethyst.debug". Both play and fdroid APKs carry the
profile and the installer.

The profile is hand-authored wildcards, which is the weak part: it will compile
methods that never run. It should be REPLACED by one generated from a
macrobenchmark via androidx.baselineprofile, which carries real call counts and
startup flags. That needs a rooted device or an AOSP emulator image; both test
devices here are `user` builds with ro.debuggable=0, so it could not be run.
Filed as follow-up; the file says so in its header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 18:00:44 -04:00
David KasparandGitHub 1236b10691 Merge pull request #3916 from davotoula/fix/i18n-format-specifier-parity
i18n fixes
2026-08-13 23:04:08 +02:00
davotoula 67e4ebd64a i18n fixes
fix: drop the unused poll_results_voters plural
fix: unescape the second placeholder in the Slovenian relay-count string
fix: restore dropped count placeholders in de and pl plurals
2026-08-13 22:22:13 +02:00
Vitor PamplonaandGitHub 45678a09a9 Merge pull request #3915 from vitorpamplona/perf/content-regex-scans
perf: scan note content by literal instead of driving the regex engine
2026-08-13 16:09:11 -04:00
Vitor Pamplona ec2c646069 Merge branch 'main' into perf/content-regex-scans 2026-08-13 16:03:09 -04:00
Vitor PamplonaandClaude Opus 5 d921981e33 test: drop the benchmark's copies of code that is now in production
While the optimizations were being compared, the benchmark carried its own
implementations of each candidate (hasNip19Candidate, anchoredNip19,
fastFindHashtags) so variants could be measured side by side. All three
algorithms now live in production, so those copies were duplicating shipped
code — and measuring the SUPERSEDED version of it, since they still used the
eight-prefix loop rather than the second-char dispatch. A reader comparing
rows would have concluded the "optimized" variant was slower than production.

Removes the three copies, the three sections that measured them, and the two
tests that compared production against them (which had become tautologies once
production adopted the same algorithm).

Nothing is left unguarded — verified rather than assumed, by mutating
production and checking what fails:

  drop the position-0 case in findHashtags
    -> ContentScanTest.callerSuppliedOutputSetIsReusedAndAccumulates
       (quartz commonTest, runs on every target — better than the jvmTest-only
        benchmark case it replaces)
  stop the scan after the first hashtag
    -> hashtagsMatchReferenceUnderFuzz

The randomised half is kept: `hashtagsMatchReferenceUnderFuzz` still fuzzes
3,000 inputs against a verbatim copy of the pre-optimization implementation,
because that is what catches disagreements an anchored scan can have with
`findAll` on awkward `#` placement. The explicit cases moved to ContentScanTest.

Benchmark now measures only production: 679 -> 517 lines, 8 sections covering
every scan the ingest and composer paths touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 16:01:35 -04:00
Vitor PamplonaandClaude Opus 5 996562c3a6 test: cover the TLV decode paths and tryParseAndClean
Closes the last gaps the coverage review found. These are read paths every
ingested note can reach, and two of them had no positive test anywhere.

nprofile1 / naddr1 / nrelay1 / nembed1 were only ever decoded through
`uriToRoute`, which receives one isolated entity. Nothing asserted they survive
being FOUND INSIDE arbitrary text — the path the content scan takes — or that
the relay hints and identifiers they carry come back intact. Adds those, with
nprofile round-tripped through `NProfile.create` so the relay list is checked
rather than assumed.

Two findings while writing them:

- `nrelay1` has no encoder. The codebase can parse it (`NRelay.parse`) but
  there is no `toNRelay()` alongside toNsec/toNpub/toNote/toNEvent/toNProfile/
  toNAddress/toNEmbed/toNCryptSec, and the only fixtures anywhere are negative
  ("nostr:nrelay" -> null). The test builds one from TLV + Bech32 directly, so
  this is the first positive nrelay case in the suite. Adding the missing
  encoder is left out of this PR deliberately — it is API surface, not coverage.
- `nembed1` had no commonTest coverage at all; its fixtures live only in
  androidDeviceTest and iosTest, so the GZip + Event decode never ran on JVM.
  It does now.

tryParseAndClean was untested. Covers scheme/@ stripping, null and non-entity
input, that it accepts ncryptsec1 while the content scan deliberately does not
(different regex), and that `type!! + key` can never render a literal
"npub1null" suffix.

Measured, not changed: tryParseAndClean runs 0.6-1.1 us per call on the short
strings its callers pass — same shape as uriToRoute, nothing to win from an
anchored walk.

Mutation: pointing NProfile.parse at the wrong TLV field is caught by
nprofileWithSeveralRelayHintsKeepsAllOfThem.

quartz jvmTest 4,245 -> 4,256, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:42:47 -04:00
Vitor PamplonaandClaude Opus 5 9ea4b6366e perf: share the anchored scan with parseAllEvents; cover it and uriToRoute
parseAllEvents had the same shape as parseAll before it was rewritten — a
`findAll` over the whole of the content, restarting the regex engine at every
position — and it runs in the composer on the message being typed
(`findNostrEventUris`).

Both now share one `forEachNip19Match(content, regex, action)` walk. The
candidate check covers the union of the prefixes across the three NIP-19
regexes (adding `ncryptsec1`), so a narrower regex simply fails `matchAt` on a
prefix it does not accept — still far cheaper than restarting the engine
everywhere. That keeps one scan to reason about instead of three copies.

  parseAllEvents          before        after
    no entities           23 MB/s       ~1,020 MB/s     (44x)
    with entities         23 MB/s       146-706 MB/s
    767 KB content        33.9 ms       0.75 ms

parseAll is unchanged by the refactor (911 MB/s, same as before).

uriToRoute is deliberately NOT changed. It takes one short URI or id per call —
podcast person tags, address deserialization, search queries, deep links — and
measures 0.6-4.0 us per call. `find` on a ~60 character string has nothing to
win from an anchored walk, so leaving it alone avoids the risk for no gain.
Benchmarked anyway so that stays true, and pinned by tests since the shared
candidate scan sits next to it.

Coverage: 5 new cases in Nip19ScanTest — that parseAllEvents finds event-ish
entities, ignores npub/nsec/nprofile, keeps scanning past a profile entity to
find a later event (the case where sharing a wider candidate set could have
gone wrong), handles the same edges as parseAll, and that uriToRoute still
resolves every form including null/empty input.

Mutation: making the shared walk stop at the first candidate that fails to
match is caught by parseAllEventsSkipsProfilesButStillFindsLaterEvents
(expected:<1> but was:<0>).

quartz jvmTest 4,240 -> 4,245, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:29:19 -04:00
Vitor PamplonaandClaude Opus 5 c355dd8277 test: cover the content parsers' behaviour, not just the rewrite's equivalence
The optimization PR pinned each rewritten scan against a verbatim copy of the
implementation it replaced. That proves "unchanged" but not "correct" — and a
coverage review found the underlying behaviour was barely tested at all:

- NIP19ParserTest has 37 tests but every one of them exercises `uriToRoute`.
  `parseAll` — the whole-content scan run on every ingested note — had no
  behavioural test.
- `findHashtags` and both `IndexedTags` scans had no tests anywhere.

Adds two commonTest suites (41 tests). commonTest on purpose: these are
commonMain parsers and the scans use matchAt/regionMatches, so they must behave
identically on JVM, Android, Apple and native — the existing jvmTest benchmark
only covers one of those.

Nip19ScanTest covers where an entity may sit (start/middle/end, multiline,
glued to a preceding word), what may precede it (bare, `nostr:`, `@`,
uppercase), what must NOT be mistaken for one (`npub1tooshort`, prose full of
n-words, every two-letter prefix stem), invalid checksums, and the greedy
trailing group that makes two adjacent entities parse as one.

ContentScanTest covers what opens a hashtag (start of content vs each kind of
whitespace, and that a non-breaking space does not), what terminates one, and
the IndexedTags failure modes: out-of-range indices, non-numeric and unclosed
refs, a number too large for Int, tag kinds other than p/e/a, and a tag with no
value. Both include a case where an early non-matching `#` must not hide a
later real one.

Every case was written to fail against a mutated parser, and the two riskiest
were verified to do so:

  removing the `i + 1` bounds guard
    -> contentEndingInNDoesNotReadPastTheEnd: StringIndexOutOfBoundsException
  dropping 'e' from the second-char dispatch (nevent1/nembed1)
    -> findsSeveralEntitiesInOneNote: expected:<3> but was:<2>

quartz jvmTest 4,186 -> 4,240, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:18:34 -04:00
David KasparandGitHub 999f678f5b Merge pull request #3914 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-13 21:13:35 +02:00
vitorpamplonaandgithub-actions[bot] eb97dd1174 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-13 19:06:24 +00:00
Vitor PamplonaandGitHub 0b7a9e12f1 Merge pull request #3913 from vitorpamplona/claude/pdf-preview-aspect-ratio-uwtui3
Fix PDF preview layout stability and aspect ratio handling
2026-08-13 15:03:38 -04:00
Vitor PamplonaandClaude Opus 5 a88ad5b39d perf: scan note content by literal instead of driving the regex engine
The NIP-19, hashtag and legacy-#[n] scans all ran `Regex.findAll` over the whole
of a note's content. `findAll` restarts the regex engine at every position in the
string, so cost scaled with content length regardless of whether the content had
anything to find — and most notes have nothing to find.

Each of these patterns has a literal that must be present for any match:

- `nip19regex` can only match at one of eight entity prefixes, all starting `n`
- `hashtagSearch` requires `(?:\s|\A)` immediately before a `#`
- `tagSearch` requires the same before `#[`
- `RichTextParser.tagIndex` requires the literal `#[`

So the scans now jump between candidate positions with `indexOf`/char compares and
apply the regex ANCHORED there via `matchAt`. For the NIP-19 scan, `(nostr:)?@?`
are optional, so anchoring at the entity's own `n` matches the same entities and
captures the same groups the callers read.

Measured on the production content distribution — median 529 B with a tail to
767 KB, taken from an on-device heap dump where 2,541 of 4,573 live Matchers were
running the NIP-19 regex:

                         before          after (no match / with matches)
  Nip19Parser.parseAll   19 MB/s         922 / 61-654 MB/s
  findHashtags           68 MB/s      ~19,000 / ~1,240 MB/s
  IndexedTags            63 MB/s      ~19,900 / ~12,100 MB/s
  RichTextParser '#['    -            4.93x on the #[n] step

Worst case improves most: a 767 KB long-form note went from 40.6 ms to 0.83 ms
per NIP-19 scan.

Two further notes on the NIP-19 scan. Every prefix starts with `n` and English
prose is ~7% `n`, so testing all eight prefixes at each `n` dominated the scan;
dispatching on the second character first cuts that to at most two and is worth
2.1x on its own. And `RichTextParser` gates on `contains("#[")`, not
`startsWith`, because `find()` also matches `#[n]` mid-word.

Behaviour is unchanged. `RegexContentBenchmark` (new, in commons) pins each
rewritten scan against a verbatim copy of the implementation it replaces, plus
~6,000 fuzz cases, the boundary cases where `n`/`#` is the last character, and
hardcoded segment expectations for the RichTextParser '#' path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:59:21 -04:00
David KasparandGitHub 775fb86b39 Merge pull request #3912 from davotoula/i18n/missing-translations-cs-de-sv-ptbr
feat: translate missing strings into cs, de-DE, sv-SE and pt-BR
2026-08-13 20:43:59 +02:00
Claude a12e62265c test: pin DimensionTag against non-numeric doubles from relays
"NaN" and "Infinity" are legal input to Kotlin's String.toDouble(), so both
reach the ratio maths from any relay-carried dim tag. Worth pinning because
the `<= 0.0` rejection cannot stop NaN — every comparison against it is
false — leaving the finite check as the only thing holding that line.

Verified rather than assumed: NaN yields no shape, and Infinity saturates to
Int.MAX_VALUE on both axes for a usable 1:1. Neither produces a shape
Modifier.aspectRatio would throw on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DjHVxiXFncNhoKuNgtSio
2026-08-13 18:38:57 +00:00
Claude 36e03cce50 feat: keep the declared shape of fractional imeta dims
A dim below 1 on either axis truncates to 0 whole pixels, taking its shape
with it: "0.75x1" is a legitimate 3:4 and "0.4x0.4" a legitimate square, but
both parsed to sizes nothing could lay out. Rejecting them loses information
the author actually sent, so keep it instead.

DimensionTag now carries the ratio exactly as declared, computed before the
truncation to whole pixels, and aspectRatioOrNull() prefers it over the
pixel counts. They differ only for fractional dims, where the declared one
is the more faithful of the two — "317.9x498.4" is now that ratio rather
than 317/498. For whole numbers, every well-formed tag, it is the same
number, so nothing about the common path changes.

The field is @Transient: it is derived from the tag text, so an instance
rebuilt from serialized width and height falls back to the pixel counts
rather than carrying a stale ratio. Being transient also keeps it out of the
serialized form of every model that embeds a dim (PictureMeta, VideoMeta,
ProductImageMeta, …), so this reads and writes old data unchanged.

A dim that declares no shape at all — "0x5", "-3x4" — still returns null,
since Modifier.aspectRatio throws on everything it would otherwise produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DjHVxiXFncNhoKuNgtSio
2026-08-13 17:42:19 +00:00
davotoulaandClaude Opus 5 092f597c7a feat: translate missing strings into cs, de-DE, sv-SE and pt-BR
Fills the untranslated backlog in both Crowdin-managed resource trees for
the four target locales: 109 entries per locale in amethyst/src/main/res
(relay_auth_*, poll_results_*, audience_*, concord_invite_*,
backup_keys_nudge_*, copy_text_*) and 34 per locale in
commons/src/commonMain/composeResources (backup_keys_*, new_key_*).

Czech plurals carry all four CLDR categories (one/few/many/other); German,
Swedish and Brazilian Portuguese carry one/other. Formality and terminology
follow each file's existing house style: cs formal, de/sv informal, pt-BR
"voce", and relay renders as rele / Relay / rela / relay respectively.

The 34 commons keys share no names with the amethyst tree, so none were
copied across - all translated fresh against the commons English.

Also converts poll_results_selections from <string> to <plurals>. It read
"- 1 selections" at count 1, and fixing it after translating would have
meant touching every locale twice. The existing pl-rPL and hu-rHU
translations are converted with it (aapt2 rejects a resource-type mismatch
across locales) and given their full CLDR category sets, which corrects
Polish "1 odpowiedzi" to "1 odpowiedz".

poll_results_voters is a bare noun with no format argument, so pt-BR - whose
"one" category spans 0 and 1 - trips lint's ImpliedQuantity with nothing to
disambiguate. Suppressed via tools:ignore, matching the tools:ignore="Typos"
precedent already surviving Crowdin round-trips in these files.

Verified: ./gradlew spotlessApply,
:commons:convertXmlValueResourcesForCommonMain, :amethyst:compileFdroidDebugKotlin
and :amethyst:lintPlayBenchmark (the task CI runs) all pass, the last with
zero errors. No duplicate keys, well-formed XML, no residual missing keys,
no extra translations, and format-specifier parity with each base entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uQksy5spXR8gC8Z5QfsRB
2026-08-13 19:31:52 +02:00
David KasparandGitHub d4543e17e8 Merge pull request #3910 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-13 18:45:51 +02:00
Claude 09fdf3d0b2 fix: never hand a zero or NaN aspect ratio to a layout
DimensionTag.parse rejects only the literal string "0x0", so an imeta `dim`
of "0.4x0.4" truncates past it into DimensionTag(0, 0). aspectRatio() then
returns 0/0 — NaN — and Modifier.aspectRatio throws IllegalArgumentException
on NaN and on 0f alike, taking down the composition around the media rather
than mis-sizing it. Eleven call sites fed dim?.aspectRatio() straight into a
layout, so a tag any relay can carry could crash a feed containing an image
or a video. ImageGallery was the one site already guarding with hasSize().

Closed at both sources rather than at the eleven consumers:

- DimensionTag gains aspectRatioOrNull(), null unless hasSize(), and every
  production call site now uses it. The raw accessor stays for callers that
  want the number, with KDoc pointing at the safe one.
- MediaAspectRatioCache.add checked only the divisor, so a reported width of
  0 stored a perfectly dividable 0f — just as unusable downstream. It now
  requires both sides, leaving the entry empty for a later, better report.

mediaSizingModifier, where six of the eleven converge, treats a non-finite
or non-positive ratio as no ratio — a backstop for a future caller that
forgets, not the primary guard.

Every remaining ratio reaching Modifier.aspectRatio was traced: VideoView
already guarded its own division, PiP and SoftwareApp read only the cache,
and the rest are literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DjHVxiXFncNhoKuNgtSio
2026-08-13 16:16:59 +00:00
Claude b070d1ed5c fix: skip zero-sized imeta dim when reserving the PDF preview box
DimensionTag.parse only rejects the literal string "0x0", so a `dim` of
"0.4x0.4" truncates past it into DimensionTag(0, 0). Guard with hasSize()
before reading aspectRatio(), matching what ImageGallery already does, so a
PDF with such a tag reserves nothing rather than a box computed from 0/0.
previewAspectRatio() still backstops the rendered-page case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DjHVxiXFncNhoKuNgtSio
2026-08-13 15:51:16 +00:00
Claude 130c69377a fix: guard PDF preview against degenerate page sizes and main-thread cache I/O
Three defects found auditing the aspect-ratio caching work.

Modifier.aspectRatio throws IllegalArgumentException on 0f and on NaN, and
the 0.2 clamp catches neither — NaN.coerceAtLeast(x) is NaN, since every
comparison against NaN is false. Both values are reachable: a malformed PDF
whose first page measures 0x0, and an imeta `dim` that reaches 0x0 by
truncation ("0.4x0.4"), which DimensionTag.parse does not reject because it
only rejects the literal string "0x0". Reading `dim` here is new, so this
turned a tag any relay can carry into a crash of the whole feed's
composition. previewAspectRatio() is now the single place that guarantees
the number is finite and positive, falling back to Letter portrait.

PdfFetcher.fetchSnapshot ran its cache-hit fast path before entering
Dispatchers.IO, so openSnapshot() executed on whatever thread the caller was
on — for the feed card, the main thread. That call contends on the global
DiskLruCache lock, which Coil's cleanup pass holds across a burst of unlink
syscalls (documented at length in DeferredDeleteFileSystem), so a frame
could block for the whole burst. The hit path is exactly the one a PDF card
takes when it scrolls back into view. The viewer dialog already wrapped its
call; now the fetcher guarantees it for every caller.

Image(bitmap = …) holds its BitmapPainter in a remember keyed on the
ImageBitmap, but asImageBitmap() allocates a fresh identity-compared wrapper
per call, so calling it inline rebuilt the painter on every recomposition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DjHVxiXFncNhoKuNgtSio
2026-08-13 15:49:21 +00:00
Claude ada329cf3c fix: remember PDF preview aspect ratio so the feed stops jumping
A PDF card in the feed rendered its first page from scratch on every visit,
and until that render landed it collapsed to a bare filename row. Scrolling
away and back therefore replayed the same grow-into-place animation the very
first sight of the post has to accept, because — unlike images and videos —
nothing recorded the page's shape.

Store it in MediaAspectRatioCache, the same URL-keyed cache the image and
video paths already fill, and reserve that box in the loading placeholder.
The first sight of a PDF still grows into place, since that render is what
fills the cache; every later visit lays out at the right shape immediately.

The cache is read ahead of the imeta `dim` tag here: it holds the page size
this card measured itself, so an author-supplied `dim` that disagreed would
guarantee the jump on every single visit. The 0.2 ratio floor moved into a
shared previewAspectRatio() helper so the reserved box and the rendered
thumbnail are clamped identically — applying it on one side only would
misreserve for exactly the tall pages the clamp exists to protect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DjHVxiXFncNhoKuNgtSio
2026-08-13 15:29:40 +00:00
vitorpamplonaandgithub-actions[bot] 537dbf247e chore: sync Crowdin translations and seed translator npub placeholders 2026-08-13 15:13:34 +00:00
Vitor PamplonaandGitHub 1acc6d405d Merge pull request #3911 from vitorpamplona/claude/gpu-mining-phone-pow-6dj8l7
NIP-13: refresh created_at while mining, analyze GPU path
2026-08-13 11:10:35 -04:00
Claude 43b20aa035 fix(pow): never end a mining pass while created_at is pinned
Pre-merge audit turned up a bug in the created_at refresh. The nonce search
enumerates its byte alphabet in order at every position, so the random nonce
base is overwritten before the first hash -- it only exists to make the
placeholder findable with indexOf. created_at is therefore the ONLY thing
that makes a new pass search anywhere new, which the new
PoWMinerDeterminismTest pins down: three different bases mine the identical
nonce, and bumping created_at by one second does not.

That made the one-second pass budget unsafe whenever the clock is pinned --
a backwards wall-clock step mid-mine, or a restored job whose template is
stamped ahead of this device. maxOf holds created_at still, passedOver
suppresses the nonce widening, and the loop no longer has a nextSize bound,
so every pass re-hashed a byte-identical candidate sequence forever: cores/2
burning with zero chance of success until isActive cancelled.

A pass now ends only once the clock has actually advanced past the timestamp
that pass is mining under. Pinned, it stays in the pass and falls back to
exhaust-then-widen, exactly as it behaves with no clock at all. The elapsed
check stays as the cheap gate so the clock is out of the hot loop for the
first second and restamping is still capped at created_at's resolution.

Also from the audit: the send-without-pow fallback stamped the clock
unclamped while the mining path clamped with maxOf, so the two disagreed on
a future-stamped template despite the comment saying they matched. And the
two anonymous post paths still pre-stamped a fresh template that the miner
now restamps anyway -- the same redundancy 210a18fc removed from the queue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
2026-08-13 14:59:07 +00:00
Claude 7a271a96fe docs: the 3.7x SHA-256 gap was a bad measurement, not a real gap
Chased the unexplained gap between quartz's MessageDigest path (172
ns/block) and OpenSSL's SHA-NI path (47 ns/block) on the same CPU. There
is no gap: re-measured with best-of-5, sha256Into runs at 46-48 ns/block
at every payload size, matching OpenSSL's 47 exactly.

Ruled out in turn: the HotSpot intrinsic is active (disabling it costs 8x,
C1-only costs 12x), the SUN provider is the one serving SHA-256,
compilation shape is irrelevant (OSR loop vs hot C2 method differ by
1.02x), the Gradle test worker reproduces the fast number, both the
original timing method and a best-of-5 agree, and four CPU hogs on four
cores change nothing.

What is left is the original sample itself: one unguarded 500 ms window,
no best-of-N, taken in the same Gradle invocation as the module's first
full compile with a 6 GB Gradle daemon and an 8 GB Kotlin daemon settling
on a 4-core container.

Corrected model is 42 ns fixed + 46 ns per block, which changes the
midstate estimate rather than the GPU verdict: ~3x on JVM targets (the
fixed cost is small), while Android now turns on one number nobody has
measured -- Conscrypt's per-digest JNI cost, which OpenJDK does not pay at
all since its intrinsic runs over pure Java. Flags that as the measurement
to take on-device before any midstate or native-batching work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
2026-08-13 14:27:30 +00:00
Vitor PamplonaandGitHub f6778b43af Merge pull request #3909 from nrobi144/feat/key-backup-nsec-exposure
Discoverable key backup: guided first-run save-your-keys + Settings backup (Desktop) + Android nudge
2026-08-13 10:18:28 -04:00
Claude 210a18fce2 refactor(pow): drop the redundant pre-mining created_at stamp
The queue re-wrapped the template with a fresh created_at before handing it
to the miner, but the miner now stamps the top of its first pass from the
same clock -- so a job that waited in the queue or was restored from disk
picks up "now" either way. The send-without-pow fallback keeps its own
stamp; nothing mines on that path.

Also records the shipped created_at work in the plan doc, including why the
new parameter goes after isActive (so trailing-lambda call sites keep
binding to it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
2026-08-13 13:51:26 +00:00
Claude 6c7e4fd0dc feat(pow): keep created_at current while mining
NIP-13: "It is recommended to update the created_at as well during this
process." We only did half of it -- PoWPublishQueue and the anonymous post
paths re-stamped the timestamp when a worker picked the job up, but the
mining run itself was frozen. At 28 bits that is about a minute and at 30
several, so a post could still land in the feed minutes in the past.

PoWMiner.run/mine take an optional refreshCreatedAt clock. A search pass
now ends on either nonce-space exhaustion or a one-second budget (matching
created_at's resolution), and each new pass re-stamps from the clock and
rebuilds the payload. The returned template carries the timestamp its nonce
actually commits to. Restamps are clamped with maxOf(previous, now) so a
wall clock stepping back cannot drag a post into the past, and a pass that
stopped on the clock does not widen the nonce -- its space is untouched, it
just gets searched under the next timestamp.

Left frozen wherever created_at is meaningful: scheduled posts (the queue
reuses the existing predicate, renamed refreshCreatedAtOnStart ->
refreshCreatedAt now that it covers the whole run), NIP-59 gift wraps with
deliberately randomized timestamps, and amy pow, which mines a template the
caller supplied. Replaceable and ephemeral kinds never reach the miner.

Trailing-lambda call sites became explicit isActive = { ... } so the new
optional parameter cannot capture them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
2026-08-13 13:41:23 +00:00
Claude 8057898460 docs: correct the midstate estimate and spec created_at refresh while mining
Measuring both regimes on the same CPU changes the recommendation. OpenSSL
with SHA-NI does a block in 47 ns where quartz's MessageDigest path takes
172 ns, so the earlier 3.5x midstate figure was the software-SHA number.
ARMv8 crypto extensions put Android in the fast regime, where the ~200 ns
JNI floor dominates and a midstate is worth ~1.2x at best -- via clone(),
which adds a second JNI call, or via a Kotlin compression function, which
gives up the hardware instruction and ends up slower than today.

The Android win is therefore batching many attempts under one native call,
not the midstate. Midstate still stands for desktopApp/cli/geode.

Also specifies advancing created_at during mining as NIP-13 recommends.
PoWPublishQueue and ShortNotePostViewModel already refresh it at mining
start; a long run goes stale the same way. Bounds a search() pass by a
wall-clock budget as well as nonce exhaustion, passes the miner a clock
rather than making EventTemplate.createdAt lazy (it is @Serializable and
checkpointed), and reuses refreshCreatedAtOnStart's predicate so scheduled
posts keep their intentional future timestamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
2026-08-13 13:14:28 +00:00
Claude 8a3e1056c2 docs: analyse GPU offload for the NIP-13 PoW miner
Investigates whether the phone GPU can speed up PoWMiner. It cannot, by
enough to matter: ARMv8 CPUs implement SHA-256 in silicon (SHA256H et al,
dispatched by BoringSSL) and mobile GPUs have to emulate it in ~3400
integer ALU ops per block, which puts a flagship GPU at ~1.4x the CPU
cores the miner already uses and behind them on mid-range hardware.

Records the measured cost model of the current hash loop (76 ns fixed +
172 ns per 64-byte block) and the payload layout showing 6 of 8 block
compressions per attempt are recomputing a constant prefix, which is
where the real 2-4x is: a midstate that re-hashes only the tail, on
every platform rather than Android flagships only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017pEArg58zWxDJ7EzzzPdXT
2026-08-13 06:01:58 +00:00
nrobi144andClaude Opus 4.8 b6210e9fd0 feat(desktop): guided first-run key backup + reachable settings backup
Reworks the Desktop new-account key backup after testing surfaced that the
original card never rendered and the settings entry sat on dead code.

- Guided 3-step "Save your keys" onboarding (NewKeyOnboardingScreen), full
  window + scrollable so it never clips. Used by BOTH the cold-start login and
  the in-app "+ Add Account" generate paths. Retires NewKeyWarningCard.
- Split key generation from activation (AccountManager.buildNewAccount +
  activateAccount + begin/finish/cancelNewAccountOnboarding). generateNewAccount
  previously flipped account state immediately, tearing down the screen before
  any backup UI could show — the root cause of "no save-your-keys prompt".
- Move BackupKeysCard into the reachable Settings screen (RelaySettingsScreen);
  the old ProfileScreen host had no call sites.
- Fix the password show/hide eye (was a non-clickable Icon) in both the
  onboarding and settings encrypted-copy sections.
- Run NIP-49 encryption off the UI thread so the "Copy encrypted" button stays
  responsive and reliably flips to "Copied!"; match the plain copy button style.
- Add EncryptedKeyBackupTest: nsec -> hex -> Nip49 encrypt -> decrypt round-trip
  (and wrong-password fails).
- Plans + manual testing sheet under desktopApp/plans/.

Known follow-up: Desktop loginWithKey does not yet import ncryptsec1, so
encrypted backups can't be restored on Desktop yet (Android already handles it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 06:54:55 +03:00
Vitor PamplonaandGitHub 1866007e99 Merge pull request #3908 from vitorpamplona/claude/inostrclient-fetchall-signature-sa136w
Remove maxTotalMs ceiling from fetchAll/fetchAllWithHooks
2026-08-12 23:35:34 -04:00
Claude e1ab4d6c56 refactor: drop the unused diagnoseSlow drain flag
No caller ever passed it, so logSlowDrain has never run. Removing the flag
takes its only call site with it.

drainResult's KDoc now notes where a stall diagnostic would go if one is wanted
again -- the result already names every stalled relay and why each of the rest
stopped, so it no longer needs a parameter to report that.

This shifts pendingOnAuthRequired from the 4th positional slot to the 3rd, and
both it and diagnoseSlow are Boolean, so a positional third argument would have
changed meaning silently rather than failing to compile. Checked every .drain /
.drainResult call site with a balanced-paren parse: none passes a third
positional argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVEFY78vGUEaTgSRjZLWT9
2026-08-13 03:24:44 +00:00
Claude 94d5f18fef fix: repair doc rot and dead imports left by the FetchAllResult refactor
Audit follow-up. Nothing functional was broken, but the refactor left
references to parameters that no longer exist:

- Context.drain's KDoc still documented "When [deadOut] is provided…", a
  broken KDoc link to a removed parameter. Rewritten to point at drainResult.
- Three doneOut references in the accessory's comments and in the KDoc of the
  anyRelayServed / authRefusedRelays extensions, now pointing at
  FetchAllResult.doneReasons.
- Two unused `anyRelayServed` extension imports. ktlint's no-unused-import rule
  keeps them because the identifier still appears in the file as a member
  access (result.anyRelayServed), so spotless could not catch these.

Also caches authRefused, which allocated a filtered map plus a keys view on
every access while dead was already cached; anyRelayServed stays computed since
it short-circuits and allocates nothing.

Adds aMixedFetchPartitionsRelaysByWhatEachOneDid, which pins the invariant the
refactor rests on -- that every relay lands in exactly one of served / failed /
stalled, that a relay which answered is never reported stalled, and that dead is
derived from the reasons. Verified by mutation: replacing the stalled set with
emptySet() fails this test and silenceEndsTheFetchAfterOneIdleWindow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVEFY78vGUEaTgSRjZLWT9
2026-08-13 03:20:10 +00:00
Claude a12dda31e9 refactor: return a FetchAllResult instead of three out-parameters
deadOut, doneOut and onTimeout all smuggled results back through the argument
list, and none of them needed to.

deadOut was never independent information -- it is doneReasons run through
classifyDrainFailure, so a caller could hold one without the other or read a
stale half. It is now a lazy derived view. onTimeout fired with (stalled,
doneReasons, collected), all three of which are simply the return value taking
the long way round; callers test result.stalled at the point where they act on
it instead. And an out-param reads as optional when doneReasons is anything
but: it is what stops a read-merge-write on a replaceable event from
overwriting entries it failed to read.

fetchAllWithHooks now returns FetchAllResult(events, doneReasons, stalled) with
derived dead / anyRelayServed / authRefused. fetchAll is unchanged externally.

The CLI's Context.drain keeps returning the event list, so its ~65 callers are
untouched; a new drainResult exposes the whole thing for the two read-merge-write
call sites that need the reasons, and it now runs the diagnoseSlow logging off
result.stalled rather than a callback.

Reason strings are deliberately left as-is. Typing them would pull in
GrapeRankCrawler, which has its own drain and calls classifyDrainFailure(String)
directly -- unrelated to the smell being fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVEFY78vGUEaTgSRjZLWT9
2026-08-13 02:47:07 +00:00
Claude c5b45a49cc fix: make the fetchAll drain loop observe cancellation
Audit of the maxTotalMs removal: cancelling the caller is not a drop-in for
the cap it replaced, and one gap was a real regression.

The collect loop's fast path is all tryReceive, which never suspends, and a
suspending onEvent hook that returns without suspending performs no
cancellation check either. The removed cap checked capChannel.tryReceive()
on every iteration, which was the loop's only escape hatch. Without it a
relay feeding faster than we drain spins there forever, deaf to an enclosing
withTimeout -- the new test drains all 200_000 events after cancel() when
the ensureActive() is taken back out, and against a real relay that keeps
feeding it would never end at all. fetchAllPages already does this per page.

Verified as fine, now pinned by tests: the finally block still unsubscribes
under cancellation (nothing in it suspends, so it runs rather than throwing),
and the auth resolver dies with the enclosing coroutineScope instead of
leaking.

Documents what cancelling costs that the cap did not: collected events,
doneOut/deadOut and onTimeout are all lost when the stack unwinds, and a
suspending hook can be cancelled mid-write. Callers that need the partial
results should accumulate inside onEvent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVEFY78vGUEaTgSRjZLWT9
2026-08-13 02:00:40 +00:00
Claude 4b8d143376 refactor: drop maxTotalMs from fetchAll and fetchAllWithHooks
The wall-clock ceiling could not distinguish a relay legitimately streaming
a large backlog from a never-terminal trickle -- both look like steady
arrival -- so it cut healthy fetches at a fixed multiple of the idle window.

These are suspending functions: a caller that wants a hard deadline composes
one with withTimeoutOrNull, which is what every other accessory in this
package already relies on. The idle window stays, since only the accessory
can implement it (it needs the message stream).

Removes the parameter, the delay()-based watchdog, and the cap channel from
the collect loop; the post-loop drain now always runs, and onTimeout fires
on a stall alone. Docs in the package README, fetchAllPages and AuthOutcome
that referenced the ceiling are updated, and the test that pinned the cap is
replaced by one pinning the new contract: an endless trickle is bounded by
the caller's withTimeoutOrNull.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVEFY78vGUEaTgSRjZLWT9
2026-08-13 01:35:06 +00:00
Vitor PamplonaandGitHub 1ff1077d58 Merge pull request #3905 from vitorpamplona/claude/nip42-auth-quartz-fetch-90jyve
NIP-42: wait for auth challenge resolution instead of timing out
2026-08-12 20:29:59 -04:00
Vitor PamplonaandGitHub bb4605a629 Merge pull request #3907 from vitorpamplona/fix/okhttp-blocking-io
perf: stop blocking coroutine workers on synchronous OkHttp calls
2026-08-12 19:14:09 -04:00
Vitor PamplonaandClaude Opus 5 57aa2e38f5 test: drive BlossomClientTest's fakes through Call.enqueue()
BlossomClient now calls executeAsync(), which is enqueue(Callback) plus
invokeOnCancellation { cancel() } — it never touches Call.execute(). The MockK
fakes only stubbed execute(), so all 10 tests failed on the unstubbed enqueue.

Adds a small bridge that answers enqueue() from the execute() stub already set
up in each test, so the response fixtures are unchanged and only the call path
moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:07:02 -04:00
Vitor PamplonaandClaude Opus 5 17609acc1e fix: keep the LNURL body read off the UI thread and bound its size
SendDialog's two LNURL effects returned the Response out of their
withContext(Dispatchers.IO) block and then called response.body.string()
outside it. body.string() is a blocking socket read and LaunchedEffect resumes
on the composition dispatcher, so the read ran on the EDT — the withContext
gave the appearance of off-thread IO while doing almost none of it, since the
cost of a request is mostly the body transfer. A slow or hostile LNURL server
froze the wallet dialog.

The Response was also never closed with use { }. On the happy path
body.string() closes the source itself, but both effects are keyed on
sendState and restart on every state change, so a cancellation between the
headers arriving and the body read leaked the connection.

Both effects now share fetchLnurlJson(), which does the request, the capped
body read and the parse inside one withContext(Dispatchers.IO) and always
closes the Response.

Adds the two missing guards:

- Size cap. LUD-06/LUD-16 documents are a few hundred bytes and the body is
  buffered in memory, so it is capped at 64 KiB. Note this does NOT copy
  Nip11Fetcher's pattern, which does not work: okio's readUtf8() is
  buffer.writeAll(source) + readUtf8(), and writeAll drains the entire
  upstream, so request(MAX) only pre-buffers and never limits the read. With a
  1 MB source and a 1 KB "cap" that pattern returns all 1,000,000 bytes.
  Reading from source.buffer after request(MAX + 1) caps for real.
  Nip11Fetcher has the same latent bug and is left for a separate change.

- Status code. Not a hard isSuccessful gate: LUD-06 servers report failures as
  HTTP 200 + {"status":"ERROR","reason":...} and some use 4xx with a usable
  reason body, so throwing before parsing would discard the server's message.
  The body is parsed first and the status is surfaced only when the body is
  not usable JSON — which is the HTML-error-page case that previously produced
  a raw Jackson error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:01:24 -04:00
Vitor PamplonaandClaude Opus 5 8020102364 perf: replace synchronous OkHttp execute() with executeAsync()
Every coroutine dispatched to Dispatchers.IO is stamped BlockingContext at
dispatch time, so its worker releases its CPU permit and the shared kotlinx
scheduler grows past ncpu. A blocking execute() holds one of those threads for
the whole request; executeAsync() suspends until the response headers arrive
and releases the thread across the network round-trip.

Converts 27 call sites across quartz, commons, amethyst, nestsClient,
desktopApp and cli. executeAsync() was already the house pattern (63 existing
uses); these were the stragglers.

The withContext(Dispatchers.IO) wrappers are kept on purpose: executeAsync()
only suspends until headers, and reading the body (string()/bytes()) is still
a blocking read. Moving those onto Dispatchers.Default would hold its
core-sized CPU permits and starve the pool.

Four private helpers become suspend (decodeGifFrames, fetchFromNetwork,
downloadFirstChunk, extractFirstFrame). Each had exactly one caller, already
inside a withContext(Dispatchers.IO) in a suspend function, so nothing
propagates further.

desktopApp gains the okhttp-coroutines dependency (Apache-2.0, same version as
the okhttp it already ships).

Not converted: NipCommand.fetchText and RelayCommands.info in the CLI. amy is a
short-lived process whose main is runBlocking { dispatch(argv) } — there is no
long-lived dispatcher pool or UI thread to protect there, so blocking is by
design and making them suspend would only add risk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:01:08 -04:00
Claude 1c4371921a Merge remote-tracking branch 'origin/main' into claude/nip42-auth-quartz-fetch-90jyve 2026-08-12 23:00:31 +00:00
Claude 828ac399e7 fix(nip42): import the multiplatform Volatile in NostrClient
`@Volatile` on the new auth-responder registry resolved on JVM, where
`kotlin.jvm.Volatile` is a default import, and failed on every Kotlin/Native
target — `:quartz:compileKotlinIosSimulatorArm64` with "Unresolved reference
'Volatile'". The file had no prior `@Volatile`, so nothing had ever pulled the
import in; the sibling `RelayAuthStatus` already uses the correct one.

Checked the whole of quartz commonMain rather than just this site: every other
file that annotates with `@Volatile` imports `kotlin.concurrent.Volatile`, so
this was the only instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8u58RJgTEGkbZH8ygqR35
2026-08-12 23:00:17 +00:00
Vitor PamplonaandGitHub c94bb81df5 Merge pull request #3906 from vitorpamplona/claude/relay-auth-group-coverage-o64zcn
Auth: recognize NIP-29 groups and Concord communities as joined venues
2026-08-12 18:58:22 -04:00
Claude 09e160cefc refactor(nip42): drop the auth flag from every accessory that didn't need it
Plumbing pendingOnAuthRequired through the wrappers was answering the wrong
question. A derived default evaluates against the receiver of the function that
DECLARES it, so every wrapper already inherited the right answer for the client
it was called on simply by calling down into fetchAllWithHooks and passing
nothing. The parameters never carried the fix; they only added an override.

And the override barely has a meaning. With no responder attached, `true` and
`false` produce identical outcomes — awaitAuthOutcome returns NO_RESPONDER
without waiting, and the terminal reason is auth-refused: either way. With one
attached, waiting is the entire point, and it is bounded (the grace, the relay's
OK false, or the caller's own idle window), so there is no latency argument for
opting out. A boolean whose correct value is computable, on seven functions, is
surface area inviting callers to get it wrong.

Removed from fetchAll (all six shorthands and the map form), fetchFirst,
fetchAllPages (both), fetchAllPagesFromPool, fetchAllPagesFromPoolWithHooks and
count (both) — eleven parameters. Those accessories now just do the right thing.

Kept on fetchAllWithHooks alone: it is pre-existing API that downstream callers
pass today, so removing it would be the breaking change this work set out to
avoid, and that function's documented job is to be the option-rich sibling.

Pins the propagation in a test — fetchFirst's single-relay shorthand forwards
nothing and must still read an auth-gated relay — because that property is the
whole reason the plumbing is unnecessary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8u58RJgTEGkbZH8ygqR35
2026-08-12 22:47:16 +00:00
Claude cf7fd9d914 fix(nip42): don't sign one challenge twice, and name the wall either way
Audit of the NIP-42 fetch work turned up one real defect, one inconsistency of
its own making, and one allocation worth removing.

**A slow signature was signed twice.** reauthenticateIfAuthRequired coalesces a
burst of `auth-required:` CLOSEDs by skipping while an AUTH is in flight, but it
only asked hasFinishedAllAuths(), which knows about AUTHs already SENT. A
signature is not instantaneous, and the ordinary sequence — relay challenges at
connect, the first REQ is refused before the signature comes back — leaves the
watcher empty, so the guard passed and a second signing pass started for the
same challenge. On a NIP-55 or NIP-46 signer that window is a user-facing
prompt, so the user got a second one; it also put two threads into
saveAuthSubmission's non-atomic check-then-put at once, which can let both AUTHs
onto the wire. isSigning() is the half the guard was missing. The sibling tests
never saw it because they run on Dispatchers.Unconfined, which completes the
signature inline; the new test uses a real dispatcher and a signer that takes
time, and fails 2-vs-1 without the fix.

**An auth wall is now named whether or not we waited for it.** fetchAllPages
already reported End.AUTH_REQUIRED unconditionally while fetchAllWithHooks only
wrote `auth-refused:` when pendingOnAuthRequired was on, so authRefusedRelays()
silently missed every client with no responder attached — exactly the
configuration the reason exists to describe. What the relay said does not depend
on whether anyone was there to answer it.

**authSuccessMarks() replaces a per-relay associateWith.** The old form
allocated one entry per relay on every fetch, auth-gated or not, to record a
value the lookup already defaults to. It now records only relays that have
already authenticated (near-empty in practice) and returns emptyMap() when
nothing answers AUTH at all — this runs per fetch on fan-outs of thousands.

Also stops IAuthStatus.NO_AUTH_STATE handing out a MutableStateFlow upcast to
StateFlow, which any caller could cast back and mutate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8u58RJgTEGkbZH8ygqR35
2026-08-12 22:38:47 +00:00
Claude 90043b1e87 fix(relay-auth): four defects the venue-coverage change introduced
Audit of the previous commit. Each of these is the same shape: an id that
looks like something it isn't.

- A Concord community id is a bare 64-hex string, so `rememberVenueLabel`'s
  public-chat branch took it — and on a POST_VENUE, which is exactly what a
  pending plane wrap now derives, that branch *get-or-creates*. So naming a
  Concord room minted the phantom public chat (plus its metadata subscription)
  this function exists to avoid, then named the room after the phantom's own
  nevent, making the Concord lookup below it unreachable. Both joined-room
  shapes now resolve first, and the community name is read off the folded
  ConcordChannels in LocalCache before the active account's joined list —
  LocalCache is shared by every logged-in account, so a prompt raised for one
  account no longer degrades to a hex prefix while another is on screen.

- `venueHostRelays()` folded the process-wide BuzzWorkspaces singleton into a
  per-account venue set, so every logged-in account auto-authenticated on a
  workspace only one of them joined — silently revealing a bystander account's
  npub where the user used to be asked. `isFirstParty` cannot catch that: the
  Buzz set carries no account. Only per-account list events belong here; the
  workspace's own first-party reason in AuthCoordinator is unchanged.

- A NIP-29 group id is scoped to its host relay and is routinely generic (`_`
  is the spec's relay-wide group), so matching on the id alone let a group we
  merely browsed elsewhere pass for one we joined. The trusted-venue check now
  takes the (relay, venue) pair. A Concord community id is a 64-hex derived
  value that names one community wherever it is served, so it still matches on
  its own.

- Marmot (MLS) carries its group id in an `h` tag exactly like NIP-29, so the
  new rule read a kind-445 send as a post into a room — an opaque MLS id with
  no metadata behind it, 64-hex, and therefore another phantom-channel mint at
  label time. MLS kinds keep their prior reading.

Two allocations out of the auth path while here: only a stream-wrap kind pays
for the plane lookup (it ran per pending event), and the lookup itself is a
membership test on the session rather than a union of its channel and
prior-epoch address sets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHCHwnWjVt83Ne3qGBeGq6
2026-08-12 22:28:47 +00:00
Claude a4fdd3583b test(nip42): assert what the per-connection contract actually says
aSecondFetchOnAnAuthenticatedConnectionIsNotRefusedAgain pinned the first
fetch's AUTH count at exactly 1. That is not what (e) is about — the contract
is that the SECOND fetch costs no authentication — and how many AUTHs the
first one took is a property of the responder, not of the accessories.

Pinning it also made the test fail under load on an unrelated, pre-existing
race: RelayAuthStatus.saveAuthSubmission does a non-atomic check-then-put on
an LruCache, and two signing coroutines run concurrently in the normal case
(the connect-time challenge, plus the re-auth the first REQ's auth-required
CLOSED triggers while nothing has been submitted yet). Both can observe the
challenge as unsent and both send. Harmless — the relay OKs both and the
anti-loop guard still holds — but enough to move the count off 1.

Now asserts the mark does not MOVE across the second fetch, which is the
actual claim, and holds however many AUTHs the first fetch took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8u58RJgTEGkbZH8ygqR35
2026-08-12 22:24:08 +00:00
Claude d99753ef58 fix(nip42): stop reading auth-gated relays as empty ones
A relay that gates reads answers the REQ with `CLOSED auth-required:` before it
will send a single event. Every fetch accessory treated that as terminal, so the
fetch returned EMPTY while the RelayAuthenticator on the same client was still
signing the challenge on the same socket — the events arrived milliseconds after
the caller had already given up. Measured against a relay that really gates reads
(quartz's own NostrServer under FullAuthPolicy, driven over the in-process socket
by a real NostrClient + RelayAuthenticator): `fetchAll` returned 0 events in 18 ms
for a relay holding 5, `fetchFirst` returned null in 13 ms, and `fetchAllPages`
reported `End.CLOSED` in 20 ms.

`fetchAllWithHooks` had a `pendingOnAuthRequired` flag that fixed this, but it was
plumbed through nothing — not `fetchAll`'s six overloads, not `fetchAllPages`,
`fetchFirst`, `fetchAllPagesFromPool` or `count` — so the only way to read an
auth-gated relay correctly was to drop to the option-rich form by hand.

Four changes:

* **Plumbed everywhere.** Every read accessory takes `pendingOnAuthRequired`.
  Parameters are appended, so no positional call site breaks.

* **The default derives from the client, not a constant.** Waiting for a challenge
  is right when something will answer it and dead time when nothing will, so the
  default is `hasAuthResponder()`. `INostrClient` gains an auth-responder registry
  (default no-op, so delegating wrappers forward it for free) and
  `RelayAuthenticator` registers itself. A client with no responder behaves exactly
  as before, in under 10 ms.

* **The wait is bounded by the AUTH, not the idle window.** `awaitAuthOutcome`
  waits a short grace for a responder to pick the challenge up, then for it to
  settle — bounded by the caller's own `idleTimeoutMs`, which yields the guarantee
  that makes the derived default safe to ship: an auth-gated relay costs at most
  what a silent relay already cost. This needed two additions to the auth model,
  because the existing phase could not answer the question: a `SIGNING` phase
  (IDLE meant both "nobody is answering" and "the signature is being written", and
  a signer holding a user prompt lives in that gap), and a monotonic
  `successCount` (AUTHENTICATED cannot tell "an AUTH just landed for my refusal"
  from "authenticated an hour ago and gated for some other reason").

* **An unsatisfied wall is visible, and is not a dead relay.** It gets its own
  terminal reason (`auth-refused:<msg>`, read via `doneOut.authRefusedRelays()`),
  its own `PagedFetchResult.End.AUTH_REQUIRED`, and its own
  `DrainFailure.AUTH_REQUIRED` — flagged `dropFromRouting = false`, because the
  relay answered and serves the same query to an identity it accepts. An absent
  `doneOut` entry still means only "nobody told us", never "auth-gated".

AUTH is per-connection, so no caller-side retry loop is needed or wanted; a test
pins that the second fetch on an authenticated socket is served outright.

Tests: an end-to-end harness (AuthGatedRelayHarness) running the real NIP-42
exchange over quartz's own relay, plus virtual-time tests for the two-stage wait
including a signer prompt that outlives the grace by 60x and one nobody answers
at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8u58RJgTEGkbZH8ygqR35
2026-08-12 22:21:23 +00:00
Vitor PamplonaandGitHub d069c434cf Merge pull request #3904 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-12 18:15:40 -04:00
vitorpamplonaandgithub-actions[bot] 6f62675dd6 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-12 22:14:55 +00:00
Vitor PamplonaandGitHub 46b05d4fb8 Merge pull request #3903 from vitorpamplona/claude/shortpost-lock-list-selection-3pg1s2
Add bulk audience picker for short-note composer
2026-08-12 18:11:48 -04:00
Claude 9cca9efe8a feat: bring the audience flap to the comment composer
The redesign shipped only in ShortNotePostScreen. The NIP-22 comment
composer still rendered the old flat Notifying row — bold grey label,
wall of chips, alpha(0.4) for muted — and it backs four more screens: the
url, geohash, hashtag and generic-comment posters. Both composers now use
AudienceFlap and the manage sheet.

Rather than duplicate ~60 lines of state glue, the shared behaviour moves
into IAudience, following the composer-interface convention already in the
package (IExpiration, IMessageField, IZapField, IZapRaiser,
ILocationGrabber). Each ViewModel keeps its own backing field — pTags on
the short note, notifying on the comment — and maps onto it, so
createTemplate and the draft loaders keep reading the name they always
did. The rules themselves still live in the unit-tested AudienceSelection;
the interface is thin plumbing over them.

The comment composer gains what it never had: a way to add people to the
notify list at all. It previously passed no onAddUser, so the row was
mute-only. It now reaches the same search-and-lists sheet, including bulk
adds from people lists and follow packs with the same caps and the same
group-chip undo. Its flap stays in notify form — a comment is never
gift-wrapped, so isPrivate is fixed false and no tint or lock appears.

Notifying.kt is deleted: with both call sites migrated it had no consumers
left anywhere in the repo. Its notify_add_user string stays in
strings.xml, like private_note_no_receivers before it, since pruning a
default-locale string alone would trip lint's ExtraTranslation across the
Crowdin-managed locale files.

Full amethyst suite green at 1197.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
2026-08-12 21:55:16 +00:00
Claude c5f80bc7b0 fix(relay-auth): count relay groups and Concord rooms as "a room I joined"
"…it's my relay, or a room I joined" — the default auto-login exemption —
only ever knew two kinds of room: NIP-28 public chats and NIP-72 communities.
A NIP-29 relay group's id and a Concord community's id are on neither list, so
a relay whose entire job is hosting a group the user joined fell through to a
prompt on every connection, or, before its subscription was assembled and there
was anything attributable to say, to a silent denial that left the room empty.

The venue check now has two halves: a venue *id* named by a purpose (adding
joined NIP-29 group ids and Concord community ids to the chats/communities it
already knew), and the relay that *hosts* one. The second half is what covers
the challenge that arrives before any of the room's filters do — and a group or
community host relay serves nothing else, so it is a safe signal.

Three supporting corrections, all of the same "the room doesn't look like a
room" shape:

- The first-party gate took a NIP-29 group-relay set; it now takes every joined
  room's host, so Concord relays and Buzz workspaces qualify too. Concord is the
  harder case of that rule: a plane wrap the account publishes is signed by the
  plane's stream key, so even its own outbound traffic carries someone else's
  pubkey and can never qualify by the publishing rule.
- A pending `h`-tagged event is a post into that group, not a notification to
  whoever it mentions.
- A pending Concord plane wrap is a post into that community. It is a kind-1059
  wrap `p`-tagged to a throwaway pubkey, so on tag shape alone it read as a gift
  wrap: the prompt offered to "send a message" to a key belonging to nobody.
  ConcordSessionRegistry gained the plane -> community lookup that makes it
  recognizable.

The prompt can now name both kinds of room instead of showing an id prefix: a
NIP-29 group resolves against the relay doing the asking (the id alone is
ambiguous without its host), and a Concord community's name comes off the
account's own joined list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PHCHwnWjVt83Ne3qGBeGq6
2026-08-12 21:49:00 +00:00
Claude fbc8e5d1f7 fix: second-pass audit findings on the audience flap
Seven findings, all verified against the code before acting.

Crash: AudienceDetail keys its chips by pubkey with no dedup, while the
Notifying row it replaces deduped via toSet(). pTags can legitimately
repeat a pubkey — the voice-reply branch notifies the parent author on top
of the notify list, and none of the three loadFromDraft paths called
distinct() — so expanding the flap on such a draft throws a duplicate-key
error. Fixed at both ends: the loaders dedupe, and the render guards.

Removed the "No inbox relay" badge outright. It read LocalCache once
inside a remember with no kind:10050 subscription, so on a cold start
every member was badged and the badge never cleared. Worse, it overstated
the consequence even when accurate: EventBroadcaster falls back to the
recipient's linked relays when a DM relay list is missing, so the wrap is
not undeliverable. A warning that fires for everyone and overstates its
own severity is worse than none; doing it properly needs a subscription.

Provenance: a list that un-mutes somebody recorded nothing for them,
because they were not newcomers to pTags — so undoing the group chip
removed their batch-mates and left them in a private note's audience.
addToAudience now distinguishes "new to pTags" from "new to the effective
audience" and claims both.

Also: list ids are qualified by kind, since a people list and a follow
pack may share a d tag and provenance keys on that string; a member in
both the public and encrypted halves of a list is no longer badged as a
disclosure risk they are not; the group chip counts only people who will
actually be p-tagged, not muted ones; and the lock's haptic reports the
direction it moved instead of always ToggleOn.

The new tests caught a bug in this very commit: the newcomer filter used
a mutating set membership check that reported every incoming pubkey as
already known, silently emptying provenance.

21 selection tests (up from 19); full amethyst suite green at 1197.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
2026-08-12 21:14:09 +00:00
David KasparandGitHub 80ee9aceab Merge pull request #3902 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-12 22:38:54 +02:00
vitorpamplonaandgithub-actions[bot] 296e377412 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-12 19:37:28 +00:00
Vitor PamplonaandGitHub 93ace887f9 Merge pull request #3900 from vitorpamplona/claude/registeraccounts-socket-timeout-tc9giu
Fix push registration to fail loudly on HTTP errors
2026-08-12 15:34:30 -04:00
Vitor PamplonaandGitHub c27f87e80c Merge pull request #3901 from vitorpamplona/claude/strictmode-disk-read-violation-ijd7lo
Optimize locale application to skip redundant system calls
2026-08-12 15:34:07 -04:00
Vitor PamplonaandGitHub df18650b46 Merge pull request #3899 from vitorpamplona/claude/auth-permissions-redesign-us1fwy
Redesign relay auth permissions UI and decision flow
2026-08-12 15:22:57 -04:00
Claude bdbb2cfded fix: retry push registration when the server returns an error status
postRegistrationEvent logged response.isSuccessful but never threw, so a
non-2xx from push.amethyst.social returned normally. retryIfException only
re-attempts on an exception, and PushNotificationUtils caches lastToken /
hasInit right after a clean return, so an error response marked the device
as registered while push was silently dead until the token or the account
list changed.

Throw PushRegistrationException on any non-2xx instead, carrying the status
and a bounded (512 byte) snippet of the response body so the failure is
actionable in logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BEVyfwuhxWgiKYSihYZpAh
2026-08-12 18:58:59 +00:00
Claude e323e7e355 fix: stop re-applying the app locale on every launch
UiSharedPreferences.languageUpdate called AppCompatDelegate.setApplicationLocales
on the main thread for every emission of the preferred-language flow, including
the eager one at startup. On API 33+ that call does not deduplicate: it is a
blocking Binder round trip into LocaleManagerService, which commits a
SharedPreferences file (and on Samsung ROMs also appends to a log file) before
returning. StrictMode reported the resulting ~220ms main-thread stall as three
DiskReadViolations on every cold start, even when the locale was unchanged.

Compare against AppCompatDelegate.getApplicationLocales() (@AnyThread, read only)
off the main thread and skip the write when nothing changed. Real changes still
hop to the main thread, which is required below API 33 where AppCompat applies
them in process by reconfiguring the active activities.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153izm6ZxY6z8tdYDuQJGTq
2026-08-12 18:58:13 +00:00
Vitor PamplonaandClaude Opus 5 af13f69370 fix(relay-auth): prefer a thread participant we can actually name
The conversation label rendered whichever author came first in note order, so one
unloaded participant sent the whole sentence to the generic fallback even when
somebody else in the same thread was perfectly nameable. Participants with loaded
metadata now sort first.

Also rewords the inbox sentence. "your incoming replies, zaps and messages" was
vague about ownership; "sent to you" states it, and keeps the DMs and nutzaps that
MY_INBOX also covers (SubPurposeToAuthPurpose maps DIRECT_MESSAGES and
NUTZAP_INBOX here, so a wording about posts alone would have dropped them).

Note this cannot always win: when the relay being asked about is the one hosting
the person's outbox, it is withholding their profile too, so there is no name to
show and the generic phrasing is correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:36:50 -04:00
Vitor PamplonaandClaude Opus 5 41432b9c7c fix(relay-auth): don't name a conversation after an unloaded stranger
Follow-up to the previous commit, caught on the next device run: when the thread's
notes resolved to a pubkey with no metadata yet, the sentence became "It won't
serve the rest of your conversation with someone you haven't loaded yet unless you
log in" — longer than the vague version it replaced and no more informative.

The "with %s" variant is now used only when the label is a real name; the generic
placeholder falls back to "the rest of this conversation".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:04:46 -04:00
Vitor PamplonaandClaude Opus 5 0e13a3b7a7 fix(relay-auth): say whose conversation the relay is holding back
The thread prompt read "It won't serve the rest of this conversation unless you
log in" without saying which conversation — and these prompts routinely surface
over the home feed or the settings screen, so "this" pointed at something the
reader did not have on screen at all.

The information was there and being discarded. Thread reads are `#e` against note
ids, which the assemblers either declare as `entityIds` or (ReactionsFilterAssembler)
only put in the filter's `e` tags; the deriver kept neither, emitting a bare
AuthPurpose(THREAD). It now carries those ids in a new `notes` field — separate
from `venues`, because a thread is not a room — and the dialog resolves them to
their authors through the same counterparty label the other purposes use, so the
sentence gets a name and an inline avatar.

"this conversation" survives only as the fallback for a thread whose notes are not
in the cache yet.

Verified on device: "It won't serve the rest of your conversation with Alice Peer
unless you log in."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:46:18 -04:00
Vitor PamplonaandClaude Opus 5 88f81bbff9 fix(relay-auth): "and 1 others" in the counterparty label
Same class as the "1 other thing(s)" fix, caught on the next prompt: a relay
serving two people rendered "someone you haven't loaded yet and 1 others".
`relay_auth_name_and_n_others` was a `<string>` with a bare `%2$s` count, so it
had no singular form at all. Now a `<plurals>` keyed on the count, and the count
is passed as `%2$d` rather than a pre-stringified number.

Verified on device across both arms: "Hawk and 1 other" / "Hawk and 7 others".
Only present in values/, so no other locale needed converting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:20:39 -04:00
Vitor PamplonaandClaude Opus 5 bc3ce1139b fix(relay-auth): two copy defects the prompt only shows once it can fire
Both found on device once prompts started appearing again.

An unloaded counterparty rendered as a shortened npub: "It won't serve posts from
npub1j9hlsge8...kqy003h0 unless you log in." `User.toBestDisplayName()` falls back
to `pubkeyDisplayHex()`, so the sentence presented an unrecognizable key as if it
were a person's name — the one thing the reason line exists to supply. The
counterparty now falls back to the generic "someone you haven't loaded yet", which
says exactly as much and reads as language. The dialog *title* deliberately keeps
the npub: it names the account whose identity is about to be revealed, and there an
exact key beats a generic phrase.

"It's also holding back 1 other thing(s)." was a `<string>` with a parenthesised
plural, which res/CLAUDE.md rules out — the noun declines on count in the Slavic
and Semitic locales we ship. Now a `<plurals>`. It exists only in values/, so no
other locale needed converting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:40:52 -04:00
Vitor PamplonaandClaude Opus 5 0bddb64abc fix(relay-auth): ask instead of silently denying a non-first-party relay
On device, no AUTH prompt ever appeared. Instrumenting the coordinator showed
why: 56 of 56 challenges that arrived with an account registered were dropped at
the `isFirstParty` gate, and none reached the ledger. Zero AUTH events were sent
under *either* "Decide per relay" or "Always log in".

The gate returned early, so a relay this account has no reason of its own to be
on was denied without a word — including `purposes=[READ_OUTBOX]`, a purpose that
names a counterparty and renders a perfectly good sentence. That made "Decide per
relay" mean "deny, and don't mention it" for every purpose about someone else,
which is the case the prompt was built to explain.

Not auto-authing there is right and stays: that is what keeps a bystander account
off a relay only another account uses. So `isFirstParty` stops being a gate and
becomes an input to the pure resolver, where it now guards only the *automatic*
grants — both the CUSTOM toggle categories and the ALWAYS policy. A challenge we
cannot explain is still denied silently; one we can now falls through to ASK.

Nothing the user already closed reopens: blocked relays, stored per-relay
overrides and the NEVER policy are all evaluated ahead of this and unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:40:39 -04:00
Vitor PamplonaandClaude Opus 5 de247c3b5a fix(relay-auth): address the reader in second person
Every reason sentence described the person reading it in the third person — "to
readers it can't identify", "from someone it can't identify", "unless it can
identify the sender". That reader is you, and the dialog already knows it: the
title names your account and the button says Log in. Saying "unless you log in"
names the same condition in the reader's own terms and points straight at the
control that resolves it.

All seven variable sentences rewritten. Shorter, too — the read-outbox line now
fits one line instead of two on a tablet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 10:08:50 -04:00
Vitor PamplonaandClaude Opus 5 3a35a21998 fix(relay-auth): four copy and layout defects found testing the prompt
Found by driving the prompt against a local geode running --auth, with the
account's auto-login toggles off so every purpose has to ask.

The read-outbox sentence appended a possessive to a phrase that is already
plural: "Alice Peer and 1 others's posts". The name slot is filled by
counterpartyLabel, which may be a single name or "X and N others", so nothing
can be glued to its end. Recast as "posts from %1$s", which reads correctly
either way.

The same state drew the counterparty twice — an avatar inline beside the name in
the sentence, and a facepile of the same people directly beneath it. Removing
the duplicated avatar row is the whole point of this redesign; the facepile is
gone and the inline avatar carries it.

The "Remember for this relay" row was a filled surfaceVariant block, which in
dark theme reads as a black box and hands the least important control in the
dialog more visual weight than the two buttons below it. It is transparent now.

The "…I'm messaging anyone else" row carried a static description reading
"Off — you'll be asked each time." The intent was to explain that this row,
unlike the other three, defaults to off — but written as a state assertion it
contradicts its own switch the moment you turn it on. Dropped rather than made
conditional: the group header already says "Log in without asking when…", so an
off switch means it asks, and the spec's own rule for these rows is a short
completion with no description.

Verified on a tablet: the prompt now reads "It won't serve posts from [avatar]
Alice Peer and 1 others to readers it can't identify." with one avatar, and the
remember row no longer draws a box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:42:19 -04:00
nrobi144andClaude Opus 4.8 129bad5a02 feat: discoverable key backup + nsec exposure (Desktop + Android)
Make the secret key (nsec) discoverable and backupable without obfuscating
it, while keeping the npub/nsec asymmetry every Nostr client relies on:
npub is shareable (plain, copy, QR); nsec is an unrecoverable password
(masked, gated reveal + gated copy, NIP-49 encrypted option, never a QR).

Desktop:
- New BackupKeysCard in the settings/profile screen: npub (copy + QR),
  nsec masked-by-default with reveal/copy gated behind the existing
  PrivacyLock (new LockScope.KeyBackup), plaintext + NIP-49 encrypted copy,
  strong "cannot be recovered" warning, external-signer note when no key.
- Upgrade NewKeyWarningCard: per-key copy, encrypted copy, stronger warning,
  soft "I have saved my keys" acknowledgement.
- Extract shared copyToClipboard + best-effort copyToClipboardThenClear util.

Android:
- Soft, dismissible post-signup "Back up your keys" nudge on the home feed
  (per-account hasBackedUpKeys flag; only freshly-generated accounts nudged;
  "Back up now" opens the existing AccountBackupScreen).
- FLAG_SECURE on the key-backup screen (screenshot / app-switcher redaction).
- Best-effort clipboard auto-clear 60s after copying the plaintext nsec.

Deferred: compose paste-guard (nsec self-doxx warning) — the send path is
reimplemented across ~17 *PostViewModels with no shared choke point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 11:06:33 +03:00
Claude 00d19f2465 Merge remote-tracking branch 'origin/main' into claude/shortpost-lock-list-selection-3pg1s2 2026-08-12 02:05:07 +00:00
Claude e30ec41de9 Merge remote-tracking branch 'origin/main' into claude/auth-permissions-redesign-us1fwy 2026-08-12 02:03:40 +00:00
Vitor PamplonaandGitHub 3cfed4c429 Merge pull request #3893 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-11 22:02:06 -04:00
vitorpamplonaandgithub-actions[bot] 7b8aaaba0b chore: sync Crowdin translations and seed translator npub placeholders 2026-08-12 02:00:41 +00:00
Vitor PamplonaandGitHub 61f9e0d9f2 Merge pull request #3898 from vitorpamplona/claude/polls-result-page-design-cyn3pl
feat(polls): extended poll results page with voter list
2026-08-11 21:57:24 -04:00
Vitor PamplonaandClaude Opus 5 fb1d233d1e Revert "polls: drop the NIP-05 line from voter rows"
The row goes back to `UserLine`, NIP-05 and all.

Dropping that line was justified by a frame measurement I later found unsafe —
some runs in that session were measuring the thread screen, because the tap that
opens the results page had missed and I only verified the screen on some of them.
A design subtraction is not worth making on a number I cannot reproduce, and a
voter list is exactly where a NIP-05 earns its place: it is what tells two people
with the same display name apart.

The screen is still slower to scroll than the rest of the app — that part is
verified — but the cause is not established, and :benchmark is where that gets
settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:51:13 -04:00
Vitor PamplonaandClaude Opus 5 65e6e5a7e1 fix(polls): let the results summary scroll with the voters again
Pinning the summary above the list traded one problem for a worse one: on a phone
it eats most of the screen and leaves a sliver of list to scroll. It goes back
into the LazyColumn.

Two things stay from the pinned attempt, on their own merits rather than on any
measurement: the option bars animate *to* their value instead of growing from
zero, so they no longer replay a full 800ms every time the summary scrolls back
into view, and the first tally build stays off the composition thread.

Caveat for whoever picks this up: the frame numbers in the previous commit
message are not reliable. They were taken with dumpsys gfxinfo around scripted
swipes, and at least two runs turned out to have been measuring the thread screen
because the tap that opens the results page had missed — I only verified the
screen on some runs. What survives verification is that this screen scrolls far
worse than the home feed on the same build (250-300ms per frame against 42ms),
that every janky frame is Slow UI thread, and that the page is idle when not
touched. The attribution between summary, rows and animation is not established;
:benchmark is the right tool for that, not scripted swipes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:46:29 -04:00
Vitor PamplonaandClaude Opus 5 9c38240e3e perf(polls): make the results screen scroll like the rest of the app
Scrolling the results page cost 250ms per frame against 42ms on the home feed —
same debug build, same device, same swipe — with twelve rows and barely a
screenful of content. Every janky frame was Slow UI thread; the GPU never went
above 7ms, and the page renders one frame in eight seconds when left alone, so
all of it was composition work provoked by scrolling.

Three causes, measured one at a time.

The summary was list content. Header, option bars and chips each sat in their
own `item {}`, so scrolling disposed and rebuilt the whole block — nine
user-observing composables and two bar animations restarted — every time it left
and re-entered the viewport. It is one static block above a list, so it now sits
above the list, and the LazyColumn holds only voters.

The first `build()` ran on the composition thread. `uiState` is `flowOn(Default)`,
but `stateIn`'s initialValue was an eager build: it sorted every voter and
materialised every row synchronously wherever the ViewModel was constructed. Fine
at twelve voters, not fine on the thousand-voter poll this screen exists for. The
initial value is now a placeholder and `hasTally` keeps the footer from reading it
as a poll with no votes.

The voter row carried a NIP-05 line. `UserLine` is the app's short-list row — the
mention picker, follow import — and its supporting slot opens a third per-row
observer and a verified NIP-05 lookup for a list of people the reader has mostly
never met. The row is now the same `SlimListItem` primitive with avatar, name and
the vote, which is what the screen is for.

    scrolling, median frame / 90th        before    after
    poll results                          250ms     38ms      (550 -> 77)
    home feed, for scale                   42ms     42ms      (73)

The NIP-05 line is the one thing lost here, and it was worth 60ms a frame on its
own (97ms with it, 38ms without, after the other two fixes). Putting it back is
one call site if identity disambiguation matters more than the scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:13:11 -04:00
Vitor PamplonaandClaude Opus 5 c937d42254 refactor(polls): load the results screen's votes the way every other screen loads events
The results page had its own private relay stack: a `PollResponseLoader` seam, a
`RelayPollResponseLoader` that drove `fetchAllPagesFromPool` straight from the
ViewModel, and a process-wide `recentDrains` map that suppressed re-drains for
five minutes. Those accessories are the headless path — CLI, geode, tests — and
using them here meant the screen paid for its own paging, its own caching and
its own staleness rules while the rest of the app got all three for free. It
also showed: the completeness line was the only thing on a live page that could
not refresh, because that five-minute cache outlived the screen that drew it.

Votes now arrive through `PollResponsesFilterAssembler`, a current-screen data
source shaped like `ThreadFilterAssembler` — one sub-assembler, EOSE-tracked per
poll, deduplicated across screens, subscribed for exactly as long as the screen
is composed, and asking the poll's own declared relays for a page of kind-1018
large enough to be worth calling "every voter". No TTL to go stale, because
there is nothing cached to go stale.

What is left of the loader is the single question a subscription cannot ask —
NIP-45 COUNT, "how many exist that we were not sent" — which is what the
completeness footer needs and nothing else. `isBackfilling` becomes
`isCheckingCompleteness` and its string says what it now does, rather than
describing a drain that no longer happens.

The avatar stack was a second copy of the card's `UserGallery`, down to its own
face cap. `UserGallery` now takes (shown, total) with the tally overload
delegating to it, both callers draw the same widget, and the cap is one constant
on `PollResponsesCache` instead of `GALLERY_FACES` and `AVATAR_STACK` drifting
apart.

Verified on a tablet: the page loads its voters through the new subscription —
showing 11 while the card behind it still had 10 — and a vote published with the
page open still lands live (11 → 12, bars and chips redrawn, no navigation).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:18:25 -04:00
Vitor Pamplona 44c993841e Merge remote-tracking branch 'upstream/main' into test/polls-results 2026-08-11 19:34:03 -04:00
Vitor PamplonaandClaude Opus 5 21da6c6128 fix(polls): separate backdated from late votes, reach the empty state, align the results header
Follow-ups from testing the results page on device.

The out-of-window counter conflated two opposite things. `isInWindow` rejects a
response stamped before the poll was published as well as one stamped after it
closed, and both landed in `lateVotes`, whose footer line says "arrived after
the poll closed". On an open poll that line contradicts the status chip beside
it — nothing has closed — and for a backdated event it is simply untrue.
`PollTallyPolicy` now names the two edges, the tally counts `backdatedVotes`
apart from `lateVotes`, and each gets its own sentence.

The page's "no votes yet" state was unreachable. The card's results link is the
only door into the screen and it returned early below one voter, so the empty
state could never render. It now shows at zero too: a poll reading zero is
exactly when someone doubts the number, and the page is the only surface that
answers them — it says whether votes are still loading and what the relays
claim exists against what we hold.

Header, on review feedback: the timestamp trailed the author's name instead of
sitting in the top-right corner like every other note header in the app, and
there was no options menu at all. The time now anchors right, next to the
shared `MoreOptionsButton`, so share/copy/bookmark/report work here as they do
on any note. The standalone "N voters" headline is gone; the total rides in the
"All options · N" chip in the same shape as each option chip beside it, and the
selections count — which only differs on multiple choice, and only exists to
explain bars summing past 100% — moves into the poll-type chip that makes that
claim.

Verified on a tablet against purpose-built polls: an open poll with a backdated
vote reports it as backdated, an ended poll still reports its late vote as
late, a poll with no votes opens and says so, and the multiple-choice chip
reads "Multiple choice · 8 selections".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:19:33 -04:00
Vitor PamplonaandGitHub 160550ddcb Merge pull request #3897 from vitorpamplona/claude/copy-text-translation-options-yl7m5x
Add "Copy Original/Translated" chooser for translated notes
2026-08-11 17:47:23 -04:00
Vitor PamplonaandClaude Opus 5 6251d6e18e fix(copy-text): close the menu when decryption fails, key the chooser on what was rendered
Two follow-ups from testing the Copy Original / Copy Translated chooser on
device.

The menus stopped dismissing for a note that cannot be decrypted.
AccountViewModel.decrypt only invokes its callback on success, and the copy
flow now dismisses from inside that callback, so a read-only account (or a
foreign/corrupt DM, or a signer that refuses) left the popup/sheet sitting
open with nothing copied — previously onDismiss ran unconditionally. Adds
decryptOrNull, which always answers (null on failure, and still rethrows so
launchSigner keeps toasting/logging the signer error), and dismisses on null.

The chooser was also missed whenever the string the viewer translated wasn't
the note's raw content. RenderTextEvent prepends a NIP-14 subject the body
doesn't already repeat, and renders the newest edit of a versioned post; the
copy flow looked the translation up by the raw content of the note it was
handed, so a subject-carrying note never hit the cache and silently copied
the untranslated text. That derivation is now one function, displayedNoteText,
used by both sides, and it is also what gets copied — a subject is part of
what the user is reading. The handler takes (note, versionShown) so the body
comes from the version on screen and the subject from the note itself, as the
viewer composes them; the quick-action menu, which passed the pre-edit
original, now resolves the newest modification the way the card does.

Verified on a tablet: an undecryptable NIP-04 message under a read-only login
now closes the sheet, and a translated note carrying a subject tag offers the
chooser and copies the subject line with the original.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 17:38:08 -04:00
Vitor PamplonaandGitHub 4a70af8bf4 Merge pull request #3896 from vitorpamplona/claude/neo4j-quartz-graph-schema-z04j8o
Declare pointer hints on Report, ChatMessage, Classifieds and ChannelCreate events
2026-08-11 16:35:14 -04:00
Claude db6b81eb04 feat(nip56): declare pointer hints on report, chat, classifieds and channel events
Quartz's PubKeyHintProvider / EventHintProvider / AddressHintProvider are the
kind-agnostic answer to "what does this event point at" — they let a caller
walk an event's references without knowing which tag name a given NIP chose
(`p` vs `P` vs `member` vs `moderator`). Measured against the 248k-event
corpus in commonTest, 84 of 403 event classes implement one, covering ~95% of
all pointer edges. This closes the four largest remaining gaps.

ReportEvent (1984) carried the most undeclared edges of any kind — 14,244 —
and they are the negative trust signal that a web-of-trust projection most
needs. Its tag classes also predate the modern layout, so they are brought up
to the structure used by e.g. NIP-88 polls:

- ReportedAuthorTag now implements PubKeyReferenceTag, ReportedEventTag
  implements GenericETag, and all three tags carry a relay hint.
- Adds parseKey / parseId / parseAddressId / parseAsHint companions.

Fixes a latent bug while doing so. NIP-56 predates the convention that slot 2
of a pointer tag is a relay hint — it put the report type there — so both
layouts are in the wild. The old reader passed slot 2 straight to
ReportType.parseOrNull, which despite its name never returns null and maps
anything unrecognized to OTHER. A modern `["p", <pubkey>, "wss://relay/"]`
tag therefore became an OTHER report and masked the event-level default. The
new shared ReportTagLayout disambiguates by shape (a slot that parses as a
relay URL is a hint, never a type) and falls back to the event-level default
when a tag names no type of its own.

Emitted tags are unchanged: assemble() still writes the legacy
`[name, id, type]` form unless a relay hint is supplied, since many clients
still read the report type out of slot 2.

Also renames ReportedAuthorTag.pubkey to pubKey to satisfy
PubKeyReferenceTag, updating the four call sites.

Coverage over the corpus goes from ~95.3% to ~98.5% of pointer edges. What
remains is GiftWrapEvent's recipient p-tag (deliberate — it is the store
owner key and handled separately) and PrivateDmEvent's e-tags.
2026-08-11 20:15:58 +00:00
Vitor PamplonaandGitHub 106853c2f2 Merge pull request #3895 from vitorpamplona/claude/embed-browser-keyboard-focus-kcgqqc
fix(embed): bring the keyboard back on a tap in an already-focused field
2026-08-11 14:17:53 -04:00
Vitor Pamplona 237ece79d5 Merge remote-tracking branch 'upstream/main' into claude/embed-browser-keyboard-focus-kcgqqc 2026-08-11 13:25:52 -04:00
Vitor PamplonaandClaude Opus 5 be95701618 fix(embed): stop a readonly field's text reaching the page after blur
On-device QA of 1de9c242 (SM-T220, Android 14). Copying from a readonly field
and then tapping an editable one wrote the readonly text into it:

    FOCUSOUT readonly
    FOCUSIN  empty
    INPUT    empty val="readonly" len=8 sel=8..8    <- page, not the user

TYPE_NULL closed the inbound half (nothing can be typed into a readonly
field's mirror) but not the outbound half. The mirror still flushes on
selection changes — a long-press select-all emits one, Chrome's
collapse-to-endpoint another — and `ime.set` carries the buffer's text.
Delivery is asynchronous, so the flush lands after the page has moved focus
and the shim applies it to whatever field is focused THEN. Host log at the
moment of the leak:

    FLUSH sending={"type":"ime.set","text":"readonly","selStart":0,"selEnd":8,...}
    FLUSH sending={"type":"ime.set","text":"readonly","selStart":8,"selEnd":8,...}

Two changes, because each alone leaves a hole:

- `stateJson()` omits the `text` key entirely while the mirrored field is
  readonly. The shim already treats a missing text as selection-only
  (`var next = (msg.text != null) ? String(msg.text) : prev`), so the
  host-drawn handles and Copy keep working off a synced selection while
  nothing can be written back.

- `onPageBlur()` no longer clears `fieldReadOnly`, and suppresses its own
  echo. The flag describes the buffer the mirror still holds, which outlives
  the blur; clearing it up front meant the second flush above — scheduled by
  `clearFocus()` moving the caret, hence firing after the reset — was
  computed as if the field were editable and shipped the text. The blur now
  wraps `clearFocus()` in `applyingRemote` and drops the queue, so nothing
  belonging to a field the page has already left can be delivered to the next
  one. That part is not readonly-specific: any field's stale buffer could
  land on its successor, readonly just made it visible.

Verified on device: the exact sequence (long-press readonly → Copy → tap the
empty field) now produces no INPUT and leaves the field empty; Copy still puts
the text on the system clipboard (IME clipboard chip shows it); selection,
handles and the Copy/Select-all bar are unchanged; an editable field still
types normally straight after the readonly excursion.

NOT verified: the hardware-keyboard half of 1de9c242's rationale (Ctrl+V via
onTextContextMenuItem). No Bluetooth/USB keyboard is attached to this device,
so that path is untested — see the PR comment.

tools/ime-test/shim-events.mjs still exits 0 (shim.js untouched).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:21:57 -04:00
Claude 1de9c242e9 fix(embed): don't let a readonly field's mirror be typed into
Audit of the branch, two findings.

`readonly` stops the *user* editing a field, not scripts: the shim writes
through the native value setter, so any text that reaches the host mirror is
applied to the page and fires an `input` event no native browser would. Cut and
Paste were refused at their call sites, but that misses a hardware keyboard
(tablets, DeX, Chromebooks) — whose Ctrl+V goes straight to
`onTextContextMenuItem`, bypassing the wrapper — and autofill. Configure the
mirror as TYPE_NULL for a readonly field instead: `onCheckIsTextEditor()` is
then false, so there is no InputConnection to type through at all, while
selection and Copy — the half native does offer on a readonly field — keep
working.

The selection toolbar's item list was rebuilt on every recomposition of the tab
layer, which recomposes on every IME inset change, bounds report and console
line, for a toolbar only shown during a selection. Remembered on the readonly
flag, so it allocates once and keeps a stable identity the overlay can skip on.

Adds tools/ime-test/shim-events.mjs, a regression test that drives the shipped
shim in headless Chromium and asserts the page→host envelopes. It fails on main
(7 cases, including "no ime.wantkb — the keyboard could never come back") and
passes here. A JVM unit test cannot cover this: the host parser runs on Android's
org.json, which the unit tests stub out, so it would pass without parsing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
2026-08-11 15:21:10 +00:00
Vitor PamplonaandGitHub 278ddd2790 Merge pull request #3894 from vitorpamplona/fix/bottom-nav-webapp-tab-switch
fix(nav): switching between two pinned web-app tabs crashed onto the wrong one
2026-08-11 10:20:32 -04:00
Vitor PamplonaandClaude Opus 5 b7ef983bd8 fix(embed): no caret handle or Cut/Paste on a readonly field
Suppressing the keyboard for a readonly field was only half of it. The
selection UI still treated it as fully editable: a tap-and-hold raised the
insertion caret handle, and its toolbar offered Cut and Paste — on a field the
page will not let you modify. Cut appeared to work in the mirror while the page
kept its text, so the two silently drifted apart.

Native, on the same page in the full-screen WebView, gives a readonly field
selection handles and a Copy / Select-all bar, and nothing else: no caret
handle (there is no caret to place) and no editing actions.

Carry the flag into SelectionUiState so the overlay can reason about it:
`fieldReadOnly` gates the insertion handle (and with it the Paste/Select-all
popup that hangs off it), and the field toolbar drops Cut and Paste. Selection,
its handles, Copy and Select-all are untouched — that half is what native
offers and it works today.

`cutSelection`/`pasteClipboard` refuse on a readonly field too. The toolbar no
longer offers them, so this is a backstop, placed next to the ops so a future
call site can't reintroduce the drift.

Device-verified: readonly long-press selects with handles and shows exactly
"Copy | Select all"; a plain tap gives no keyboard and no caret droplet; an
editable field still shows all four actions and keeps its caret handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:51:42 -04:00
Vitor PamplonaandClaude Opus 5 37f9c5ad85 fix(embed): restore the keyboard on tab return, and none for readonly
Device testing on a tablet (SM-T220, Android 14) walked every text-field focus
path in the embedded tab. Two of them were wrong.

**The tab-return restore never fired.** `noteKeyboardOnLeave` sampled
`WindowInsets.imeAnimationTarget > 0 && isMirroringPageField()` inside
`onDispose`, on the assumption that the dispose runs before anything hides the
IME. It does not: by the time it runs, the nav transition has already snapped
the animation target to 0 *and* taken focus off the view, so both halves read
false and every tab was recorded as "left without a keyboard". Instrumented on
device, the leave was `keyboardUp=false mirroring=false imeBottomPx=0` for all
three nav-rail routes, so `pendingRestore` was false on every return and a tab
left mid-typing always came back with the keyboard down.

Ask the mirror what it *intends* instead of sampling the window at teardown:
`RemoteImeView.keyboardWanted` is set when we raise the keyboard and cleared
when the field blurs or the user puts the keyboard away, so it still reads true
while the view is being torn down.

Telling "user dismissed it" apart from "the tab went away" is what that clearing
needs, and there is no key hook for it — Android 13+ routes the IME's back
dismissal through OnBackInvokedCallback, so `onKeyPreIme` is never called (tried
first; it silently never fired and the tab over-restored). The two cases are
distinguishable by what else is true when the insets collapse, measured on
device:

    dismiss:     imeBottomPx=0  hasFocus=true   mirrors=true
    tab switch:  imeBottomPx=0  hasFocus=false  mirrors=false

so a collapse while we still mirror the field is the dismissal, and a switch
never looks like one — the focus loss lands in the same frame as the insets.

**A readonly field raised a keyboard that cannot type.** `isEditable` in the
shim never looked at `readOnly`, so the host took the field and showed a
keyboard whose keystrokes the page discards. Native, checked side by side in the
full-screen WebView on the same page, focuses a readonly field without a
keyboard. The field stays "editable" for selection (native offers handles and
Copy there); only the raise is suppressed, via one guard in `raiseKeyboard` so
the fresh-focus, tap-doorbell and tab-restore paths are all covered.

Verified on device, 27/27 checks: fresh focus raises for text/textarea/
contenteditable/email/number/password/search/tel and not for disabled or
readonly; BACK-dismiss then re-tap restores; re-tapping a field whose keyboard
is up keeps it; leaving mid-typing restores on return (~1s, 5/5 runs) while a
dismissed tab stays down; typing after either restore lands in the right field
at the right caret; page-background tap blurs; address-bar keyboard never arms
an embed restore; and the full-screen round trip leaves the embed IME working.

`tools/ime-test/keyboard.html` is the page those checks drive: every field type
plus a live focus readout and an event log that marks taps on an already-focused
field, which is the case with no DOM event of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:40:26 -04:00
Claude b7f55d8697 chore: add a runtime perf probe for the embedded vs full-screen WebView
The embedded tab and the full-screen browser are the same WebView in the same
`:napplet` process with byte-identical WebSettings, so a site whose JS feels
slower in the embed is being slowed by the host, not by its configuration.
`perf.html` measures which host effect it is: page visibility (a page Chromium
treats as hidden gets ~1Hz timers and no rAF), raw CPU throughput (the renderer
inherits its scheduling class from whichever process hosts the WebView — the
embed's is a plain bound service, the full-screen one is top-app), forced-layout
cost, rAF rate, long tasks, and input-delivery latency measured from the
platform's own event timestamp.

Open the same URL in both hosts and compare the summary line; the README says
what each divergence points at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
2026-08-09 22:44:46 +00:00
Claude a0f4328f2a fix: keep the embed re-focus ping payload-free and re-seed via resync
Audit of the previous commit found three problems in it.

The `ime.refocus` sent on every tap carried the field's full editing state,
so a tap in a long textarea put 40KB on the wire per tap (145B before), and
its geometry made the page mirror the whole field into a hidden div and force
a synchronous layout — measured at ~3.5ms per tap on a 40k-char textarea,
doubling the cost of every tap in a field. Split the message in two: taps ring
a payload-free `ime.wantkb` doorbell, and the host answers it with the
`ime.resync` it already had — but only when it no longer mirrors the field, so
the common "keyboard was dismissed, tap to get it back" case is one small
message and no round trip. Per-tap payload is now constant (~180B) and the
per-tap CPU cost is back at parity with before the fix.

The "am I already hosting this field" check read `hasFocus()` alone, so a
focus that lingers past `clearFocus()` (a lone focusable in the hierarchy can
take it straight back) would have skipped the re-seed and shipped the previous
tab's text to the page on the first keystroke. Track mirroring explicitly.

The keyboard-restore mark was armed by any keyboard up at tab-switch time,
including one belonging to the browser's own address bar; require that the
mirror actually holds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
2026-08-09 19:38:50 +00:00
Claude 9bfec38697 fix: bring the embed keyboard back on a tap in an already-focused field
Returning to a warm embedded tab left the page's field focused but with no
way to get the keyboard back: the surface just moves off-screen, so the page
never fires another focus event, and `ime.focus` was the host's ONLY
keyboard-raising signal. A tap on the still-focused field only produced
`ime.carettap` (which re-shows the insertion handle), so the keyboard stayed
down until the user tapped away and tapped back. Same dead end after
dismissing the keyboard with back, without leaving the tab at all.

Keep the page focus — blurring it on tab-away would fire the page's own blur
handlers (validation, autocomplete dismissal, submit-on-blur) for a switch
the user never made inside the page, and lose the caret the user came back
to — and give the host the two signals it was missing:

- `ime.refocus` (page -> host), the focus payload for a field that is already
  focused, sent on every tap inside it. The host re-takes the field and raises
  the keyboard; when it is still the field's mirror it only re-raises, so a
  live composing region survives.
- `ime.resync` (host -> page), sent when a tab becomes active again, answered
  with the same payload. The keyboard comes back only for a tab that was left
  with it up, the way Android restores a window's IME state; a tab whose
  keyboard the user had dismissed comes back with the caret in place and the
  page unobstructed.

Also folds the two identical private `parseImeEvent` copies in the browser
and napplet controllers into one shared parser next to the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
2026-08-09 17:24:46 +00:00
Claude e56e2269df feat: offer Copy Original / Copy Translated when copying translated notes
Every Copy Text menu (note quick-action popup, the shared note-action
sections behind the 3-dot menu and chat long-press sheet, and bookmark
group item options) now checks whether the rendered note was translated
and, if so, pops a chooser offering Copy Original / Copy Translated
instead of silently copying the original.

Rather than plumbing the translated string from TranslatableRichTextViewer
down to the menus, the shared copyNoteTextAction flow re-derives it from
the process-wide TranslationsCache keyed by (content, language settings) —
rendering the note is what populated that cache, so a hit means the user
is looking at a translation. The cache is play-flavor-only, so the lookup
goes through a new cachedTranslation() flavor pair (fdroid always null,
keeping its Copy Text single-option).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7wEshKZC5HSgQykQiMQnt
2026-08-04 18:39:39 +00:00
Claude 0e3fa8ac60 fix: start the AUTH prompt timeout when the dialog is shown, not when it arrives
The host renders one dialog at a time, but every prompt's 60s deadline started
when its challenge arrived. When several relays challenged at once, prompts 2..N
counted down while queued and invisible, then expired unseen -- a silent deny the
user had no way to act on.

Reaching one after it expired was worse than useless: complete() is a no-op on a
resolved deferred, so the click did nothing at all. No auth sent, no "always
allow" rule written, no feedback.

RelayAuthPrompt.markShown() now opens the answer window. It is gated on a host
actually collecting the flow -- with no UI nobody can ever answer, which is what
the timeout has always existed for, so that case keeps the arrival clock -- and
capped by queueWaitMs so a host that stops rendering mid-queue cannot suspend a
relay coroutine forever.

A second challenge for the same (relay, account) now rides along on the owner's
answer with no deadline of its own. Giving it one would let it complete the
shared deferred and tear the dialog away while the user was still reading it.

Three regression tests cover the queued-clock, the no-host timeout, and the
rider.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USYujXpjNrQdEyK39Q48Z1
2026-08-04 01:12:39 +00:00
Claude 434a454cda feat: rebuild the relay AUTH permission screens
Implements amethyst/plans/2026-08-03-auth-permissions-redesign.md.

The prompt named the recipient four times and asked the question three, across
six stacked blocks, while never naming which account's npub was about to be
revealed. It now leads with the relay, states "Log in as @x?" once, and carries
everything variable in a single sentence with the counterparty's avatar spliced
inline -- replacing the purpose-specific title, the purpose label, the avatar row
and the red consequence line, which all repeated the same name. Four buttons
(three meaning yes) become Not now / Log in plus a Remember switch; the button
that silently switched the global policy to CUSTOM and flipped two account-wide
toggles is now a link to the screen where those toggles are visible.

More importantly the reason shown is now true. RelayAuthPurposeDeriver re-guessed
intent from raw filter shape, so downloading your own replies and zaps (#p = me,
no authors) matched nothing and borrowed the "Notify:" label from unrelated
pending traffic on the same socket, and reading a thread's engagement (#e against
note ids) was classified as a room -- asking "Open 3f8a12c9?" and minting a
phantom public-chat channel in LocalCache for a note that was never a room. Every
filter already declares a SubPurpose; the deriver now reads it, with tag shape
kept only as the fallback for a plain Filter. That adds the two states the prompt
could not previously express: your own inbox, and this conversation.

Prompts are also per account now rather than per challenge, since the dialog
names the identity at stake; the bus dedupes on (relay, account).

Settings: the list headed "Per-relay overrides" was really overrides plus grant
rationale plus last-used timestamps, mostly not overrides. It splits into
Exceptions (explicit rules only, with an undoable Remove), Blocked by your block
list (kind 10006 -- a hard deny that outranked everything and was invisible here),
and Recent logins (the log, captioned by what the relay was doing instead of an
unexplained facepile). The policy cards become a radio group and the toggles
become completions of their group header, dropping seven restating paragraphs.

Strings whose meaning or placeholders changed were renamed rather than reused, so
stale translations fall back to the new English instead of rendering the old copy;
the orphaned translations were removed from the 11 locale files carrying them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USYujXpjNrQdEyK39Q48Z1
2026-08-04 00:45:02 +00:00
Claude f56da5adbf docs: drop the npub line, restore exception removal in the AUTH proposal
Two corrections to the mockups.

The npub under the prompt title is gone. It replaced the cut boilerplate
sentence in the same slot, which repeated the mistake one layer down: truncated
it can't be verified by eye, the handle already names the account, and it cost a
line on every state.

Exceptions rows regain an explicit removal path. The copy deck claimed "clearing
both segments removes the row", but a two-segment control has no neutral
position, so there was no way back to being asked again. Today's Forget action
is kept and retitled "Remove exception", with an undo snackbar naming the
fallback. It is unambiguous now that the list holds only real overrides: it no
longer neighbours a red chip that reads as a block, and the usage history it used
to wipe has its own list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USYujXpjNrQdEyK39Q48Z1
2026-08-03 23:08:54 +00:00
Claude a434f49ed1 docs: cut the "operator will see your npub" line from the AUTH prompt proposal
"Log in" already means "identify yourself", so a sentence explaining it is
boilerplate on every state — the same duplication this proposal removes
elsewhere. The npub now renders under the title instead: the concrete value
handed over rather than a description of it, and nothing to re-read on the
fifth prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USYujXpjNrQdEyK39Q48Z1
2026-08-03 23:03:13 +00:00
Claude 8d6fc5b93e docs: review and redesign proposal for the relay AUTH permission screens
Inventories every state of the two NIP-42 surfaces (RelayAuthPromptHost and
RelayAuthSettingsScreen), documents the duplication and mis-attribution each
carries, and proposes a redesign for each state as live HTML mockups rendered
with Amethyst's own Material 3 tokens.

Key findings: the DM prompt names the recipient four times and asks the question
three times while never naming which account is being revealed; three of its four
buttons mean yes, and one of them silently rewrites the global policy; and the
purpose deriver re-infers intent from raw filter shape, so downloading your own
replies/zaps shows unrelated "Notify:" copy and opening a thread renders a note id
as a room (also minting a phantom public-chat channel in LocalCache).

ExplainedFilter already carries SubPurpose on every filter the app opens; reading
it at the auth path removes that class of mis-attribution.

Proposal only — no behaviour or decision-model changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01USYujXpjNrQdEyK39Q48Z1
2026-08-03 22:59:36 +00:00
Claude aafa962245 fix(polls): audit fixes across the tally, the card and the screen
A draw now has no winner. winning() used maxByOrNull, which handed the
green highlight and the check to whichever tied option happened to come
first — on a 1-1 poll, an outright lie about the result.

The feed card no longer sorts every voter on the UI thread to draw four
avatars. filterTo ran during composition and again on every tally change,
sorting an option's whole voter list so UserGallery could take(4).
TallyResults now keeps its voters unsorted, materialises the ordered list
lazily for the screens that list everyone, and offers topUsers(n) via a
bounded insertion. Desktop's own gallery and its is-this-my-vote check go
through the same paths instead of forcing the sort.

Voter rows are keyed by pubkey rather than by User instance. The cache
normally hands out one User per pubkey, but an eviction and re-create
would leave two live instances for one person — and two rows sharing a
LazyColumn key is a crash, not a cosmetic duplicate. The tally still
counts them separately; this stops the crash.

An empty poll that is still loading no longer says "No votes yet" and
"Loading every vote..." at the same time.

The results opt-in fires on a deep link. It read note.event inside an
id-keyed LaunchedEffect, so when the poll event arrived after first
composition the effect never ran again and the visit went unrecorded.
Keyed on the observed note state now, via observeNote, which also carries
the subscription the screen already needed.

The backfill remembers. The ViewModel is rebuilt on every visit, so
bouncing in and out of a poll re-walked every relay's whole history; a
completed drain now stands in for the next five minutes, which the live
subscription makes safe.

Smaller: the ViewModel factory, loader and mapped Flow are remembered
instead of rebuilt each recomposition; winning() is computed once per
build rather than once per option; myVote falls out of the loop already
running instead of a second scan; option bars grow from zero on first
show; the vote column has a max width instead of a fixed one; and a
no-HLL merge reports approximate when any relay said so, not just when
the highest did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 22:49:24 +00:00
Claude aae563c927 perf(polls): fold responses into the tally instead of rebuilding it
ResponseTally rebuilt itself from every response on each arrival —
copying the list, redoing latestByAuthor, re-inverting the option map —
so a poll cost O(n²) across its lifetime. The containment guard in
addResponse was a second O(n) scan per arrival. Draining a few thousand
votes through the new backfill meant millions of map operations and one
full copy per event.

The tally now sits on persistent collections and folds one response in at
a time, sharing structure with the snapshot the UI is still reading, so
an arrival costs O(log n) and allocates almost nothing. Insertion-ordered
persistent collections are used deliberately: replaying them has to keep
the same latest-per-author tie behaviour a list walk had.

Folding is only sound if every add retracts what it supersedes, which a
rebuild never had to think about. A re-vote now explicitly removes the
voter from the options they previously held, and drops an option key that
empties out rather than leaving a zero-size set for winning() to find. A
later response that casts nothing valid still supersedes an earlier valid
one, per NIP-88's latest-per-author rule.

Removal and policy changes keep the full rebuild. Retracting a deleted
vote means finding that author's next-latest, which would need a
per-author index that is not worth carrying for a delete — and neither
path is on the hot path.

New tests: re-vote retraction and empty-key removal, a later invalid vote
superseding a valid one, deletion restoring the previous vote, an echoed
response leaving the flow untouched, and an equivalence check that folds
a messy stream — re-votes, a late vote, an unknown code, an out-of-order
arrival — one at a time and asserts it matches a rebuild exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 21:50:35 +00:00
Claude 5de8ccd626 fix(nip45): keep plain counts when merging with HLL; keep polls live
mergeCountResults moves into quartz's nip45Count package, next to the
HyperLogLog code that defines what a COUNT fan-out means, and countMerged
now delegates to it so every caller shares one policy.

The policy had a hole in the mixed case. Whenever any relay shipped HLL
registers, relays that answered with a plain count were dropped entirely
— so a relay outside the register set holding 5000 events was discarded
in favour of a ~50 estimate from the relays that did ship registers. Both
figures are lower bounds on the union, so the answer is the larger of the
two. Summing is still never an option: relays mirror each other and the
same event would be counted once per relay holding it.

Tests cover empty, single, plain-only (largest, not the sum), overlapping
and disjoint HLL unions, both mixed directions, register equality, and
the at-least-every-answer property. Two of them assert the merge property
— that merging registers equals counting the union — rather than a band
around the true cardinality, because HLL at m=256 has no bias correction
and reads high near n≈m; asserting accuracy there would be testing the
estimator, not this function.

Also fixes two things the results screen got wrong.

It never held a subscription, so the only votes it could show were the
ones the one-shot backfill caught: a vote cast while you were reading it
never arrived, because the feed card that used to carry the subscription
is disposed behind the screen. It now keeps the standard note
subscription open, which also loads the kind-1068 event when arriving by
deep link.

And it rebuilt its state on the UI thread. stateIn(viewModelScope) put
the combine's transform on Dispatchers.Main, so every new vote re-sorted
every voter there — during a backfill that is one full re-sort per
consumed event. The transform now runs on Default via flowOn, with the
dispatchers injected so tests keep driving them on the test scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 21:15:48 +00:00
Claude b3f5a543e4 fix(polls): gate the results link, page past the cap, report completeness
Three follow-ups on the results screen.

The results link no longer bypasses the vote-first gate. It was rendered
unconditionally under the card, so a reader who had not voted got the
voting controls plus a one-tap jump to the full split — and since opening
the screen counts as opting in, that choice stuck. RenderPollCard now
takes a footerContent slot rendered from RenderResults, which is reached
only in the branches where the tally is already visible, so the link
inherits the existing gate instead of working around it.

Backfill pages past the subscription cap. Kind 1018 rides in the shared
engagement filter with a small limit spread across every batched note, so
a busy poll arrived truncated with nothing saying so. Opening the results
now drains every response per relay via fetchAllPagesFromPool, from the
poll's declared relays plus the usual engagement relays.

Completeness is reported honestly. Relay COUNT results are never summed —
relays mirror each other, so adding them multiplies the poll. When relays
supply NIP-45 HyperLogLog registers the registers are merged and the
union re-estimated; when they don't, the largest single relay's count is
used as the tightest defensible lower bound. Relays without COUNT support
simply never answer and only make the figure smaller. The result is then
floored by what the local cache already holds, since an HLL estimate can
land under the truth. The footer appears only when responses are provably
missing, and says "about" whenever the figure is an estimate or some
relays stayed silent.

Adds PollResultsViewModelTest: poll-order options, relevance ordering,
option scoping that leaves the summary alone, muted voters counted but
unlisted, multi-choice rows, avatar-stack capping, live vote arrival, and
the three completeness paths including a loader that throws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 20:24:27 +00:00
Claude 846eeeb904 feat(polls): poll results screen with counts and voters
Adds a dedicated NIP-88 poll results screen showing how many votes each
option got and who voted for what, and fixes the tally bugs and the
missing subscription it would otherwise have inherited.

Tally is now poll-aware. PollTallyPolicy carries the kind-1068 rules —
valid option codes, poll type, and the open window — into ResponseTally,
which previously had no access to the poll it was counting. That fixes
four things at once: single-choice polls now read only the first response
tag instead of every one of them, unknown option codes no longer create
phantom buckets that drag real percentages down, responses stamped
outside the poll's window are excluded rather than winning on timestamp,
and percentages divide by distinct voters instead of total selections.
Responses routinely arrive before their poll, so the tally starts
permissive and recomputes when updatePolicy lands; both caches set it
from either arrival order.

Percentages are now share-of-voters. Single choice is unchanged; on
multiple choice a bar reads "7 in 10 people" and the bars can sum past
100%.

Android now asks the poll's own relay tags for kind 1018. Votes are
published there per NIP-88 and EventBroadcaster obeys that on the way
out, but the engagement filter only queried the author's inbox relays and
where the note was seen, so tallies were systematically short. Desktop
had already patched this per-card.

The screen itself reads that same tally off the poll Note — no new
subscription, no second cache, so the feed card and the results page
cannot disagree. Voter rows are UserLine unmodified, with the vote passed
into the trailingContent slot it already exposes. Tapping an option
scopes the list without moving the summary above it. Muted voters still
count toward the totals but are not listed, and the footer accounts for
every response excluded and why.

Entry points: a vote count beside each percentage on the feed card, and a
"N votes" link that opens the screen — so the avatar stack's "+N" is no
longer a dead end.

Desktop column, audience filter, sort, search and zap polls are not in
this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 15:52:37 +00:00
Claude 1bd39335b2 docs(commons): use UserLine for poll voter rows, show NIP-05
UserLine (ShowUserSuggestionList.kt:185) is already the row this needs: a
SlimListItem with ClickableUserPicture / UsernameDisplay /
WatchAndDisplayNip05Row, and it already exposes a nullable
trailingContent parameter. So the results screen writes no row and
modifies no existing composable — it passes the option label and
timestamp into the slot that is already there. Drops the previously
proposed trailingContent addition to UserCompose.

The second line is now the NIP-05 identifier rather than the about text,
drawn the way the app draws it: local part, verified mark, domain, no @,
with the local part ellipsizing and the domain left visible. A root
identifier shows the domain alone; the npub appears only as the
no-NIP-05 fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 15:05:23 +00:00
Claude 6053dc4f1d docs(commons): reuse the app's user row for poll voters
Three revisions to the poll results proposal and its mockup.

Voter rows are no longer a bespoke row. A voter is a user, and the app
already draws users one way: SlimListItem with UserPicture /
UsernameDisplay / AboutDisplay, which is what UserCompose is. The results
row wants the first three verbatim and differs only in the trailing slot,
where the vote goes instead of the follow buttons — so the proposal adds
a trailingContent parameter to UserCompose with a default that leaves
every existing call site unchanged, rather than forking the row. The
second line is the profile's about text, and the bespoke follows/you
chips are gone; ordering already carries that.

Drops the privacy call-out entirely.

Moves the audience filter, sort control and voter search out of the first
version into their own later phase; option chips remain the only filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 14:47:50 +00:00
Claude 3954c2ebe6 docs(commons): add annotated mockup for the poll results page
Self-contained HTML mockup accompanying the poll results proposal: the
Android screen with seven numbered callouts, the Desktop deck column, the
loading / closed / empty states, and a side-by-side of the two candidate
readings for a multiple-choice percentage.

Mockup interiors use the app's real theme values from ui/theme/Color.kt
and Theme.kt so the screens read as Amethyst rather than as generic UI;
the annotation layer around them uses a separate neutral set. Light and
dark both supported, following the viewer's preference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 14:32:26 +00:00
Claude 140048a209 docs(commons): propose an extended poll results page
Design proposal for a dedicated NIP-88 poll results surface showing
per-option vote counts and the full list of who voted for what, shared
between Android and Desktop.

Also documents four correctness gaps in the current tally that the page
would otherwise inherit: multi-choice percentages divide by selections
instead of voters, single-choice polls count every response tag instead
of the first, votes outside the poll timeframe still count, and unknown
option codes inflate the denominator. All four collapse into making
ResponseTally poll-aware.

Plus the data-completeness gap: Android never queries a poll's own
`relay` tags for kind 1018, so its tallies are systematically short
compared to Desktop, which already works around this per-card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQorYBSv9oMvKa8hF1uGBv
2026-08-03 14:05:08 +00:00
Claude b6bf6ccf3b fix: address audit findings on the audience flap
Self-audit of the redesign. The headline is a crash: the group chip's
label is "%1$s · %2$d", and the call passed the count as a String to
satisfy stringRes's String-vararg overload — String.format then throws
IllegalFormatConversionException. The chip is the payoff of the whole
feature, so every bulk add crashed the composer on the next frame. The
format specifier is now %2$s.

Correctness: provenance was recorded for every member of an added list,
not just the ones the add introduced. Removing that list's chip would then
evict people who were in pTags for an unrelated reason — dropping the
author of the note being replied to just because a list happened to
contain them. The add rule moved into AudienceSelection.addToAudience so
the invariant is pinned by tests rather than living in the ViewModel.

Dead end: a list larger than the hard cap opened with every member
selected, which the confirm button then refused, leaving ~100 individual
taps as the only way out. Oversized lists now open with nothing new
selected.

Performance: the screen re-derived pTags.toImmutableList() and
mutedNotifies.toImmutableSet() on every recomposition. That minted fresh
collections, invalidated the groupChips remember every single time, and
handed AudienceFlap new parameter identities so it could never skip. Both
are now remembered on the ViewModel state they derive from.

Visual: the facepile punched its separator rings in colorScheme.background
while the flap paints a primary tint over it, leaving untinted discs
floating on the tint in exactly the mode this design exists for. Rings now
composite against the flap's own surface. The flap's lock also rotated
-18° in public mode, where the glyph is a bell, not a lock — a permanently
crooked bell. Rotation dropped; the size and colour shift carry the state.
And a locked-private note lost its filled pill entirely, because the
button is disabled there and M3's disabled colours overrode it.

Also: 48dp touch targets on the manage and back buttons (were 30/32dp),
sheet list heights budgeted against the screen instead of fixed dp that
overflow a short screen or a large font scale, the screen's duplicate
suggestion list no longer composes behind the sheet's own, and the
unreachable wantsToAddNotifyUser flag and unwired removeFromReplyList are
gone.

19 selection tests (up from 13); full amethyst unit suite green at 1075.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
2026-07-31 03:43:23 +00:00
Claude e51b824601 docs: record what shipped in the audience redesign
Marks the plan as shipped (P1 + P2 + the seven visual moves), and records
the two deviations: the empty state kept the "only you" fact the old
paragraph carried, since canPost() does not gate a private note on having
recipients; and move 07's staggered arrival is not implemented because the
facepile is a plain Row with no animateItem to hang it on.

Lists what is still open: the "last private note" entry, adopting the flap
in the comment and group-DM composers, and provenance being
compose-session-only so a group chip does not survive a draft round trip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
2026-07-31 03:05:19 +00:00
Claude 2bcb8c657c feat: redesign the composer's Notify/Visible-to row as an audience flap
The audience is the most consequential control in the short-note composer
— when the lock is on it decides who can decrypt the note at all — but it
was drawn as a bold grey word followed by loose chips in a FlowRow, with a
muted member rendered at alpha(0.4), Android's universal "disabled" signal.

Replaces it with AudienceFlap: a container that tints when the note is
sealed, showing a facepile plus "Alice, Bruno and 7 others" at rest. That
is the same height at three people or ninety, which is what makes adding a
whole people list viable — chips only appear when the row is expanded.

Also adds AudienceSheet, one entry point (the + on the flap) for search,
people lists and follow packs, replacing the "Add" chip that competed
visually with the people it acted on. Bulk adds go through
addAllToReplyList — one state write per field, since N single adds would
recompose the row and save a draft N times for one gesture.

Selection rules live in AudienceSelection, free of Compose so they unit
test on the JVM:

- private members of a kind-30000 list start deselected; adding one
  publishes their pubkey to every other recipient
- muted/blocked people start deselected; already-added ones count toward
  the header but are never re-added
- recipients with no NIP-17 inbox relay are flagged, but only for private
  notes, where the wrap may not reach them
- a soft cap (25) discloses that a private note is sealed and signed once
  per recipient — two signer round trips each on NIP-46/NIP-55 — and a
  hard cap (100) refuses rather than silently truncating

Provenance tracking lets a bulk add be undone as a unit: removing the
"Close friends" chip drops only the people that list alone brought in,
leaving anyone also added by hand or by another list in place.

Rounds it out with the mode transition — the lock closes and takes a
filled pill, the flap tints, a haptic tick lands, and the send button
relabels to "Send privately" so the button admits what it is about to do.
The empty state stops being a grey paragraph and becomes the thing you
tap, while keeping the fact the old copy carried: with nobody picked, a
sealed note reaches only its author.

Notifying() is untouched; the comment composer keeps the old row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
2026-07-31 02:45:27 +00:00
Claude c2e97f60b9 docs: add the visual direction for the Notify/Visible-to row
Seven moves that turn the loose chip row into a sealed "envelope flap":
container over row, facepile over chips, an explicit muted state instead
of alpha(0.4), one manage affordance instead of two competing chips, an
actionable empty state, a choreographed private-mode transition, and a
staggered bulk add.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
2026-07-31 02:12:45 +00:00
Claude f58ec177d5 docs: propose a people-list picker for the composer's Notify/Visible-to row
Design proposal for adding every member of a NIP-51 people list or follow
pack to a short note's audience in one gesture, from the lock (private
note) flow in ShortNotePostScreen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1eVeWjMgG8WSU6jKk8d3o
2026-07-31 01:47:24 +00:00
3426 changed files with 308220 additions and 136380 deletions
+73 -22
View File
@@ -3,7 +3,7 @@
## Project Overview
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `amethyst`,
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `commonsUI`, `amethyst`,
`desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should
contain implementations of Nostr specifications and utilities to help implement them. Commons stores
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop
@@ -17,7 +17,9 @@ relay-server code; smaller modules are `benchmark` (Android macrobenchmarks),
`relayBench` (head-to-head relay benchmark — boots geode, strfry and other
relay binaries, replays a shared deterministic corpus, measures ingest/query/
NIP-77 sync; `./relayBench/run.sh`, see `relayBench/README.md`) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `marmotQuic` is the Marmot raw-QUIC transport
binding for agent text stream previews (`transports/quic.md`) on top of
`:quic` — its own ALPNs and framing, not WebTransport. `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
production listener AND speaker paths both run on moq-lite to interop with the
@@ -46,11 +48,18 @@ amethyst/
│ ├── androidMain/ # Android-specific (crypto, storage)
│ ├── jvmMain/ # Desktop JVM-specific
│ └── iosMain/ # iOS-specific
├── commons/ # Shared UI components (convert to KMP)
├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe
│ └── src/
│ ├── commonMain/ # Shared composables, icons, state
│ ├── androidMain/ # Android-specific UI utilities
── jvmMain/ # Desktop-specific UI utilities
│ ├── commonMain/ # Domain models, state holders, ViewModels, services
│ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop
── androidMain/ # Android-specific actuals (Keystore, DataStore)
│ └── jvmMain/ # Desktop-specific actuals (keyring, upload pipeline)
├── commonsUI/ # Shared Compose UI on top of commons (composables, icons, theme, Coil, resources)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, theme, composeResources (strings/fonts)
│ ├── jvmAndroid/ # Markdown renderer, Coil OkHttp fetchers
│ ├── androidMain/ # Android Coil bridge
│ └── jvmMain/ # Desktop Coil bridge (+ skikoMain shared with iOS)
├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport)
│ └── src/
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
@@ -68,20 +77,33 @@ amethyst/
**Sharing Philosophy:**
- `quartz/` = Nostr business logic, protocol, data (no UI)
- `commons/` = Shared code for every front end (Android, Desktop, iOS, and the
headless `cli`): domain models, state holders, ViewModels, the relay client,
shared services, **and** the Compose UI that ≥1 GUI front end renders. The
package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?"
guide are documented in **`commons/ARCHITECTURE.md`** — read it before adding
a new package or dropping code into `commons`.
- `commons/` = Shared **headless** code for every front end (Android, Desktop,
iOS, and the headless `cli`): domain models, state holders, ViewModels, the
relay client, shared services. It may use the Compose *runtime*
(`@Stable`/`@Immutable`, snapshot state) but never Compose UI, Coil or
Compose resources — the build enforces this: `commons` has no such deps.
- `commonsUI/` = Shared **Compose UI** that ≥1 GUI front end renders
(composables, `ui/theme`, icons, robohash, Coil fetchers, markdown, the
`composeResources` strings/fonts and the generated `Res` class). Depends on
`commons` (as `api`); `cli` never depends on it. Files keep their
`com.vitorpamplona.amethyst.commons.*` packages — the split is a module
boundary, not a package rename. The package taxonomy, the CLI-safe / UI
boundary, and a "where does my code go?" guide are documented in
**`commons/ARCHITECTURE.md`** (+ `commonsUI/ARCHITECTURE.md`) — read them
before adding a new package or dropping code into either module.
- `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any
KMP project that needs MoQ. Has no Android-framework dependencies.
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio.
- `marmotQuic/` = Marmot's raw-QUIC binding for agent text stream previews.
Takes `:quic` for the connection and `:quartz` for the record/envelope
codecs. Not WebTransport — the binding has its own ALPNs and writes frames
straight onto QUIC streams, so it deliberately does not reuse
`nestsClient`'s `WebTransportSession`.
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
standalone relay); never on `:amethyst` or `:desktopApp`.
standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`.
**Plans per module:** design docs for new subsystems live in the owning
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
@@ -173,16 +195,19 @@ etc. instead of re-implementing them.
**Share vs keep platform-native:**
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
`commons/commonMain/` (major UI components, **ViewModels** under
`viewmodels/`, icons). ViewModels are platform-agnostic state + logic
(StateFlow/SharedFlow), so they belong in `commons`.
- **Share** → `quartz/commonMain/` (business logic, data models, protocol),
`commons/commonMain/` (**ViewModels** under `viewmodels/`, state holders,
relay client, services — headless) and `commonsUI/commonMain/` (major UI
components, icons, theme). ViewModels are platform-agnostic state + logic
(StateFlow/SharedFlow), so they belong in `commons`; anything that imports
`androidx.compose.ui`/`foundation`/`material3`, Coil, or `Res` belongs in
`commonsUI`.
- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android
`Activity`), navigation (sidebar vs bottom nav), platform interactions
(gestures, keyboard shortcuts), system integrations (notifications, file
pickers).
When extracting a composable: move it to `commons/commonMain/` (see
When extracting a composable: move it to `commonsUI/commonMain/` (see
`/compose-expert`), add expect/actual for any platform behavior (see
`/kotlin-multiplatform`), then point both Android and Desktop at the shared
version. `quartz/` is protocol-only — no composables.
@@ -209,7 +234,7 @@ version. `quartz/` is protocol-only — no composables.
## Dependency Licensing
**MANDATORY whenever you introduce a new third-party dependency** — in *any*
module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`,
module (`quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, `quic`,
`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a
module's `build.gradle.kts`: determine its license **before** wiring it in.
Amethyst ships under the **MIT** license, so a copyleft dependency linked into a
@@ -246,9 +271,9 @@ JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
## Icons
The Material Symbols font bundled at
`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
`commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
is a **subset** that only contains the glyphs referenced from
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
**MANDATORY:** Whenever you add a new icon — i.e. introduce a
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
@@ -263,7 +288,33 @@ Commit the regenerated `material_symbols_outlined.ttf` alongside your
at runtime because the glyph is not in the bundled font.
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
regenerating. See `tools/material-symbols-subset/README.md` for details and
regenerating.
### Amethyst's own icons are also a font
The icons in `commonsUI/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
overrun P90 **-17.4%** on the feed scroll benchmark.
**MANDATORY:** whenever you add or change an icon under `commonsUI/.../commons/icons/`,
regenerate the font *and* its codepoint table together:
```bash
python3 tools/icon-font/build_icon_font.py \
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
```
Both outputs must be committed together: codepoints are assigned in filename order,
so adding an icon renumbers the ones after it, and a stale `AmethystIcons.kt` then
points at the wrong glyph. Needs `fonttools` (`pip install fonttools`). The script
prints any icon it could not convert — an icon that is skipped must keep using its
`ImageVector`.
See `tools/material-symbols-subset/README.md` for details and
prerequisites (`pip install fonttools brotli`).
## Code Formatting
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Fail if the Compose resource catalog carries Android-only string escaping.
Android's aapt and Compose Multiplatform do not share escaping rules. Compose
(compose-gradle-plugin `handleSpecialCharacters`) resolves only \\uXXXX, \\n and
\\t, and collapses \\\\. It leaves \\' \\" \\? \\@ alone and renders Android's
quote-wrapping literally, so a value carried verbatim out of `res/values/` ships
a visible backslash: the login screen once read `Don\\'t have a Nostr account?`.
`tools:` attributes are the same class of mistake. They are an Android-lint
construct whose namespace is declared on the Android `<resources>` root; Compose
catalog roots do not declare it, so the prefix is unbound and the XML malformed.
**This recurs on every Crowdin sync.** Crowdin holds the Android-escaped source,
so each import reintroduces it -- twice in two days, 2,068 escaped apostrophes
across 40 locales each time, always the apostrophe-heavy regional variants
(uz-rUZ, fr-rFR, fr-rCA, tr-rTR). Like the orphan-strings desync, it arrives
through a bot-authored PR with no local session in the path, so CI is the layer
that has to catch it.
Repair with:
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\
commonsUI/src/commonMain/composeResources
`--no-unwrap-quotes` is mandatory on already-migrated files: escape conversion is
idempotent, quote-unwrapping is not, and a second unwrap strips the real display
quotes from values like `import_follows_tips`.
Only the Compose catalog is scanned. In an Android res tree the same escaping is
correct and must be left alone.
"""
import re
import sys
from collections import defaultdict
from pathlib import Path
CATALOG = "*/src/*/composeResources/values*/strings.xml"
# A backslash escape that is not itself escaped. \n, \t, \uXXXX and \\ are fine --
# Compose resolves those itself.
ANDROID_ESCAPE = re.compile(r"(?<!\\)\\(['\"?@])")
TOOLS_ATTR = re.compile(r'tools:[\w.-]+="')
def find_violations(root: Path):
found = defaultdict(lambda: defaultdict(int))
for path in sorted(root.glob(CATALOG)):
text = path.read_text(encoding="utf-8", errors="replace")
for esc in ANDROID_ESCAPE.findall(text):
found[path][f"\\{esc}"] += 1
n = len(TOOLS_ATTR.findall(text))
if n:
found[path]["tools: attribute"] += n
return found
def main() -> int:
root = Path(__file__).resolve().parents[2]
found = find_violations(root)
if not found:
return 0
out = sys.stderr
total = sum(sum(k.values()) for k in found.values())
print(
f"Android-only escaping in the Compose resource catalog: "
f"{total} occurrence(s) in {len(found)} file(s).",
file=out,
)
print("Compose does not resolve these; they render literally.\n", file=out)
for path, kinds in sorted(found.items(), key=lambda kv: -sum(kv[1].values()))[:12]:
detail = ", ".join(f"{k} x{v}" for k, v in sorted(kinds.items()))
print(f" {path.relative_to(root)}: {detail}", file=out)
if len(found) > 12:
print(f" ... and {len(found) - 12} more file(s)", file=out)
print(
"\nRepair:\n"
" python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\\n"
" commonsUI/src/commonMain/composeResources\n"
"(--no-unwrap-quotes is mandatory on already-migrated files.)",
file=out,
)
return 1
if __name__ == "__main__":
sys.exit(main())
+61
View File
@@ -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())
+87
View File
@@ -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())
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# PreToolUse gate: the Compose resource catalog must not carry Android escaping.
#
# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool.
# Delegates to compose_escaping_check.py, which scans
# `*/src/*/composeResources/values*/strings.xml` for \' \" \? \@ and `tools:`
# attributes. Compose resolves only \uXXXX, \n and \t, so anything else carried
# verbatim out of an Android res tree renders literally -- the login screen once
# read `Don\'t have a Nostr account?`.
#
# Why a dedicated hook: this is reintroduced by every Crowdin sync, because
# Crowdin holds the Android-escaped source. It came back twice in two days, 2,068
# escaped apostrophes across 40 locales each time. Nothing in the Gradle build
# fails on it -- the strings simply ship wrong -- so there is no slow gate this
# stands in for; it is the only gate.
#
# Run the scan by hand any time with: .claude/hooks/compose_escaping_check.py
set -uo pipefail
hook_dir="$(cd "$(dirname "$0")" && pwd)"
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
payload="$(cat)"
# Same cheap pre-filter as the orphan-strings gate: only a payload mentioning a
# push or the PR tool can possibly match, and this runs on every Bash call.
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/compose_escaping_check.py"
+37
View File
@@ -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"
+13 -38
View File
@@ -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
+10
View File
@@ -8,6 +8,16 @@
"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
},
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-compose-escaping.sh",
"timeout": 30
}
]
}
@@ -47,7 +47,7 @@ Walk the imports. The usual offenders:
| `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). |
| `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. |
| `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. |
| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. |
| `androidx.compose.*` | Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` must stay out of `commons` entirely — they belong in `:commonsUI`, which Amy never depends on. Only the Compose *runtime* (`@Stable`, snapshot state) is allowed in `commons`. |
### Step 3 — Pick a migration strategy per dependency
@@ -66,7 +66,7 @@ Walk the imports. The usual offenders:
# Target location depends on what it is:
# - Protocol → quartz/src/commonMain/kotlin/…
# - Business logic → commons/src/commonMain/kotlin/…
# - UI → commons/src/commonMain/… (needs Compose Multiplatform)
# - UI → commonsUI/src/commonMain/… (needs Compose Multiplatform; never used by amy)
git mv amethyst/src/main/java/com/.../FollowListManager.kt \
commons/src/commonMain/kotlin/com/.../FollowListManager.kt
```
+7 -7
View File
@@ -24,7 +24,7 @@ Visual UI patterns for sharing composables across Android and Desktop.
## Philosophy: Share by Default
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
**Default to `commonsUI/commonMain`** (shared composables live in `:commonsUI`, the Compose half of the shared layer; headless state/ViewModels stay in `:commons`) unless platform experts indicate otherwise.
### Always Share
@@ -416,7 +416,7 @@ fun DataScreen(uiState: UiState) {
}
```
**Components** (all in `commons/commonMain`):
**Components** (all in `commonsUI/commonMain`):
- `LoadingState` - Progress indicator + message
- `EmptyState` - Empty message + optional refresh button
- `ErrorState` - Error message + optional retry button
@@ -527,12 +527,12 @@ fun FeedList(items: List<Item>) {
| Task | Pattern | Location |
|------|---------|----------|
| Reusable UI | State hoisting | commons/commonMain |
| Reusable UI | State hoisting | commonsUI/commonMain |
| Simple state | remember { mutableStateOf() } | Composable scope |
| Derived state | derivedStateOf { } | remember block |
| Async → state | produceState { } | Composable function |
| Custom icons | roboBuilder + PathData | commons/icons |
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
| Custom icons | roboBuilder + PathData | commonsUI/icons |
| Loading/Error | LoadingState, ErrorState | commonsUI/ui/components |
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
@@ -540,7 +540,7 @@ fun FeedList(items: List<Item>) {
### Creating a Shared Component
1. Start in `commons/src/commonMain/kotlin/.../ui/components/`
1. Start in `commonsUI/src/commonMain/kotlin/.../ui/components/`
2. Use Material3 primitives only
3. Hoist state (parameters for data, callbacks for events)
4. Add modifier parameter
@@ -551,7 +551,7 @@ fun FeedList(items: List<Item>) {
1. Read current implementation in `amethyst/` or `desktopApp/`
2. Identify pure visual logic (no platform APIs)
3. Create in `commons/commonMain` with hoisted state
3. Create in `commonsUI/commonMain` with hoisted state
4. Replace platform implementations with shared component
5. Keep platform-specific wrappers if needed
@@ -1,11 +1,11 @@
# Shared Composables Catalog
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
This catalog documents shared UI components in `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/` (the Compose half of the shared layer; headless state stays in `commons`).
## Directory Structure
```
commons/src/commonMain/kotlin/.../commons/ui/
commonsUI/src/commonMain/kotlin/.../commons/ui/
├── components/ # Reusable UI components
├── screens/ # Screen-level composables
├── theme/ # Theming and styling
@@ -232,7 +232,7 @@ private val pathData1 = PathData {
```
**roboBuilder** - Custom ImageVector.Builder DSL
- Located in: `commons/robohash/`
- Located in: `commonsUI/.../commons/robohash/`
- Pattern: Builder-based, composable paths
- Parts: Face, Eyes, Mouth, Body, Accessory (0-9 variants each)
- Colors: Dynamic (fgColor parameter) + Black constants
@@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
(`duplicate: a newer version of this replaceable event is already stored`), which the relay
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
by lowest id; older re-inserts blocked but acknowledged as already covered.
**STORE-W02 — addressable supersession.** Same as W01 with unique index
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: feed-patterns
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
description: Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
---
# Feed Patterns
@@ -25,7 +25,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
│ ◄── MarmotGroupFeedViewModel │
│ │
│ │
│ commons/.../ui/feeds/ (shared, KMP) │
│ commons/.../feeds/ (shared, KMP) │
│ IFeedFilter / FeedFilter<T> (abstract base) │
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
│ ChangesFlowFilter │
@@ -68,7 +68,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
### Shared filter bases (commons)
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/`:
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/feeds/`:
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
@@ -100,7 +100,7 @@ Concrete filters (Home, Hashtag, Profile, Bookmark, Notifications, Communities,
## Filter Sharing (Android vs Desktop)
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/`**shared**. ViewModels are in `commons/.../viewmodels/`**shared**.
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../feeds/`**shared**. ViewModels are in `commons/.../viewmodels/`**shared**.
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
+191 -23
View File
@@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
## Overview
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs the missing keys, then offers the two things that close them: **translate** the ones needing translation, and **copy the English value verbatim** for the ones a locale deliberately keeps in English (since 2026-09-12 that copy is what seeds Crowdin — see Background).
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
@@ -24,9 +24,9 @@ There are two separate `strings.xml` trees, each with its own default `values/`
| Tree | Default file | Per-locale file |
|------|--------------|-----------------|
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-<locale>/strings.xml` |
| **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` |
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
@@ -37,7 +37,7 @@ The `commons` tree appeared when shared event-renderer composables were extracte
Detect name-overlap **and flag value mismatches** in one pass:
```bash
cdef=commons/src/commonMain/composeResources/values/strings.xml
cdef=commonsUI/src/commonMain/composeResources/values/strings.xml
adef=amethyst/src/main/res/values/strings.xml
comm -12 \
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
@@ -54,7 +54,7 @@ Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
```bash
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml
grep -nE '<string name="[^"]*">"' commonsUI/src/commonMain/composeResources/values-*/strings.xml
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
```
@@ -67,15 +67,49 @@ grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
## Background: Crowdin strip-identical behavior
## Background: source-identical translations and the `import_eq_suggestions` flag
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin does not *store* a translation that exactly equals the source unless it is told to, so historically a key a translator deliberately kept as English (brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, version prefixes like `"v%1$s"`) never appeared in the locale's `strings.xml`, even though the Crowdin UI showed it as 100% translated.
**That changed on 2026-09-12.** `.github/workflows/crowdin.yml` now passes `import_eq_suggestions: true` to `crowdin/github-action`, so `upload_translations` no longer skips values equal to the source — whatever sits in the repo's locale files is seeded into Crowdin's database, identical values included. `auto_approve_imported` stays at its default `false`, so they arrive as **pending** translations for a translator to approve.
Confirmed end-to-end the same day: the first sync after the flag landed (workflow run `34706537802` → PR #4107) rewrote all five touched locale files in Crowdin's own key order with **zero net key changes** — 323 additions and 323 removals that pair up exactly. All 330 identical values pushed that morning came back down intact, unapproved included. Since Crowdin's download *replaces* file content with its export, a value it did not hold would have vanished; none did.
**Reading such a sync diff: compare key *sets* per file, never `-`/`+` lines separately.** A reorder looks identical to a mass strip under `grep '^-'`, and it will convince you the mechanism failed when nothing changed at all.
What this means for this skill:
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
2. **Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
3. **Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
1. **The raw on-disk diff is the candidate set.** A key missing from a locale file is genuinely untranslated, *or* a source-identical entry stripped before 2026-09-12 that no sync has re-seeded yet. Both are reported, and both are now actionable in the repo — translate the first, copy English into the second. The Crowdin web UI ("N untranslated") remains the ground truth for what needs human work.
2. **Source-identical entries are still recognizable, but no longer skipped.** Brand terms (`Nowhere X`), loanwords (`Apps` / `Feed` / `Issues`), symbol- or format-only values (`v%1$s`, `+%1$d`, `%1$d/%2$d`, `∞`, 👀) and example placeholders (`iPhone 13`, `https://example.com`) are the usual cases. Copy the English value into the locale file verbatim so the upload can seed it.
3. **DO add source-identical values — that is now the mechanism, not churn.** A key absent from a locale file is invisible to `upload_translations`; writing the English value in is what gets it into Crowdin, so a translator approves it once in bulk instead of typing it into the UI ~70 times per locale. (Runtime behaviour is unchanged either way: Android still falls back to `values/strings.xml`.) Two exclusions:
- **Never for `<plurals>`.** Copying English `one`/`other` into cs/pl trips `MissingQuantity`, which is a CI error (cs needs `one`/`few`/`many`/`other`). Plurals stay a Crowdin-UI job.
- **Not for words a locale would genuinely translate.** German `buzz_dm_workspace` ("Arbeitsbereich"), `workout` ("Training"), `relay_group_threads_title` ("Themen"), `calendar_rsvp_section` ("Zusagen") are *gaps*, not deliberate English keeps. Copying English there seeds a wrong pending suggestion — list those for the human to translate rather than approve.
4. **A repo-side edit to a translated value only sticks where Crowdin's database
doesn't contradict it.** Download replaces file content with Crowdin's current
export; it does not diff or merge. So a hand fix to a locale file survives only
if Crowdin happens to hold the same value (or holds nothing for that key). If
Crowdin holds a *different* value — including an **empty** one — the next sync
silently reverts you.
Observed 2026-08-13/14 in one pass, which is what makes the rule concrete:
`pow_estimate_minutes[few]` (pl) **survived** the sync because Crowdin's
approved value matched the fix, while `nest_listener_count[many]` (pl) was
**reverted to empty** two commits later because Crowdin stores an empty string
there. Same file, same commit, opposite outcomes.
Consequences: fixing a *value* durably means entering it in the Crowdin web UI
— no repo commit will hold it. Changes to the **source** file are different and
do stick, because that file is Crowdin's input, not its output: deleting a key
from `values/strings.xml` removes it project-wide, and attributes declared
there propagate into every export.
**This does not contradict item 3 — the two cases differ.** Seeding a key
Crowdin holds *nothing* for (the identical-value copy) sticks, because there is
no stored value to contradict it; that is exactly why the copy pass works.
*Overwriting* a value Crowdin already holds differently — including an empty
one — still loses on the next sync. Add missing entries in the repo; change
existing translations in the UI.
> **Historical note:** an earlier version of this skill tried to auto-filter the
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
@@ -113,14 +147,14 @@ Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
# commons tree
Default: commons/src/commonMain/composeResources/values/strings.xml
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
Default: commonsUI/src/commonMain/composeResources/values/strings.xml
Target: commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml
```
A convenient way to run the whole technique twice is to loop over the two base dirs:
```bash
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do
for base in amethyst/src/main/res commonsUI/src/commonMain/composeResources; do
echo "########## tree: $base ##########"
# ... run the diff/count/value-extraction commands with $base/values[...] ...
done
@@ -153,7 +187,7 @@ comm -23 \
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
Locale files are asymmetric — legacy pre-2026-09-12 strips and uneven translator progress both leave different keys missing in different locales — so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
```bash
for locale in cs de-rDE sv-rSE pt-rBR; do
@@ -171,7 +205,7 @@ for locale in cs de-rDE sv-rSE pt-rBR; do
done
```
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set: translate what needs translating, and copy the English value verbatim for the entries a locale keeps in English (see Background).
### 3. Get English values for missing keys
@@ -239,8 +273,8 @@ Flag and offer to fix:
# hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales, in BOTH resource trees.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
commonsUI/src/commonMain/composeResources/values/strings.xml \
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="one"/ {
@@ -260,8 +294,8 @@ Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
commonsUI/src/commonMain/composeResources/values/strings.xml \
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
# Skip Arabic, Latvian and Welsh — they natively use the zero category.
# (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.)
case "$f" in
@@ -280,6 +314,64 @@ done
For each hit, warn the user that the entry is unreachable in that locale. The fix is to **remove the `<item quantity="zero">`** and, if the UX wanted distinct wording for count=0, add a separate `<string>` plus an `if (count == 0)` branch at the call site (see "Plurals: handle with care" below).
Also audit **format-specifier parity and empty items** across the locales you
touched. These are a different defect class from a missing key — the key is
present and looks translated, but the placeholder was dropped, escaped, or the
item left blank, so the number never reaches the user:
```bash
python3 - <<'PY'
import re, io, glob
keyre = re.compile(r'<string name="([^"]+)"[^>]*>(.*?)</string>', re.S)
plre = re.compile(r'<plurals name="([^"]+)"[^>]*>(.*?)</plurals>', re.S)
itre = re.compile(r'<item quantity="([^"]+)"[^>]*>(.*?)</item>', re.S)
# (?<!\\) is REQUIRED: \%2$d is an escaped literal, not a placeholder.
phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])')
sig = lambda t: sorted(m.group(0) for m in phre.finditer(t))
for base in ['amethyst/src/main/res', 'commonsUI/src/commonMain/composeResources']:
d = io.open(f'{base}/values/strings.xml', encoding='utf-8').read()
dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)}
dpl = {}
for m in plre.finditer(d):
s = set()
for it in itre.finditer(m.group(2)): s.update(sig(it.group(2)))
dpl[m.group(1)] = sorted(s)
for p in sorted(glob.glob(f'{base}/values-*/strings.xml')):
if '/values-ar' in p: continue # see caveat below
s = io.open(p, encoding='utf-8').read()
for m in keyre.finditer(s):
k, v = m.group(1), m.group(2)
if k in dstr and sig(v) != dstr[k]:
print(f'{p}\n {k} base={dstr[k]} loc={sig(v)}')
for m in plre.finditer(s):
k = m.group(1)
if k not in dpl: continue
for it in itre.finditer(m.group(2)):
if sig(it.group(2)) != dpl[k]:
print(f'{p}\n {k}[{it.group(1)}] base={dpl[k]} loc={sig(it.group(2))}')
PY
# Empty plural items render as nothing at runtime — always a bug.
grep -rn '<item quantity="[a-z]*"></item>' \
amethyst/src/main/res/values*/strings.xml \
commonsUI/src/commonMain/composeResources/values*/strings.xml
```
Three things this scan taught us, all of which it now encodes:
- **The `(?<!\\)` lookbehind is not optional.** Without it the scan matches
`%2$d` *inside* `\%2$d` and scores a broken string clean. `\%` is not a
recognised Android escape, but lint reads it as one, so the placeholder is
reported missing. (2026-08-13: `nip46_signer_relays_some_down` in sl-rSI
survived a "clean" sweep exactly this way.)
- **Skip Arabic.** Its `zero`/`one`/`two` forms omit the numeral idiomatically
("دقيقتان" = "two minutes"), so ~30 hits there are correct translations, not
defects. Everything else is worth reading.
- **Repeated indices are legitimate.** `%1$s` appearing twice in a translation
where the base uses it once is normal — German repeats the name where English
says "They". Compare *sets*, and treat an arity difference as a question, not
a verdict.
Quick scan over the missing keys:
```bash
@@ -340,6 +432,37 @@ When adding or proposing **`<plurals>`** entries, follow these rules:
pluralStringResource(R.plurals.foo_items, count, dateLabel, count)
}
```
- **Converting an existing `<string>` to `<plurals>`: give every locale its FULL
category set, not just `other`.** You must convert it in every locale that
already had the `<string>` (aapt2 rejects a resource-type mismatch across
locales, and an orphaned locale `<string>` trips `ExtraTranslation`) — but
carrying the old text across as an `other`-only block, on the theory that
Crowdin backfills the rest, **fails `MissingQuantity` and breaks CI before
Crowdin ever gets a turn.** Supply `one`/`few`/`many` for pl, `one` for hu, and
so on, at conversion time.
Note this contradicts `amethyst/src/main/res/CLAUDE.md` step 3, which still
advises the `other`-only shortcut. That advice is wrong; prefer this.
Watch the declension when you do it: the retained text is usually the *plural*
form, so reusing it verbatim for `one` produces "1 odpowiedzi". (2026-08-13:
converting `poll_results_selections` with `other` only errored on both hu and
pl, and the retained pl text was the few/many form.)
- **A `tools:ignore` suppression must go on the SOURCE entry in
`values/strings.xml`, never on a locale file.** Crowdin propagates attributes
declared on the source into every translation it exports; an attribute you add
to `values-xx/strings.xml` alone is simply absent from the next export. That is
why the existing `tools:ignore="Typos"` entries survive — they are declared on
the source, and the copies in cs/de/ar/eo/bn are the *result* of propagation,
not evidence that locale-file attributes stick. (2026-08-13: an
`ImpliedQuantity` suppression added only to `values-pt-rBR` was stripped by the
next sync and took `main`'s CI red.)
Before reaching for a suppression at all, check whether the key is even used —
a `grep -rn "<key>" --include='*.kt'` that returns nothing means deleting the
key is the better fix than muting the rule that objects to it.
- Reference: [Android `<plurals>` docs](https://developer.android.com/guide/topics/resources/string-resource#Plurals) and [CLDR plural rules](https://unicode-org.github.io/cldr-staging/charts/latest/supplemental/language_plural_rules.html).
**Then ask the user:** "Would you like me to translate these missing strings into [list of target locales]?"
@@ -350,7 +473,7 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because locale files are asymmetric (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
@@ -380,18 +503,63 @@ When adding translated strings to locale files:
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
```
- **Then run Android lint. This is the gate that actually matches CI, and the
checks above do NOT substitute for it.** Duplicate-key + well-formedness +
`convertXmlValueResourcesForCommonMain` can all pass on a change that still
takes CI red, because the plural rules live in lint, not in the resource
compiler:
```bash
./gradlew :amethyst:lintPlayBenchmark # the task CI runs (.github/workflows/build.yml)
```
There is no `lint-baseline.xml` in this repo and only `MissingTranslation` is
disabled (`amethyst/build.gradle.kts`), so `abortOnError` bites on the first
error. Three rules matter for a translation pass:
| Rule | Fires when | Severity |
|------|-----------|----------|
| `MissingQuantity` | a locale's `<plurals>` omits a CLDR category that locale uses | **error** for core categories — gates CI |
| `ImpliedQuantity` | a `quantity` item has no format argument in a locale where that category spans more than one number | **error** — gates CI |
| `StringFormatCount` / `StringFormatMatches` | a translation's placeholder count/type disagrees with the base entry | warning |
(2026-08-13: a pass that cleared the duplicate/XML gate above still failed
`lintPlayBenchmark` with 3 errors. Compiling is not evidence — `compileDebugKotlin`
passed on the same change.)
- **Confirm the report says zero errors, don't just trust BUILD SUCCESSFUL** of a
wider invocation:
```bash
python3 -c "
import json,io,collections
d=json.load(io.open('amethyst/build/reports/lint-results-playBenchmark.sarif',encoding='utf-8'))
r=d['runs'][0]['results']
print(dict(collections.Counter(x.get('level','warning') for x in r)))
for x in r:
if x.get('level')=='error': print('ERROR', x['ruleId'], x['locations'][0]['physicalLocation']['artifactLocation']['uri'])
"
```
## Common Mistakes
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commonsUI/src/commonMain/composeResources`). A key extracted into `commonsUI/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Skipping source-identical entries instead of copying them in** — correct before 2026-09-12, wrong now. With `import_eq_suggestions: true` the repo file is the *seed* for Crowdin's database, so a key you leave out stays untranslated in the UI forever and reappears in every future scan. Copy the English value verbatim, except for `<plurals>` (trips `MissingQuantity`) and words the locale would really translate. (Confirmed by PR #4107: 330 identical values survived the next sync with zero net changes.)
- **Skipping per-locale diffs when only diffing cs** — different keys are missing in different locales (legacy strips plus uneven translator progress), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **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".
- **Reading an empty `<item quantity="…"></item>` as merely "untranslated"** — it renders as nothing at runtime, and for a category like Polish `many` (521, 2531, …) that is the common case, not an edge case. Grep for them explicitly; the missing-key diff will never surface one because the key is present.
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
+81 -15
View File
@@ -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 13 cannot be applied to them until they are migrated. Subtract these files from the Step 13 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 13 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 13 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
```
+1 -1
View File
@@ -383,7 +383,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
| UI components (simple) | commons/commonMain | Cards, buttons, dialogs |
| UI components (simple) | commonsUI/commonMain | Cards, buttons, dialogs (Compose UI never goes in `commons`) |
| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** |
| Navigation | Platform-specific only | Activity vs Window too different |
| Permissions | Platform-specific only | APIs incompatible |
+3 -3
View File
@@ -7,7 +7,7 @@ description: Integration guide for using the Quartz Nostr KMP library in externa
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.13.1` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.15.2` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.13.1"
quartz = "1.15.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
}
```
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.13.1
com.vitorpamplona.quartz:quartz:1.15.2
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.13.1"
quartz = "1.15.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.13.1")
implementation("com.vitorpamplona.quartz:quartz:1.15.2")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+1 -1
View File
@@ -17,7 +17,7 @@ The layer between `LocalCache`/`Account` and the raw relay connection. Ensures c
## Layout
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`:
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` (the `@Composable` entry points — `observeUser*`, `*FilterAssemblerSubscription`, `KeyDataSourceSubscription` — sit in the same package but in `commonsUI/src/commonMain/…`, the Compose half of the shared layer):
```
relayClient/
@@ -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
+4
View File
@@ -2,3 +2,7 @@
# binary so git never applies CRLF/text normalization or textual diff/merge, which
# would corrupt the compressed stream (important on Windows checkouts).
*.gz binary
# Golden test resources are compared byte-for-byte against generated strings:
# force LF everywhere so a Windows CRLF checkout cannot break the comparison.
*.golden text eol=lf
+1 -1
View File
@@ -40,7 +40,7 @@ locally and tick the box. If your change can't possibly affect them
(docs-only, UI-only on unrelated screens, etc.), tick "N/A". -->
- [ ] N/A — change can't affect wire bytes / decoded audio / MLS state / DM envelopes
- [ ] Marmot / MLS — `cli/tests/marmot/marmot-interop-headless.sh` (NIP-EE / `whitenoise-rs`)
- [ ] Marmot / MLS — `cli/tests/marmot/marmot-interop-headless.sh` (Marmot / MDK `wn`)
- [ ] NIP-17 DM — `cli/tests/dm/dm-interop-headless.sh`
- [ ] Audio rooms manual — `cli/tests/nests/nests-interop.sh` (Amethyst ↔ nostrnests.com)
- [ ] MoQ-lite hang-tier — `:nestsClient:jvmTest -DnestsHangInterop=true`
+134 -10
View File
@@ -21,8 +21,14 @@ 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: Compose catalog escaping (Android escapes render literally there)
run: .claude/hooks/compose_escaping_check.py
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -39,7 +45,16 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Linter (gradle)
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
# The three metadata compiles resolve commonMain against only the
# deps every target shares, which is the Apple classpath — a
# dependency that reaches JVM transitively (okio via OkHttp) but is
# missing for iOS fails here, on Linux, instead of in test-quartz-ios.
run: |
./gradlew spotlessCheck \
:quartz:verifyKmpPurity :commons:verifyKmpPurity :commonsUI:verifyKmpPurity \
:quartz:compileCommonMainKotlinMetadata \
:commons:compileCommonMainKotlinMetadata \
:commonsUI:compileCommonMainKotlinMetadata
build-desktop:
needs: lint
@@ -69,7 +84,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -87,13 +102,47 @@ jobs:
- name: Test + Build Desktop (gradle)
run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD
else
$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
commonsUI/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 +175,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -136,8 +185,20 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
# -DsyncN shrinks MirrorSyncThroughputTest's corpus from its 1,000,000-event
# default. At 1M the sink cannot keep up with the in-process source, and the
# MirrorWorker's deliberately unbounded intake channel buffers the backlog until
# the runner's heap is gone: throughput collapses (13,800 -> 86 ev/s) and an
# OutOfMemoryError lands on a coroutine thread, where the UncaughtExceptionHandler
# swallows it. JUnit never sees a failure, so the JVM wedges and the job burns to
# the timeout with no signal rather than failing. 100k keeps a real ev/s number
# while bounding the worst-case backlog to a tenth of what died.
#
# Only CI is shrunk: -DsyncN is unset everywhere else, so a local or manual run
# still measures the full 1M — the number written up in
# relayBench/plans/2026-07-04-sync-throughput-1m.md.
- name: Test geode (gradle)
run: ./gradlew :geode:test
run: ./gradlew :geode:test -DsyncN=100000
- name: Upload geode Test Reports
uses: actions/upload-artifact@v7
@@ -146,6 +207,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@v6
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
@@ -161,7 +280,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -198,11 +317,15 @@ jobs:
# :commons:jvmTest stays green — this is the job that catches it.
# - compileTestKotlinIosArm64 catches device-only compile drift
# (iosArm64 = aarch64-apple-ios) without needing a physical device.
# :commonsUI (the Compose half split out of :commons) gets the same
# treatment so the shared composables keep compiling on Apple targets.
- name: Test Commons on iOS
run: |
./gradlew \
:commons:iosSimulatorArm64Test \
:commons:compileTestKotlinIosArm64
:commons:compileTestKotlinIosArm64 \
:commonsUI:iosSimulatorArm64Test \
:commonsUI:compileTestKotlinIosArm64
- name: Upload iOS Test Reports
uses: actions/upload-artifact@v7
@@ -220,7 +343,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -254,6 +377,7 @@ jobs:
:amethyst:lintPlayBenchmark \
:quartz:jvmTest \
:commons:jvmTest \
:commonsUI:jvmTest \
:nestsClient:jvmTest \
:amethyst:testFdroidDebugUnitTest \
:amethyst:testPlayDebugUnitTest \
@@ -277,7 +401,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 -1
View File
@@ -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
+1 -1
View File
@@ -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`
#
+17 -17
View File
@@ -78,7 +78,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.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.6.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
@@ -568,22 +568,22 @@ jobs:
( cd "$SRC" && tar czf "$OLDPWD/dist/amy-${VER}-jvm.tar.gz" bin lib )
echo "Collected: dist/amy-${VER}-jvm.tar.gz"
- name: Enforce CLI size budget (200 MB per asset)
- name: Enforce CLI size budget (120 MB per asset)
run: |
set -euo pipefail
# The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget
# targets < 80 MB, but :commons currently leaks Compose + Skiko as
# transitive deps (~40 MB of unused UI jars). Budget is set to
# 200 MB until commons is split into core + ui modules — track that
# as a follow-up. Until then, this gate just catches pathological
# regressions (e.g. accidental :amethyst dep pulling Android libs).
# Measured after the :commons / :commonsUI split (1.15.2, Linux x64):
# amy-*-jvm.tar.gz 55 MB, amy-*.tar.gz (jlink image) 80 MB, lib/ 60 MB
# on disk. The budget sits 50% above the largest asset so a Compose /
# Skiko / Android leak (+25-40 MB compressed) trips it, while the
# per-OS JRE variance of the jlink image does not. The "Assert no
# Compose UI" step above is the precise check; this is the coarse one.
fail=0
for f in dist/*; do
if [[ -f "$f" ]]; then
size=$(wc -c < "$f")
mb=$(( size / 1048576 ))
if (( size > 209715200 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 200 MB amy budget"
if (( size > 125829120 )); then
echo "::error file=$f::asset is ${mb} MB — exceeds 120 MB amy budget"
fail=1
else
echo "OK: $f — ${mb} MB"
@@ -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.6.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.6.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 }}
+38 -1
View File
@@ -31,6 +31,14 @@ jobs:
with:
upload_sources: true
upload_translations: true
# Upload translations that are identical to the English source (brand
# terms, loanwords like "Feed"/"Apps", bare formats like "v%1$s").
# Without this they are SKIPPED on upload, so a locale that deliberately
# keeps English never reaches Crowdin's DB and the key keeps coming back
# as untranslated. They arrive as normal UNAPPROVED translations --
# auto_approve_imported stays at its default false, so a translator still
# approves them in the Crowdin UI (bulk-select in the Editor).
import_eq_suggestions: true
download_translations: true
# Let the downloaded translations stay in the working tree; the single
# create-pull-request step below opens the combined PR.
@@ -50,6 +58,35 @@ jobs:
- name: Fix ownership after Crowdin Docker action
run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
# Both files in crowdin.yml are declared `type: android`, so Crowdin's Android
# serializer escapes apostrophes on the way down: `l'URL` comes back as `l\'URL`.
# That is correct for amethyst/src/main/res/, which aapt un-escapes at build time,
# and WRONG for commonsUI/.../composeResources/, where Compose resolves only \uXXXX,
# \n and \t and leaves \' \" \? \@ alone -- so the backslash reaches the screen.
#
# Without this step every sync reopens the same regression and CI's
# compose_escaping_check.py fails on the bot's own PR. It happened three times
# (f9baab0e, 1685d7c0, e223d505 -- 2,888 occurrences across 40 locales the last
# time) before this step existed. Convert on the way in, so the PR is born clean.
#
# Only the Compose catalog is passed in; the Android res tree keeps its escaping.
# --no-unwrap-quotes is mandatory here: escape conversion is idempotent but
# quote-unwrapping is not, and a second unwrap would strip the real display quotes
# from values like import_follows_tips.
- name: Convert Android escaping to Compose escaping in the shared catalog
run: |
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \
commonsUI/src/commonMain/composeResources
# Assert the conversion actually satisfied the check that guards main, so a case
# the converter cannot repair fails the sync loudly here instead of opening a red
# PR. Known gap if this ever trips: fix_escapes.py only rewrites text inside
# <string>/<item> elements, while the check scans the whole file -- an escape in an
# XML comment (comments do propagate into the locale files) has to be fixed at the
# source string in commonsUI/.../composeResources/values/strings.xml by hand.
- name: Verify the shared catalog is free of Android-only escaping
run: .claude/hooks/compose_escaping_check.py
# Keep docs/changelog/translators.json seeded with everyone who has translated
# recently, so the per-release `## Translations` credits (scripts/translators.sh)
# can resolve them to npubs. Only adds rows when a genuinely new contributor
@@ -70,7 +107,7 @@ jobs:
branch: l10n_crowdin_translations
add-paths: |
amethyst/src/main/res/**/strings.xml
commons/src/commonMain/composeResources/**/strings.xml
commonsUI/src/commonMain/composeResources/**/strings.xml
docs/changelog/translators.json
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
title: 'New Crowdin Translations'
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5.6.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.6.0
uses: actions/setup-java@v6.0.0
with:
distribution: 'temurin'
java-version: 21
+1 -1
View File
@@ -8,6 +8,6 @@
</component>
<component name="KotlinJpsPluginSettings">
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.4.10" />
<option name="version" value="2.4.20" />
</component>
</project>
+46 -22
View File
@@ -96,7 +96,7 @@ and each has its own guide:
| Artifact | Committed at | Regenerate when | Guide |
|---|---|---|---|
| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
> **Material Symbols is mandatory after icon changes.** The bundled font is a
@@ -105,8 +105,9 @@ and each has its own guide:
> change. Reusing an existing codepoint needs no regeneration.
Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a
Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they
are **not** required to build Amethyst from the committed sources.
Rust toolchain + the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs —
they are **not** required to build Amethyst from the committed sources.
---
@@ -466,8 +467,8 @@ Homebrew removes the quarantine attribute on its own downloads.
> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and
> now prints the per-file notary log on a non-`Accepted` verdict. If it comes
> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image the
> `:commons` core/ui split the size budget already flags). The **desktop** app
> zipping (the unused `skiko`/Compose jars left the CLI image with the
> `:commons` / `:commonsUI` split). The **desktop** app
> bundles the same jars through Compose/jpackage notarization, so run a desktop
> dry-run too; its in-jar handling differs and is likewise unverified.
@@ -533,14 +534,17 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
> **Status as of v1.13.1: neither Homebrew nor Winget has been bootstrapped.**
> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so
> **Amethyst does not currently ship through either channel.** The bump
> workflows detect this and skip with a `::warning::` instead of failing, so a
> green release run does *not* mean Homebrew/Winget shipped. The two subsections
> below are the work that activates them; until then treat the desktop app as
> GitHub-Releases-only on macOS and Windows.
> **Status as of v1.15.2:** both Homebrew packages are now live upstream — the
> `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy`
> formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so
> `bump-homebrew.yml` finally has something to bump. **Winget is still not
> bootstrapped**: [microsoft/winget-pkgs#422752](https://github.com/microsoft/winget-pkgs/pull/422752)
> is open pending CLA + review, and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` still 404s. Neither
> is the `geode-relay` formula, which has never been submitted. Those two bump
> workflows detect the absence and skip with a `::warning::` instead of failing,
> so a green release run does *not* mean they shipped; treat the desktop app as
> GitHub-Releases-only on **Windows**.
### Package-manager credentials (and why there are none)
@@ -584,7 +588,7 @@ The token then lives only in that maintainer's shell:
```bash
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-homebrew-cask.sh v1.15.2
```
Create one at
@@ -600,7 +604,7 @@ Same split, and it needs **no token at all**. `scripts/bump-winget.sh` drives
runs fine from macOS or Linux:
```bash
scripts/bump-winget.sh v1.13.2
scripts/bump-winget.sh v1.15.2
```
CI (`bump-winget.yml`, `GITHUB_TOKEN` only) does the bookkeeping: downloads the
@@ -618,15 +622,33 @@ that is needed.
### Homebrew cask (one-time initial PR)
> `brew bump-cask-pr` **cannot** do this step. It *updates* an existing cask —
> against a name that isn't in the tap yet it fails outright:
> `Error: Cask 'amethyst-nostr' is unavailable: No Cask with this name exists.`
> The first submission is a **new-cask** PR, which is a different flow:
```bash
brew bump-cask-pr amethyst-nostr \
--version 1.12.1 \
--url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amethyst-desktop-1.12.1-macos-arm64.dmg"
# 1. Scaffold from the published DMG (macOS arm64 — there is no Intel DMG)
brew create --cask \
https://github.com/vitorpamplona/amethyst/releases/download/v1.14.0/amethyst-desktop-1.14.0-macos-arm64.dmg \
--set-name amethyst-nostr
# 2. Fill in the cask body, then audit as a NEW cask (stricter than a bump)
brew audit --new --cask amethyst-nostr
brew install --cask amethyst-nostr # verify it actually installs
brew uninstall --cask amethyst-nostr
# 3. Open the PR against Homebrew/homebrew-cask by hand
```
The DMG **must be notarized and stapled** or Homebrew will reject it; verify
with `spctl -a -t open --context context:primary-signature -v <dmg>` before
submitting.
The cask filename is `amethyst-nostr` (not `amethyst` — that's taken by a
tiling window manager). After the first PR is merged, `bump-homebrew.yml`
auto-submits new version bumps on each stable release.
auto-submits new version bumps on each stable release — *that* is where
`brew bump-cask-pr` applies.
> **The desktop app is already on mainline Homebrew.** `homebrew/cask` *is* the
> mainline cask repo — GUI apps live in homebrew-**cask**, CLIs in
@@ -665,9 +687,11 @@ Caveats that the maintainer must weigh before submitting:
- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading
a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to
justify it (sandboxed Gradle can't fetch Maven deps).
- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks
Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons`
core/ui split) would shrink it and smooth review — tracked as a follow-up.
- **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked
Compose/Skiko jars onto the CLI classpath. Compose UI now lives in
`:commonsUI`, which `:cli` does not depend on: the JVM tarball is ~55 MB
and the jlink image tarball ~80 MB (1.15.2, Linux x64). The release
workflow caps every amy asset at 120 MB.
After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot
auto-open version-bump PRs on each stable release — no token or workflow on our
+14 -3
View File
@@ -175,9 +175,13 @@ device. PRs that introduce any of them will be sent back.
### KMP source-set discipline
- **Android-only imports don't belong in `commons/commonMain` or
`quartz/commonMain`.** Use `expect`/`actual` for platform-specific
bits, or move the Android-specific code to `androidMain`.
- **Android-only imports don't belong in `commons/commonMain`,
`commonsUI/commonMain` or `quartz/commonMain`.** Use `expect`/`actual`
for platform-specific bits, or move the Android-specific code to
`androidMain`.
- **Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` don't belong
in `commons` at all** — that module is on the CLI classpath. Put the file
in `commonsUI` (same package) instead.
### Logging
@@ -186,6 +190,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
+13 -8
View File
@@ -3,7 +3,7 @@
Thanks for your interest in improving Amethyst. This document captures the
expectations, conventions, and review rules for code, documentation, and
translation contributions across all modules in this repository (`amethyst/`,
`desktopApp/`, `quartz/`, `commons/`, `cli/`, `quic/`, `nestsClient/`).
`desktopApp/`, `quartz/`, `commons/`, `commonsUI/`, `cli/`, `quic/`, `nestsClient/`).
By contributing, you agree to license your work under the MIT license. Any
work contributed where you are not the original author must contain its
@@ -157,7 +157,8 @@ Common Gradle entry points:
Modules:
- `quartz/` — Nostr KMP library (protocol, crypto, models). **No UI.**
- `commons/` — Shared Compose Multiplatform UI, icons, ViewModels, flows.
- `commons/` — Shared headless layer: models, ViewModels, flows, relay client. **No Compose UI** (the CLI depends on it).
- `commonsUI/` — Shared Compose Multiplatform UI, icons, theme, Compose resources, on top of `commons`.
- `quic/` — Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport.
- `nestsClient/` — Audio-rooms client (NIP-53) built on `:quic` and
`:quartz`.
@@ -175,7 +176,8 @@ of PR churn. Place new code by purpose:
| What you're adding | Goes in |
|---|---|
| Nostr event types, NIPs, tags, signing, crypto, Bech32 | `quartz/commonMain/` |
| Shared Composables, icons, ViewModels, StateFlows | `commons/commonMain/viewmodels/` or `commons/commonMain/` |
| Shared ViewModels, StateFlows, relay subscriptions | `commons/commonMain/viewmodels/` or `commons/commonMain/` |
| Shared Composables, icons, theme | `commonsUI/commonMain/` (same packages as `commons`) |
| Android-only screen, navigation, system integration | `amethyst/` |
| Desktop-only window, sidebar, menu bar, shortcut | `desktopApp/` |
| `amy <verb>` subcommand (thin assembly only) | `cli/src/main/kotlin/.../cli/` |
@@ -188,8 +190,9 @@ Hard rules:
- `cli/` has **no Nostr protocol or business logic** — it's a thin assembly
layer over `quartz` + `commons`. If your CLI command needs new behavior,
extract it into `commons/` first.
- ViewModels belong in `commons/commonMain/`. Only screens (the Composable
that wires layout + navigation) stay in the platform module.
- ViewModels belong in `commons/commonMain/`; shared composables in
`commonsUI/commonMain/`. Only screens (the Composable that wires layout +
navigation) stay in the platform module.
- For platform-specific behavior in a shared file, use `expect`/`actual`.
## Workflow
@@ -265,9 +268,11 @@ front:
sequentially: `for peer in aioquic picoquic quic-go quinn; do
quic/interop/run-matrix.sh -s $peer; done`. Plan at
`quic/interop/plans/2026-05-06-interop-runner.md`.
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless
variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive
Marmot variant prompts a human to drive the Android UI.
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every
relay-backed suite boots the embedded `amy serve` relay (geode) — no
external relay binary; only the Marmot suites additionally need `cargo`
for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to
drive the Android UI.
If a change is documentation-only, UI-only, build-script-only, or otherwise
cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip
+32 -1
View File
@@ -3,7 +3,7 @@
**App:** Amethyst (Android Nostr client)<br>
**Publisher:** Vitor Pamplona<br>
**Contact:** amethyst@vitorpamplona.com<br>
**Last updated:** 2026-05-24
**Last updated:** 2026-09-12
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
@@ -20,6 +20,7 @@ Using the app causes the following data to leave your phone:
- **Nostr events** you publish, sent to the relays you have configured.
- **Subscriptions** (filters describing what you want to read), sent to those relays.
- **Media uploads** (images, audio, video), sent to the media server you select.
- **Workout summaries**, when you choose to publish one — see [Health and fitness data](#health-and-fitness-data-health-connect) below.
- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app.
- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy).
@@ -29,6 +30,36 @@ The developer does not run any server that aggregates or stores this data.
Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling.
### Health and fitness data (Health Connect)
Amethyst's **Workouts** section lets you publish a summary of a finished workout to the Nostr relays you choose (a NIP-101e kind 1301 event), so the people who follow you can see it. To save you typing the numbers in by hand, Amethyst can read the workout your watch or fitness app already saved to **Android Health Connect** and pre-fill the post.
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open the New Workout composer — never on first launch.
**What Amethyst reads, and what each type is for:**
| Health Connect data type | Permission | What it is used for |
| --- | --- | --- |
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start time and duration — the title, date and duration of the post. |
| Distance | `READ_DISTANCE` | The distance of the run, ride, walk or swim. |
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | The energy the workout burned. |
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback energy figure for sources that only record total energy. |
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate over the workout — how hard the effort was. |
| Steps | `READ_STEPS` | The step count of a run, walk or hike. |
| ElevationGained | `READ_ELEVATION_GAINED` | How much you climbed. |
Health Connect groups a few data types under one permission: `READ_EXERCISE` also covers CyclingPedalingCadence and `READ_STEPS` also covers StepsCadence. Amethyst does not read, store, or publish cadence — those types come attached to the permissions above and are never requested separately.
**Limits on this access:**
- **Read-only.** Amethyst never writes to Health Connect.
- **Foreground only.** Reads happen only while the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
- **Last 7 days only.** Only sessions that finished in the previous 7 days are offered. Amethyst does not request `READ_HEALTH_DATA_HISTORY`.
- **No location.** Amethyst does not request `READ_EXERCISE_ROUTE`, so it never receives the GPS track of a workout.
- **Nothing is uploaded automatically.** Health data stays on your device until you pick a suggestion, review the pre-filled post, and publish it yourself. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
- **No other use.** Health data is never used for advertising, analytics, profiling, or sale, and is never shared with third parties. It is not used to determine your eligibility for insurance, credit, or employment, and is not transferred to any such party.
- **Revocable.** Turn the feature off under Settings → Compose → "Suggest workouts to share", or revoke the permissions in Health Connect at any time. Amethyst keeps the workout suggestions it has already shown only in memory; revoking access stops all reads immediately.
### What relays can see
A relay you connect to sees:
+5 -5
View File
@@ -328,16 +328,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.13.1')
implementation('com.vitorpamplona.quartz:quartz:1.15.2')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.13.1')
implementation('com.vitorpamplona.quartz:quartz-android:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.15.2')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.15.2')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+38 -17
View File
@@ -101,15 +101,20 @@ git -c credential.helper= -c credential.helper='!gh auth git-credential' push up
```
When the `Create Release Assets` workflow finishes (~2530 min) the GH Release
holds **31 assets**, per the asset-name contract:
holds **47 assets**, per the asset-name contract:
- **Android (13):** 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the F-Droid
`.apks` set for Accrescent
(`amethyst-googleplay-*-v…apk` / `.aab`, `amethyst-fdroid-*-v…apk` / `.aab` / `.apks`)
- **Desktop (8):** DMG (macOS **arm64 only** — there is no Intel DMG),
MSI + zip, DEB, RPM, AppImage, flatpak, tar.gz
- **CLI (5):** the `amy` artifacts
- **Relay (5):** the `geode` artifacts, plus the geode Docker image
- **Desktop (14):** macOS DMG (**arm64 only** — there is no Intel DMG), Windows
MSI (x64 only — **no arm64 MSI**) + portable zip (x64, arm64), and Linux
DEB/RPM/AppImage/flatpak/tar.gz in both x64 and arm64. BUILDING.md § Release
runbook has the per-leg breakdown and why the two gaps exist.
- **CLI (10):** the `amy` artifacts — the no-JRE `jvm.tar.gz`, macOS arm64,
Windows x64 + arm64, and Linux DEB/RPM/tar.gz in both x64 and arm64
- **Relay (10):** the `geode` artifacts, same matrix as `amy`. The geode Docker
image is **not** a release asset — it goes to the registry, so don't count it
here.
- **Maven Central:** `com.vitorpamplona.quartz:quartz:<version>` published.
`repo1.maven.org` lags the publish by tens of minutes — a 404 right after the
run is normal. Confirm the step's log says "Deployment is being published to
@@ -121,8 +126,9 @@ holds **31 assets**, per the asset-name contract:
## 3. Per-channel shipping
### GitHub Releases — automatic
Nothing to do beyond pushing the tag. Verify the asset count and that Intel +
ARM DMGs are both present (BUILDING.md § Verify).
Nothing to do beyond pushing the tag. Verify the asset count (BUILDING.md
§ Verify). macOS is **arm64-only** — there is no Intel DMG, so a single
`amethyst-desktop-<version>-macos-arm64.dmg` is the expected, correct result.
### Google Play — manual upload
1. Download `amethyst-googleplay-<version>.aab` from the GH Release.
@@ -177,21 +183,36 @@ when unset. To fan the release event out to more relays for discoverability,
set `RELAY_URLS` for the run:
```bash
RELAY_URLS="wss://relay.zapstore.dev,wss://relay.damus.io,wss://nos.lol,wss://vitor.nostr1.com" \
RELAY_URLS="wss://relay.zapstore.dev,wss://nos.lol,wss://nostr.mom,wss://vitor.nostr1.com" \
SIGN_WITH=<amethyst-nsec> zsp publish
```
Keep `wss://relay.zapstore.dev` in the list — that is the relay the Zapstore app
itself reads from.
### Homebrew + Winget — ⚠️ not shipping yet
### Homebrew + Winget — ⚠️ Winget not shipping yet
`bump-homebrew.yml` and `bump-winget.yml` are wired to open PRs against
`Homebrew/homebrew-cask` (cask `amethyst-nostr`) and `microsoft/winget-pkgs`
(`VitorPamplona.Amethyst`) — but **neither package has ever been submitted
upstream**, so both workflows detect that and skip with a `::warning::`. As of
**v1.13.1** these two channels deliver nothing; macOS and Windows users get the
desktop app from GitHub Releases only.
`Homebrew/homebrew-cask` (cask `amethyst-nostr`), `Homebrew/homebrew-core`
(formula `amy`) and `microsoft/winget-pkgs` (`VitorPamplona.Amethyst`). They can
only *update* a package that already exists upstream, so until the one-time
bootstrap lands they detect the absence and skip with a `::warning::`.
Bootstrap status:
| Channel | Upstream package | State |
|---|---|---|
| **Homebrew cask** | `Homebrew/homebrew-cask` → `amethyst-nostr` | **Live** — merged 2026-08-24, upstream at 1.14.0 |
| **Homebrew formula** | `Homebrew/homebrew-core` → `amy` | **Live** |
| **Homebrew formula** | `Homebrew/homebrew-core` → `geode-relay` | Not submitted — renamed from `geode`, which is permanently reserved for Apache Geode |
| **Winget** | `microsoft/winget-pkgs` → `VitorPamplona.Amethyst` | **Submitted at v1.14.0** — [PR #422752](https://github.com/microsoft/winget-pkgs/pull/422752), still open pending CLA + review |
Until each lands, that channel delivers nothing and its users get the desktop
app or CLI from GitHub Releases only. Re-check before assuming — the state above
is a snapshot, and two calls answer it:
`gh api repos/microsoft/winget-pkgs/contents/manifests/v/VitorPamplona` and
`curl -s -o /dev/null -w '%{http_code}' https://formulae.brew.sh/api/cask/amethyst-nostr.json`
(404 = still absent).
Two separate faults kept this invisible until v1.13.1, both now fixed:
@@ -219,9 +240,9 @@ readable by anyone with push access here), so a maintainer runs the last step:
```bash
# after merging the sync PRs
export HOMEBREW_GITHUB_API_TOKEN=ghp_... # classic PAT, `repo` scope
scripts/bump-homebrew-cask.sh v1.13.2
scripts/bump-homebrew-cask.sh v1.15.2
scripts/bump-winget.sh v1.13.2 # no token — uses your `gh` auth
scripts/bump-winget.sh v1.15.2 # no token — uses your `gh` auth
```
Both scripts re-verify the published artifact's sha256 before submitting, and
@@ -283,7 +304,7 @@ Owner assignments and rotation reminders live with the team (issue tracker).
## 6. Post-release verification
- [ ] GH Release: 31 assets, sizes sane, and the asset-name set matches the
- [ ] GH Release: 47 assets, sizes sane, and the asset-name set matches the
previous release (see the `diff` one-liner in BUILDING.md § Release
runbook). macOS is arm64-only — do **not** look for an Intel DMG.
- [ ] Maven Central: `quartz:<version>` resolves (allow tens of minutes of
+116 -5
View File
@@ -1,3 +1,7 @@
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
@@ -9,6 +13,7 @@ plugins {
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
alias(libs.plugins.androidxBaselineProfile)
}
fun getCurrentBranch(workingDir: java.io.File): String =
@@ -84,7 +89,7 @@ android {
.get()
.toInt()
versionName = generateVersionName(libs.versions.app.get(), rootDir)
buildConfigField("String", "RELEASE_NOTES_ID", "\"f54843af6397f78e39fa75dbe3b7f7de14eb18c4f9c56e60e7825a2c6715719b\"")
buildConfigField("String", "RELEASE_NOTES_ID", "\"8fce45589ea44df75e828a04c7d70bb4fabedd6ffc1946a920b2f0c7c990ff9f\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -330,7 +335,7 @@ ksp {
// TODO: until google merges and unifiedpush updates https://github.com/tink-crypto/tink-java-apps/pull/5
configurations.all {
val tink = "com.google.crypto.tink:tink-android:1.17.0"
val tink = "com.google.crypto.tink:tink-android:1.23.0"
resolutionStrategy {
force(tink)
dependencySubstitution {
@@ -374,6 +379,17 @@ composeCompiler {
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
baselineProfile {
// One profile for the whole app rather than per-flavour: the ingest path being
// captured is identical in play and fdroid, and a shared profile is what the
// fdroid build needs — it never receives Play Cloud Profiles.
mergeIntoMain = true
// Keep the generated profile in source control so release builds do not depend on
// a device being attached at build time.
saveInSrc = true
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
@@ -383,19 +399,33 @@ dependencies {
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
debugImplementation("androidx.compose.runtime:runtime-tracing")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.0")
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
debugImplementation(libs.androidx.compose.runtime.tracing)
debugImplementation(libs.androidx.tracing.perfetto)
debugImplementation(libs.androidx.tracing.perfetto.binary)
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":commonsUI"))
implementation(project(":nestsClient"))
// Agent text stream previews: the raw-QUIC binding plus the QUIC
// stack under it (for the certificate validator it requires).
implementation(project(":marmotQuic"))
implementation(project(":quic"))
implementation(project(":nappletHost"))
// Compose Multiplatform resources runtime, so app-side screens that share a
// string with a commons renderer can read commons' generated `Res` directly
// instead of duplicating the key in the Android res tree.
implementation(libs.jetbrains.compose.components.resources)
implementation(libs.androidx.core.ktx)
// Installs assets/dexopt/baseline.prof on first run. Play applies the profile at
// install time via the .dm, but F-Droid builds have no store-side profile delivery
// and no Cloud Profiles at all — there, this library is the only thing that gets
// the shipped baseline profile into ART.
implementation(libs.androidx.profileinstaller)
// Profile produced by :baselineprofile from a real cold-start + ingest journey.
baselineProfile(project(":baselineprofile"))
implementation(libs.androidx.activity.compose)
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
@@ -569,6 +599,15 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
// In-process Nostr relay (geode) so unit tests that drive a real
// NostrClient talk to an embedded relay instead of a public one. Same
// wiring quartz uses for its jvmAndroidTest source set: the engine, its
// testFixtures (RelayClientTest base, preload/publish helpers) and the
// JVM SQLite driver the in-memory EventStore needs on a host JVM.
testImplementation(project(":geode"))
testImplementation(testFixtures(project(":geode")))
testImplementation(libs.androidx.sqlite.bundled.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx)
@@ -586,3 +625,75 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
}
// AGP 9.4.0's PerModuleBundleTask refuses to write an AAB entry whose name contains a colon:
//
// Entry name contains invalid characters: root/META-INF/zoomable-root:zoomable.kotlin_module
//
// A .kotlin_module is named after the Gradle project path that produced it, colons included, and
// 14 of the 98 merged into this app carry one -- zoomable, Negentropy, vico, seven coil3 artifacts
// and four of ours. The entries are identical under 9.3.1, which writes them without complaint, so
// 9.4.0 added the rejection rather than the names.
//
// packaging.resources.excludes cannot remove them: with minification on, R8 emits the java
// resources and PerModuleBundleTask.addHybridFolder hands JarFlinger its own predicate, so those
// filters are never consulted. Nothing in the AAB reads a .kotlin_module either -- it exists for
// the Kotlin compiler to resolve top-level declarations across modules at COMPILE time.
//
// So drop them from R8's java-res jar in the moment before the bundle task opens it, then put the
// jar back exactly as R8 left it. The strip is doFirst on the CONSUMER rather than doLast on R8, so
// a build-cache hit on R8 cannot skip it; the restore is what keeps R8 up to date next build --
// without it Gradle sees a modified output and re-runs R8 every time, which measured ~2 min a build
// here for no work.
fun stripColonNamedEntries(jar: File): Int {
val offenders = ZipFile(jar).use { zip -> zip.entries().toList().count { ':' in it.name } }
if (offenders == 0) return 0
val rewritten = File(jar.parentFile, "${jar.name}.stripped")
ZipFile(jar).use { zip ->
ZipOutputStream(rewritten.outputStream().buffered()).use { out ->
zip.entries().asSequence().filterNot { ':' in it.name }.forEach { entry ->
out.putNextEntry(ZipEntry(entry.name))
zip.getInputStream(entry).use { it.copyTo(out) }
out.closeEntry()
}
}
}
rewritten.copyTo(jar, overwrite = true)
rewritten.delete()
return offenders
}
androidComponents.onVariants { variant ->
val variantName = variant.name
val capitalized = variantName.replaceFirstChar { it.uppercase() }
tasks.matching { it.name == "build${capitalized}PreBundle" }.configureEach {
val javaResDir = layout.buildDirectory.dir("intermediates/merged_java_res/$variantName")
val backups = mutableMapOf<File, File>()
doFirst {
javaResDir.get().asFile
.walkTopDown()
.filter { it.isFile && it.extension == "jar" }
.forEach { jar ->
val backup = File(jar.parentFile, "${jar.name}.orig")
jar.copyTo(backup, overwrite = true)
val dropped = stripColonNamedEntries(jar)
if (dropped > 0) {
backups[jar] = backup
logger.lifecycle("Stripped $dropped colon-named entries from ${jar.name}")
} else {
backup.delete()
}
}
}
doLast {
backups.forEach { (jar, backup) ->
backup.copyTo(jar, overwrite = true)
backup.delete()
}
backups.clear()
}
}
}
@@ -0,0 +1,381 @@
# Adding a whole people-list to a post's Notify / "Visible to" audience
**Status: shipped** (P1 + P2, plus the visual direction below). Landed as
`AudienceSelection.kt` / `AudienceFlap.kt` / `AudienceSheet.kt` in
`amethyst/…/ui/note/creators/notify/`, with bulk mutators on
`ShortNotePostViewModel` and 13 JVM tests in `AudienceSelectionTest`.
Still open, in rough priority order:
- **P3 — "Last private note" entry** in the sheet, reusing the previous send's
audience. The most-requested shape ("same people as last time") and the
cheapest remaining win.
- **P3 — the other composers.** `Notifying()` is untouched, so the comment
composer (`GenericCommentPostScreen`) and the group DM composer's To row
(`SendDirectMessageTo`) still use the old flat row. They can adopt
`AudienceFlap` unchanged.
- **Provenance is compose-session-only** — it resets on draft load, so a
bulk-added group chip does not survive a draft round trip (open question 2
below, answered "session-only" for now). The audience itself round-trips
fine; only the chip's undo affordance is lost.
- **Kind-3 follows are not offered** as a catalog entry. The sheet's search
finds individuals, but "everyone I follow" is deliberately absent given the
caps.
## Goal
In the short-note composer (`ShortNotePostScreen`), the **Notify** row already lets
you p-tag individual users one at a time. When the lock chip
(`AddPrivateNoteButton`) is on, that same row is relabeled **"Visible to"** and
*becomes the audience* of the gift-wrapped note.
Today the only way to fill it is `Add` → search → pick, one user per round trip.
This proposes an interface to add **every member of one of the user's people
lists / follow packs** in a single gesture, with a review step, and without
turning a 40-person list into an unusable wall of chips or a 40× signer prompt
storm.
## What exists today (reuse — do NOT rebuild)
| Need | Reuse |
|---|---|
| The chip row + "Add" chip | `ui/note/creators/notify/Notifying.kt` (`Notifying`, `NotifyUserChip`, `AddUserChip`) |
| Audience state | `ShortNotePostViewModel.pTags: List<User>?`, `mutedNotifies: Set<HexKey>`, `activeNotifies()`, `addToReplyList(user)` |
| My NIP-51 people lists (kind 30000, public **and** decrypted private members) | `account.peopleLists.uiListFlow: StateFlow<List<PeopleList>>` (`model/nip51Lists/peopleList/PeopleListsState.kt`) |
| My follow packs (kind 39089, public members) | `account.followLists.uiListFlow` (`model/nip51Lists/peopleList/FollowListsState.kt`) |
| `PeopleList` UI model (`title`, `image`, `publicMembers`, `privateMembers` as `Set<User>`) | `model/nip51Lists/peopleList/PeopleList.kt` |
| Two-column list catalog rendering | `ui/screen/loggedIn/lists/memberEdit/FollowListAndPackAndUserView.kt` — same "Follow sets" + "Discover follows" sectioning |
| Multi-select member review (count header, select-all checkbox, per-user row, confirm button) | `ui/screen/loggedIn/newUser/ImportFollowListPickFollowsScreen.kt` (`PreviewList` / `FollowEntryRow`) |
| Bottom-sheet picker shell w/ search field | `ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupParentPicker.kt` |
| Per-user DM deliverability | `User.dmInboxRelayList()` (used by `Account.computeRelayListToBroadcast`) |
| Relay hints on the outgoing `p` tags | already done at build time in `createTemplate()` via `LocalCache.relayHints` — the picker adds nothing |
| Icons | `MaterialSymbols.Groups`, `.GroupAdd`, `.Checklist` already in `MaterialSymbols.kt`**no font subset regeneration needed** |
Genuinely new: the picker sheet, a bulk mutator on the ViewModel, chip-row
overflow, and the safety rails below.
## Constraints that shape the design
1. **A private note costs one seal + one wrap per recipient.**
`Account.sendPrivateNote``NIP17Factory.createSeals(...)` builds an
`AddressedSeal` per recipient, each needing a `nip44Encrypt` **and** a `sign`.
With a local key that's cheap; with a **NIP-46 bunker** it is 2 RPCs per
recipient throttled to `BUNKER_PARALLELISM = 4`, and with a **NIP-55 external
signer** it is 2 IPC round trips per recipient. "Add my 200-follow pack" to a
private note is not a neutral action — it must be capped and confirmed.
2. **The audience is not secret from the audience.** The inner rumor carries a
`p` tag per recipient, so every recipient learns the full recipient list.
Adding the **private** members of a kind-30000 list therefore de-privatizes
them to everyone else on the note. That needs an explicit, visible opt-in —
never a silent bulk add.
3. **Members without a NIP-17 DM inbox relay may not receive the wrap.**
`computeRelayListToBroadcast` falls back to the recipient's linked relays,
which is best-effort. The picker should surface this *before* sending, not as
a silent partial delivery.
4. **`Notifying` is a `FlowRow` of full-width name chips.** 30 chips push the
message field off screen. Bulk add forces a collapsed representation.
5. **Public posts are not exempt.** With the lock **off**, the same row is
"Notify" — bulk-p-tagging 50 people is a notification-spam vector. The same
cap applies, with different copy.
6. **Drafts round-trip the audience.** `pTags`/`mutedNotifies` are already
serialized into and restored from the draft (`ShortNotePostViewModel` load
path); a bulk add must go through the same state so drafts keep working.
## Proposed interface
### 1. Entry point — a second chip in the Notify row
`Notifying(...)` gains an optional `onAddList: (() -> Unit)? = null` slot,
rendered as an `AssistChip` immediately after the existing `Add` chip:
```
Visible to [🔔 alice ✕] [🔔 bob ✕] [ Add] [👥 Add list]
```
Nothing changes when `onAddList` is null, so the composables that reuse
`Notifying` (DM composer, comment composer) are untouched.
`ShortNotePostScreen` passes
`onAddList = { postViewModel.wantsToPickNotifyList = true }`.
### 2. The picker — `NotifyListPickerSheet` (ModalBottomSheet, two steps)
**Step 1 — catalog.** Mirrors `FollowListAndPackAndUserView`'s sectioning, with a
search field like `RelayGroupParentPicker`:
```
┌ Add people from a list ───────────────────────┐
│ 🔍 Search lists │
│ │
│ FOLLOW SETS │
│ 👥 Close friends 12 · 🔒 3 │
│ 👥 Work 8 │
│ 👥 Nostrdevs 41 ⚠ over limit │
│ │
│ FOLLOW PACKS │
│ 👥 Bitcoin builders 27 │
│ │
│ OTHER │
│ 👤 People I follow 312 ⚠ over limit │
│ ⏱ Last private note (5) │
└───────────────────────────────────────────────┘
```
- Counts are `publicMembers.size` (+ a lock badge with `privateMembers.size`).
- Kind-3 follows are listed but always land on the review step with **nothing
pre-selected** — it exists so you can search within your follows, not to bulk
add 300 people.
- "Last private note" (phase 3) reuses the previous send's audience — the most
common real-world request ("same people as last time").
**Step 2 — member review.** Reuses the `PreviewList` pattern verbatim
(`accounts_found` / `num_selected` header, select-all checkbox, `LazyColumn` of
rows, confirm button):
```
┌ Close friends ─────────────── 12 found · 9 selected ┐
│ ☑ Select all │
│ ─────────────────────────────────────────────────── │
│ ☑ 🖼 alice │
│ ☑ 🖼 bob already added │
│ ☐ 🖼 carol 🔒 private member of this list │
│ ☑ 🖼 dave ⚠ no DM inbox relay │
│ ☐ 🖼 erin muted │
│ ─────────────────────────────────────────────────── │
│ ⚠ Everyone on this note sees the full recipient list │
│ [ Add 9 people ] │
└─────────────────────────────────────────────────────┘
```
Row rules:
| Row state | Default | Behavior |
|---|---|---|
| Ordinary public member | selected | — |
| Already in `pTags` | selected, checkbox disabled | shown so the count reads true; adding is a no-op |
| **Private** member of the list | **deselected** | badge + one-line explainer; selecting it is the explicit opt-in required by constraint 2 |
| No DM inbox relay (only shown while the lock is on) | selected | ⚠ badge; a "Deselect N without inbox relays" quick action sits under the header |
| Muted / blocked by me | **deselected** | badge |
The bottom warning line renders **only when the lock is on**, and the confirm
button is disabled at 0 selected.
### 3. Chip-row overflow
Once `pTags.size > CHIP_COLLAPSE_THRESHOLD` (start at 6), `Notifying` renders the
first N chips plus a `[+K more]` `AssistChip` that expands the row in place. Add
a `[Clear all]` chip in the expanded state. This is a change to `Notifying` and
benefits the existing one-by-one flow too.
### 4. Provenance (recommended, small)
Snapshot semantics: selecting a list **expands into individual `pTags`
immediately** — the event tags individual pubkeys, the user must see exactly who
receives it, and per-person removal must keep working. A "live list reference
resolved at send time" is rejected: the audience would silently change between
compose and send.
But keep a display-only provenance map so a bulk add can be undone as a unit:
```kotlin
// pubkey -> the list dTags it arrived from. Display + undo only; never read
// when building the event.
var notifyProvenance by mutableStateOf<Map<HexKey, Set<String>>>(emptyMap())
```
which lets the row show a removable group chip when a whole list is present:
```
Visible to [👥 Close friends (9) ✕] [🔔 zoe ✕] [ Add] [👥 Add list]
```
`✕` removes exactly the pubkeys whose provenance is *only* that list, leaving
individually-added and multi-list people in place. This is the single feature
that makes "add all the users of a list" feel like a list operation rather than
a paste. If it has to be cut for phase 1, the flat chips + overflow still work.
## ViewModel changes (`ShortNotePostViewModel`)
```kotlin
var wantsToPickNotifyList by mutableStateOf(false)
/**
* Bulk sibling of [addToReplyList]. One state write for the whole batch: N
* individual writes would trigger N recompositions of the chip row and N
* draft-version bumps.
*/
fun addAllToReplyList(users: Collection<User>, fromListTag: String? = null) {
if (users.isEmpty()) return
val current = pTags ?: emptyList()
val known = current.mapTo(mutableSetOf()) { it.pubkeyHex }
pTags = current + users.filter { known.add(it.pubkeyHex) }
mutedNotifies = mutedNotifies - users.mapTo(mutableSetOf()) { it.pubkeyHex }
fromListTag?.let { tag ->
notifyProvenance = notifyProvenance.toMutableMap().apply {
users.forEach { merge(it.pubkeyHex, setOf(tag)) { a, b -> a + b } }
}
}
draftTag.newVersion()
}
fun removeFromReplyList(users: Collection<User>) { /* mirror, for the group ✕ */ }
```
`cancel()` / `load(draft)` reset `notifyProvenance` alongside `pTags` and
`mutedNotifies`.
## Safety rails
Two constants, both applied to `activeNotifies().size` *after* the add:
- `NOTIFY_SOFT_CAP = 25` — the confirm button in the sheet turns into a
confirmation: private → *"This note will be encrypted and sent 28 separate
times, once per person. With an external signer this means 28 approvals."*;
public → *"28 people will get a notification for this post."*
- `NOTIFY_HARD_CAP = 100` — selection above this is blocked with an explanatory
line rather than silently truncated.
Both are tunable; the point is that the cost in constraint 1 is disclosed at the
moment of the bulk action rather than discovered as a hung signer dialog.
Additionally: the catalog marks any list whose member count exceeds the hard cap
with `⚠ over limit` and opens it with nothing pre-selected.
## Where the code goes
```
amethyst/…/ui/note/creators/notify/
├── Notifying.kt (edit: onAddList slot, overflow, group chip)
├── NotifyListPickerSheet.kt (new: catalog + review sheet)
└── NotifyListSelection.kt (new: pure state holder — filtering,
counts, badge computation, cap checks)
amethyst/…/ui/screen/loggedIn/home/
├── ShortNotePostScreen.kt (edit: wire onAddList, host the sheet)
└── ShortNotePostViewModel.kt (edit: bulk mutators + provenance + flag)
```
Kept in `amethyst/` for now because `peopleLists` / `followLists` live on the
Android `Account` and have no `commons` equivalent (verified: no references in
`commons/` or `desktopApp/`). `NotifyListSelection` is deliberately a plain,
Compose-free state holder over `List<PeopleList>` + `Set<HexKey>` so it can move
to `commons/…/viewmodels/` unchanged the day the desktop composer wants the same
picker.
## Strings (new)
`notify_add_from_list`, `notify_list_picker_title`, `notify_list_picker_search`,
`notify_list_section_follow_sets`, `notify_list_section_follow_packs`,
`notify_list_section_other`, `notify_list_all_follows`,
`notify_list_member_private_badge`, `notify_list_member_private_explainer`,
`notify_list_member_no_inbox_relay`, `notify_list_member_already_added`,
`notify_list_member_muted`, `notify_list_deselect_no_inbox`,
`notify_list_audience_is_public_to_recipients`, `notify_list_add_n_people`,
`notify_list_over_limit`, `notify_list_soft_cap_private`,
`notify_list_soft_cap_public`, `notify_list_hard_cap`, `notify_chips_more`,
`notify_chips_clear_all`, `notify_group_chip_remove`.
Reused as-is: `accounts_found`, `num_selected`, `select_all`, `feed_is_empty`,
`follow_sets`, `discover_follows`.
## Phasing
- **P1** — `onAddList` chip, catalog + review sheet over people lists and follow
packs, `addAllToReplyList`, chip overflow, both caps. This is the whole ask.
- **P2** — provenance group chip + unit removal; "deselect all without inbox
relay" quick action.
- **P3** — "Last private note" reuse entry; the same picker wired into the group
DM composer's To row (`SendDirectMessageTo`) and the comment composer, since
all three share `Notifying`.
## Test plan
JVM unit tests on `NotifyListSelection` (no Compose needed):
- dedupe against existing `pTags`; already-added members don't inflate the count.
- private members start deselected; selecting them is what puts them in the result.
- muted/blocked start deselected.
- hard cap blocks, soft cap flags, neither truncates silently.
- `addAllToReplyList` is idempotent and un-mutes previously muted members.
- draft round-trip: bulk-added audience survives `sendDraftSync()``load(draft)`.
Manual: 12-person list into a private note with an external signer — confirm the
approval count matches the disclosed number, and that recipients without a DM
inbox relay were flagged before send.
## Visual direction — the row itself needs a redesign
Bolting a second chip onto today's Notify row makes an already weak surface
worse, so the picker should land together with a redesign of the row. The
governing idea: **when the note is sealed, the composer should look like an
envelope, and the audience should be the flap.** Seven moves, each independently
shippable, each using a component the app already has:
1. **A container, not a row.** Today the bold grey label and the chips are
siblings in one `FlowRow` — same weight class, no boundary, so it reads as
loose fragments floating above the message. Wrap them in a tinted rounded
Surface that sits flush on top of the message body. Public mode leaves it
untinted and borderless, so ordinary posts gain nothing they didn't ask for.
2. **Faces at rest, chips only while editing.** A chip is avatar + display name +
bell ≈ 180dp; three people wrap the row and twelve bury the message field.
At rest show a **facepile** — overlapping 24dp avatars plus "Alice, Bruno & 7
others" — identical in height at 3 people or 90. `Poll.kt`'s `UserGallery`
(`take(4)`, `spacedBy((-10).dp)`, "+N" bubble) already does exactly this.
3. **Muted must not look broken.** `Modifier.alpha(0.4f)` is Android's universal
*disabled* signal; using it for a deliberate, reversible state makes a working
feature look like a rendering bug. Use an unfilled chip with a struck bell and
full-contrast text — "switched off", not "greyed out".
4. **One way in, not two competing chips.** `Add` is an `AssistChip` of the same
weight as the people beside it, so the action competes with the data — and the
list feature would add a second one. Collapse both into a single `` on the
flap that opens the one sheet (search + lists + per-person switches).
`Notifying(onManage: () -> Unit)` replaces `onAddUser`/`onAddList`.
5. **The empty state is an invitation, not a paragraph.**
`R.string.private_note_no_receivers` is two lines of grey body copy where a
button belongs. Replace with one accent-coloured tappable line sitting exactly
where the faces will appear: *"Nobody yet — choose who can see this."*
6. **Make the mode change a moment.** Going from "broadcast to the network" to
"encrypted to nine people" currently tints one 22dp icon among eleven
identical siblings. Choreograph it once, ~300ms: flap expands and tints, lock
glyph closes, the strip's lock takes a filled pill, the send button relabels to
**Send privately**, one haptic tick. `AnimatedVisibility` +
`animateColorAsState` + `LocalHapticFeedback` — all already used in the app.
7. **A whole list arriving should feel like an arrival.** Twelve chips appearing
at once feels like a paste; twelve faces landing 40ms apart feels like a guest
list filling up. Pair it with the removable group chip from the provenance
section so the whole add is one tap to undo.
No new icons: `Lock`, `LockOpen`, `Groups`, `PersonAdd`, `NotificationsOff` and
`Check` are all already referenced in `MaterialSymbols.kt`, so the bundled subset
font does not need regenerating.
Ship order: **0103** stand alone and fix the ugliness without any new feature;
**0405** land with the picker; **0607** are the polish pass.
All seven shipped together, with two deviations worth recording:
- **Move 05 kept a fact the short copy would have dropped.** The old paragraph
said "only you will be able to see this note" — true, since `canPost()` does
not gate a private note on having recipients, so a sealed note with an empty
audience really does go only to its author. The invitation therefore reads
*"Only you — choose who else can see this"* rather than *"Nobody yet"*, and
the same line now covers the everyone-muted case, which is the same situation
by a different route.
- **Move 07's stagger is not implemented.** The group chip and the one-tap undo
shipped; the sequenced arrival of the faces did not, because the facepile is
a plain `Row` rather than a lazy list, so there is no `animateItem` to hang it
on. It needs a keyed `AnimatedVisibility` per face — worth doing, but it is
decoration, and the rest of the move (provenance, undo) is the part that
carries meaning.
Interactive mockups (before/after, live private-mode transition):
<https://claude.ai/code/artifact/b4f8941f-b787-4355-9c12-c1b238538fe9>
## Open questions
1. Should the private-member opt-in be per-user (as proposed) or a single
list-level "include 3 private members" toggle? Per-user is safer; list-level
is fewer taps.
2. Do we want the group chip to survive a draft round trip (provenance
serialized into the draft), or is it a compose-session-only affordance?
3. Is 25 the right soft cap for a *public* post's Notify row, or should public
notify be capped lower given it is pure notification spam?
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,253 @@
# Relay AUTH permissions — state review & redesign
**Date:** 2026-08-03
**Module:** `amethyst` (+ `commons`)
**Status:** Implemented — see "As-built" below
**Mockups:** `2026-08-03-auth-permissions-redesign.html` (open in a browser)
**Supersedes the UI half of:** `2026-07-01-auth-permission-architecture.md`
## As-built
Shipped as designed. Where it diverged or went further:
- **Purpose now comes from `SubPurpose`.** `commons/relayauth/SubPurposeToAuthPurpose.kt`
maps the purpose every `ExplainedFilter` already declares onto `AuthPurposeKind`;
`RelayAuthPurposeDeriver` reads it and keeps tag-shape inference only as the
fallback for a plain `Filter`. A declared `READ_VENUE` prefers the assembler's
`entityIds` over sniffing `#e`, so note ids can no longer arrive as "venues".
- **`MY_INBOX` + `THREAD` added**, and both added to the ledger's
`hasAttributablePurpose` — they name no counterparty by design, so without that
they would have fallen to a silent DENY instead of ASK.
- **`MY_OWN_RELAY` is reachable from the UI, not the deriver.** One socket is
shared by every logged-in account, so a shared purpose list cannot know *whose*
relay it is. The prompt carries `isMyOwnRelay` per account instead.
- **Prompts are now per account, not per challenge.** The dialog names the account
whose npub would be revealed, so the old "first account to ASK answers for
everyone" shortcut had to go, and `RelayAuthPromptBus.inFlight` is keyed by
`(relay, account)`. In practice this rarely means two dialogs — `isFirstParty`
already drops every account without its own reason to be on the relay.
- **`rememberVenueLabel` only get-or-creates a channel for `POST_VENUE`** (whose id
really is a channel root); a read looks up an existing channel and otherwise
degrades. This is what stops the phantom-channel side effect.
- **`TopBarWithBackButton` gained an `actions` slot** (defaulted, so no other caller
changed) to carry the account chip.
- **Renamed rather than reused every string whose meaning or placeholders changed**
(`relay_auth_reason_*``relay_auth_why_*`, `relay_auth_prompt_title`
`relay_auth_login_as`, the toggles → `relay_auth_auto_*`, …). A stale Crowdin
translation binding to a reused key would have shown the *old* copy — "Notify:"
where the new sentence belongs — or silently dropped a new `%1$s`. New keys fall
back to the new English until Crowdin catches up. The 401 now-orphaned
translations were deleted from the 11 locale files that carried them.
- **The 60s timeout now runs from display, not from arrival.** Found while
answering "what happens if several auths are requested inside one 60s window?".
The host shows one dialog at a time but every prompt's deadline started when its
challenge arrived, so a burst of relays meant prompt 2..N counted down while
invisible. They expired unseen — a silent deny — and if the user did reach one
after it expired, the click was swallowed whole: `complete()` is a no-op on a
resolved deferred, so no auth was sent and not even the "always allow" rule was
written. `RelayAuthPrompt.markShown()` now starts the window, gated on a host
actually collecting (no UI → the old arrival clock, which is what the timeout was
always for) and capped by `queueWaitMs` so a stuck queue can't suspend a
connection forever. A second challenge for the same (relay, account) rides along
on the owner's answer with no deadline of its own — running one would let it
resolve the shared deferred and tear down a dialog mid-read.
- **Corrected later:** this plan left the decision model alone, including the
blanket `isFirstParty` gate on `CUSTOM`. That gate turned out to make
`readFollows` ("…I'm reading someone I follow") unreachable — a follow's outbox
relay is theirs, so it is never first-party for us, and every follow produced a
prompt with the toggle explicitly on. `RelayAuthResolver.customAllows` now
checks that one category ahead of the gate; the other three still require it.
- **Still not done:** what a timeout should *look like*. It is now an honest 60s of
visible time rather than a clock the user never saw, but it is still a dialog
that vanishes and an event left pending in the outbox with no feedback. That
needs a product decision, not a layout.
Verified: `:amethyst:testFdroidDebugUnitTest` 1096 tests green (38 in the relay-auth
suites, 5 of them new), `:commons:jvmTest` 1446 green, `spotlessApply` clean.
Scope is the two **NIP-42 AUTH** permission surfaces only — not the napplet/nSite
permission screens, not the Android runtime permission prompts.
| Surface | File |
|---|---|
| The AUTH prompt dialog | `amethyst/.../service/relayClient/authCommand/compose/RelayAuthPromptHost.kt` |
| The AUTH settings screen | `amethyst/.../ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt` |
## Findings
### 1. The same fact, restated up to four times
On the commonest prompt (`SEND_DM`, one recipient) the recipient is named in the
title, in the purpose label, in the avatar row, and again in the red consequence
line — four mentions of one name, three askings of one question, across six
stacked blocks (icon, title, boilerplate paragraph, relay chip, person row,
consequence).
The boilerplate `relay_auth_prompt_message` renders on *every* state, and the one
genuinely privacy-relevant fact — **which account** is about to be revealed — is
never shown, on an app that answers AUTH per account
(`AuthCoordinator.signWithAllLoggedInUsers`).
### 2. Four buttons, three of which mean yes
`Allow once` / `Always deliver my messages` / `Always allow this relay` /
`Block this relay`. Two of the yeses have completely different blast radii and
nothing on screen says so: `relay_auth_always_deliver` switches
`defaultRelayAuthPolicy` to `CUSTOM` and turns on **two** account-wide toggles
(`changeRelayAuthTrustMessageFollows`, `changeRelayAuthTrustMessageStrangers`).
A dialog button should not rewrite settings the user cannot see.
### 3. The reason shown is often not the reason
`RelayAuthPurposeDeriver` re-infers intent from raw filter shape —
`authors``READ_OUTBOX`, `#e`/`#a` → venue, `p`-tags on a pending event →
`NOTIFY_INBOX`. Two common cases fall through wrong:
- **Downloading your own replies/zaps.** `filterNotificationsToPubkey` is
`#p = me` with no `authors` — it matches no branch, so it sets
`unattributedRead` and contributes nothing. The dialog then either shows the
blank `OTHER` copy ("Use this relay") or, when unrelated pending traffic shares
the socket, borrows that traffic's **"Notify:"** label and facepile. The user is
*reading*; the prompt says they are *writing*.
- **Opening a thread.** `ReactionsFilterAssembler` fetches likes/zaps/reposts with
`#e = [noteIds]`. Any `#e` filter is classified `READ_VENUE`, so the prompt asks
**"Open 3f8a12c9?"**. Worse, `rememberVenueLabel` treats any 64-hex venue id as a
NIP-28 channel and calls `checkGetOrCreatePublicChatChannel` — minting a phantom
channel in `LocalCache` for a note that was never a chat, plus a metadata
subscription for it.
**The fix is upstream and already exists.** `ExplainedFilter` carries
`purpose: SubPurpose` (`NOTIFICATIONS`, `DIRECT_MESSAGES`, `PUBLIC_CHATS`, …),
`accountPubKeys` and `entityIds` on every filter the app opens. The auth path
discards it and re-guesses. Reading the declared purpose removes this whole class
of mis-attribution and unlocks two states the prompt cannot express today: *your
own inbox* and *this conversation*.
## State inventory
### Prompt (`RelayAuthPromptHost`)
| ID | Trigger | Title today | Verdict |
|---|---|---|---|
| P1 | `SEND_DM`, 1 recipient | Send your message to Alice? | redundant |
| P2 | `SEND_DM`, 3+ recipients | …to Alice and others? + facepile | redundant |
| P3 | `NOTIFY_INBOX` | Notify Alice? | often wrong |
| P4 | `READ_OUTBOX` | Load profile, posts and engagement from Alice? | verbose |
| P5 | `POST_VENUE` | Post to Bitcoin Devs? | empty body |
| P6 | `READ_VENUE` | Open Bitcoin Devs? | empty body |
| P7 | 2+ purposes live | title of the winner only, labelled sections stack | scrolls |
| P8 | `OTHER` | Confirm it's you to this relay? | honest but blank |
| P9 | metadata not loaded | …to `a1b2c3d4`? | raw hex as a name |
| P10 | `#p = me` read (own inbox) | falls to P8, or borrows P3 | unexpressible |
| P11 | `#e` read on note ids | Open `3f8a12c9`? | wrong + side effect |
Venue purposes (P5/P6) carry `venues`, never `counterparties`, but the body loop
iterates counterparties — so the middle of the dialog is an empty `Column`.
`MY_OWN_RELAY` has a string and a ledger branch but the deriver never emits it, so
`primaryNamed()` can never select it. The dialog also self-resolves to `DISMISS`
after 60 s — a silent deny the user never sees, leaving the event pending in the
outbox forever (the known limitation from the 2026-07-01 plan, now also a UI gap).
### Settings (`RelayAuthSettingsScreen`)
| ID | Condition | Verdict |
|---|---|---|
| S1 | policy = `ALWAYS` | ok |
| S2 | policy = `NEVER` | ok |
| S3 | policy = `CUSTOM` (default) | 7 descriptive paragraphs before the first relay |
| S4 | list empty | ok |
| S5 | `decision == null` (allowed by policy) | chip reads "Allow" |
| S6 | `decision == ALLOW` (explicit) | chip reads "Allow" — identical to S5 |
| S7 | `decision == DENY` | ok |
| S8 | rationale/last-used present or absent | facepile is unlabelled |
| S9 | relay on block list (kind 10006) | **not rendered at all** |
Further problems:
- Two headers ("When to authenticate" / "What to log in to") for one decision; the
second group is a child of the third card in the first and nothing shows it.
- Every switch description restates its own title.
- The header says **Per-relay overrides** but the list is
`allDecisions() allRationales() allLastUsed()` — most rows are a usage log.
- A bare ✕ next to a red chip reads as "block"; it actually clears the override
*and* the rationale, so the row silently returns on next use.
- The three-state model (`ALLOW` / `DENY` / none) is driven by a two-state chip.
- Nothing names the account, though decisions are stored per account.
## Proposed
### Prompt: one title, one sentence, two buttons
```
⬤ inbox.nostr.wine (relay icon + host — the thing being trusted)
asks who you are
Log in as @vitor? (constant title; names the account — new)
It won't accept your message for ⬤Alice Nakamoto from someone
it can't identify. (the ONE variable line; avatar inline)
[ Remember for this relay (• ) ] (switch, default off)
[ Not now ] [ Log in ]
Never allow How Amethyst decides
```
The one reason sentence replaces the old title + purpose label + avatar row +
consequence line. Four buttons become two plus a switch. Nothing in the dialog
writes a global setting; the link navigates to the settings screen instead.
`relay_auth_prompt_message` is **cut, not shortened**, and nothing replaces it.
"Log in" already means "identify yourself" and the title names the account, so a
sentence explaining the disclosure is boilerplate on every state — the same
duplication this proposal removes everywhere else. An npub under the title was
tried and dropped for the same reason: truncated, it can't be verified by eye, so
it is decoration that costs a line on every single prompt.
Reason sentence per state (P1′–P12, incl. the two new kinds and the
never-reachable `MY_OWN_RELAY`) — see the copy deck in the HTML.
Multi-purpose (P7) stops stacking labelled sections: the winning purpose keeps
the sentence, the rest collapse to one expandable line, so the dialog stays a
fixed height and the buttons never leave the fold.
### Settings: one decision, then two honest lists
- Account chip on the app bar.
- One radio group, "When a relay asks who you are": *Always log in* /
*Decide per relay* / *Never log in*, one clause each that adds a fact the title
doesn't already carry.
- Toggle group header carries the grammar — "Log in without asking when…" — so each
row is a three-to-four-word completion with no description.
- The single mislabelled list splits into what it actually contains:
**Exceptions** (explicit overrides only, two-way segmented `Always`/`Never`,
plus **✕ Remove exception** — today's `Forget`, kept and retitled: it clears the
override so the relay drops back to your rules and asks again next time,
confirmed by an undo snackbar. It is unambiguous now in a way it wasn't: it no
longer sits beside a red chip that also reads as "block", and it no longer wipes
the usage history, which has its own list),
**Blocked by your block list** (locked, kind 10006 — previously invisible),
**Recent logins** (the log, with `SubPurpose` chips — "your inbox",
"a conversation", "reading 4 follows" — instead of an unlabelled facepile).
- An empty state per list.
## What this needs before it can be built
1. **`RelayAuthPurposeDeriver`** — downcast `activeRequests` filters to
`ExplainedFilter` and map `SubPurpose``AuthPurposeKind`; keep tag-shape
inference only as the fallback for plain `Filter`s. Kills P10, P11 and the
phantom-channel side effect.
2. **`AuthPurpose`** — add `MY_INBOX` and `THREAD`; make `MY_OWN_RELAY` reachable.
3. **`RelayAuthPromptHost.rememberVenueLabel`** — a venue purpose should carry its
kind rather than sniffing 64-hex string length before get-or-creating a channel.
4. **`AuthCoordinator` / `RelayAuthPromptBus`** — carry the account into the prompt
so the dialog can name it; re-examine the `askChoice` shortcut that reuses one
answer across accounts once the dialog is account-specific.
5. **`RelayAuthSettingsScreen`** — split the list at the source.
6. **`RelayAuthPromptBus`** — decide what the 60 s silent `DISMISS` should look like.
The decision model is unchanged: `RelayAuthResolver`'s precedence ladder, the
per-relay gate and the four custom toggles all stay exactly as they are. This is a
copy and layout proposal plus one upstream correction so the copy describes what is
actually happening.
@@ -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.
@@ -0,0 +1,346 @@
# NIP-A3 Payment Targets in the zap picker — v1
**Status:** proposal
**Modules:** `quartz`, `commons`, `amethyst`
**Scope:** when a note's author publishes a NIP-A3 payment target, **an
installed app can handle it**, and the note carries no NIP-57 zap split — show
one amount-less chip per such target that hands off to that app.
> **Revised after the first implementation.** This document originally gated the
> chip on *symmetry* — both parties publishing the same protocol — and capped the
> row at two chips. Both are gone: the gate is capability alone (can anything on
> this phone open the URI), there is no cap, and the setting now defaults **on**.
> Sections below that argue for symmetry are kept for the reasoning, but §5 is
> the current rule.
Deliberately excluded from v1: amounts, in-app payment, receipts, fiat
conversion, desktop.
---
## 1. Why v1 has no amounts
Zap presets are **sats**. A `venmo` / `iban` / `upi` chip cannot send 1000
sats, and there is **no FX or bitcoin-price service anywhere in this repo**
(grepped `quartz`, `commons`, `amethyst`). So v1 does not pretend: the chip
carries no number, emits no RFC-8905 `amount=`, and the amount is named in the
external app. The UI has to *say* that rather than leave a suspicious blank —
see §4.
Corollary: **the note's zap counter will not move.** No kind:9735, nothing to
count. In code it is a rail; to the user it must read as *pay*, not *zap*.
---
## 2. The layout decision — and the refactor it deletes
> This is the one place v1 diverges from the sketch, and the reason is that it
> makes the change roughly half the size.
The sketch was "add the icons to the toggle." The toggle is the segmented
control **inside each amount pill** (`UnifiedZapAmountChip`,
`ReactionsRow.kt:2362`). Putting an amount-less rail there has two costs:
1. **It repeats.** With presets of 1000/5000/10000, the identical amount-less
Venmo segment renders three times and means the same thing each time.
2. **It forces `ZapRail` to become a sealed interface.** The enum
(`ReactionsRow.kt:2481`) is payload-free, so a segment can't know *which*
target it opens. Making it data-carrying drags in `present`, `preferred`,
`selectedRail`, `ZapRailIcon`, `previewPreferredRail`, `previewRailsFor`
and the settings preview row — and, because `PaymentTarget` has no
`equals`, breaks the `remember(preferred, present)` key so the user's
selection resets on recompose.
**Instead: render the chip as a sibling of the amount pills**, appended to the
existing `FlowRow` in `ZapAmountChoiceGrid` (`ReactionsRow.kt:2297`), next to
the `Tune` preset-editor button. It wraps for free, it renders **once**, and
`ZapRail`, `UnifiedZapAmountChip` and every preview stay **completely
untouched**. Same popup, same place the user is already looking.
If an FX service ever lands and the amount becomes expressible, the chip moves
into the toggle then — that is the natural migration, not a reason to pay for
it now.
---
## 3. Intent discovery — the constraint that decides it
`targetSdk = 37`. Under Android 11+ package visibility,
`queryIntentActivities` returns **empty** for any intent not covered by a
`<queries>` declaration — so *without a manifest change this feature silently
shows nothing on every modern device*. The existing `<queries>` block
(`AndroidManifest.xml:4`) covers only `nostrsigner`, TTS, Health Connect and
Tor.
### 3.1 Manifest
Add one `<intent>` per scheme we probe. The important economy: an arbitrary
user-typed type (`iban`, `upi`, `pix`, …) always falls back to
`payto://<type>/<authority>`, so **one `payto` entry covers every generic
type**. Only the ~12 special-cased crypto schemes in `paymentTargetStyleFor`
(`DisplayPaymentTargets.kt:190`) need their own entries.
```xml
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="payto" />
</intent>
<!-- + one each: bitcoin, lightning, liquidnetwork, ethereum, monero, dash,
zcash, bitcoincash, litecoin, dogecoin, solana, tron -->
```
Use **`<queries>`, never `QUERY_ALL_PACKAGES`** — the latter is a
policy-restricted permission on Play and would need a declaration; specific
`<intent>` filters need nothing. On minSdk 2629 `<queries>` is ignored and
everything resolves, which is a strict superset of the gated behaviour.
### 3.2 https targets are exempt
`cashapp` / `venmo` / `paypal` map to `https://…`, which a browser always
resolves — discovery would be a tautology. **Skip discovery for https
targets and always show them**: opening `venmo.com/<handle>` in a browser is a
legitimate way to pay, so nothing is broken. For these three types the chip is
therefore gated only on the author having published one.
**But §4.2 still needs the control probe here.** To tell a real app handler
from a browser, resolve a control `https://<nonexistent-host>/` and treat the
target as app-backed only if its resolver set contains a package outside that
control set. It never gates the chip — it decides whether the chip wears the
app's icon or the brand-colour glyph, and a Chrome icon on a Venmo chip is
worse than no icon at all.
### 3.3 The cache — keyed by scheme+host, warmed from the open picker
The naive cache is per-post and lazy. With symmetry gone the probe set is the
author's target list, so:
> **Probe the targets of the one author whose picker is open** — typically 15
> entries — and **merge** the answers into the cache. Merging matters: replacing
> would evict what was learned about every other author the moment a second
> picker opened. Feed rendering still never triggers a probe.
- **Key:** `"<scheme>://<host>"`, e.g. `payto://iban`, `bitcoin://`. Scheme
alone is too coarse — an app may declare `android:scheme="payto"
android:host="iban"`, so a scheme-only hit would wrongly claim `payto://upi`
is handled.
- **Warm:** a `LaunchedEffect` keyed on the author's observed kind:10133 probes
that handful of keys off the main thread when the picker opens.
- **Read:** synchronous map lookup — required, because
`RailCapabilityResolver.peek` is called from inside `remember {}`.
- **Recomposition:** the map must be a `MutableStateFlow<Map<String, Boolean>>`,
not a bare `ConcurrentHashMap`. A plain map write is invisible to Compose and
the chip would not appear until something else recomposed.
- **Invalidation:** clear on app foreground (`ProcessLifecycleOwner`
`ON_START`) and re-warm — this is exactly the "user left, installed Venmo,
came back" flow. A `PACKAGE_ADDED`/`REMOVED` receiver is more precise but is
more moving parts than v1 needs.
Home: `amethyst/…/service/payments/PayToAppAvailability.kt` (Android-only;
`PackageManager` has no KMP equivalent). The scheme mapping it needs moves out
of the UI file into `commons` (§6.0).
---
## 4. The chip's face
### 4.1 Saying "the app decides the amount"
An amount-less chip beside pills that all show numbers reads as a bug unless
it is visibly a *different kind of thing*. Three cues, no extra layout:
1. **No number.** Icon + protocol label only (`VENMO`).
2. **A different terminal glyph.** `MaterialSymbols.OpenInNew` instead of the
`ArrowForward` every amount segment uses — "this leaves the app."
3. **A string that says it outright**, e.g. *"Amount set in %1$s"*, shown as
the chip's `contentDescription` and as a toast on long-press.
**Both glyphs are already in `MaterialSymbols.kt`** (`OpenInNew:280`,
`AccountBalanceWallet:27`) — **no `tools/material-symbols-subset/subset.sh`
run is needed**, and §4.2 adds no new glyphs either.
Long-press must **not** inherit `onChangeAmount` (the sat-preset editor is
meaningless here); it copies the authority, matching `PaymentTargetChip`'s
long-press on the profile.
### 4.2 Which icon it wears — the installed app's, not a bundled logo
**This already works in this codebase.** `ExternalSignerButton.kt:118` renders
installed NIP-55 signers with `it.loadLabel(pm)` / `it.loadIcon(pm)`
`toBitmap()` → Coil's `rememberAsyncImagePainter`, off the back of
`getExternalSignersInstalled` (`quartz/…/IsExternalSignerInstalled.kt`), which
is `queryIntentActivities(ACTION_VIEW, "nostrsigner:")` — **the same call
§3 already makes for discovery.** The `ResolveInfo` we keep to answer "can
anything open this?" also carries the icon and the app's own name. The icon is
therefore very close to free; what it costs is care.
**Do not bundle brand logos.** Three reasons, in order of weight:
1. **Trademark, not licence.** `CLAUDE.md`'s dependency gate covers *code*
licences; a Venmo or PayPal mark shipped inside an MIT APK is a separate
trademark question. Referential use is usually permitted, redistribution of
the mark often is not. That is a maintainer's call, not a silent one.
2. **The type space is unbounded.** `PaymentTargetsViewModel.addTarget` accepts
any `type.trim().lowercase()`, so a bundled set can never be complete —
`pix`, `upi`, `swish`, `interac` and the next one all miss.
3. **The codebase already decided this.** `paymentTargetStyleFor` pairs brand
*colours* (`VENMO_BLUE #008CFF`, `PAYPAL_DEEP_BLUE #003087`,
`CASHAPP_LIME #00E64D`) with the generic `AccountBalanceWallet` glyph.
Brand colour + generic glyph is the established pattern; keep it as the
fallback. Brand marks are also absent from Material Symbols, so each would
be a hand-authored `ImageVector` like `CustomHashTagIcons.Cashu`.
So: **the installed app's icon *is* the brand icon**, sourced from the device
instead of shipped. It is self-limiting in the right direction — the "popular
options" are exactly the ones with an app installed.
**Four things the precedent gets away with and we would not:**
- **Load once, in the warm step.** `ExternalSignerButton` calls `loadIcon()` +
`toBitmap()` inside a `LazyColumn` item, so it re-runs on recomposition —
tolerable in a one-shot dialog, not in the zap popup. `loadIcon` reads the
target APK's resources, so it is I/O: do it in §3.3's off-main warm and
cache the **`ImageBitmap`**, never the `Drawable`.
- **Size and mask it.** minSdk is 26, so any icon may be an
`AdaptiveIconDrawable`: a 108×108 canvas whose outer margin the launcher
masks away. A bare `toBitmap()` drawn at 18dp shows a small logo floating in
padding. Use `toBitmap(px, px)` at the target size plus
`Modifier.clip(CircleShape)` — what a launcher does. The precedent renders
at 48dp and gets away with it.
- **Pick one app, or none.** `payto://` can resolve to several. Ask
`resolveActivity(intent, MATCH_DEFAULT_ONLY)` for the user's default; when
Android hands back its `ResolverActivity` (no default set) there is no app
to name — fall back to the glyph rather than showing the chooser's icon.
- **Accept that it cannot be tinted.** Every other rail is a monochrome glyph
tinted `BitcoinOrange` / `onSurface`. A full-colour raster can't join that
scheme — which is arguably the point: it is the visual signal that this
segment leaves the app. It needs the circular clip and a slightly smaller
optical size to sit beside 18dp glyphs.
**This promotes the https control-probe from a nicety to v1 work.** §3.2 exempts
`venmo` / `paypal` / `cashapp` from discovery because a browser always resolves
`https://`. That is fine for *gating*, but not for *icons*: with only a browser
installed, `resolveActivity` returns **Chrome**, and a Chrome icon on a Venmo
chip is worse than no icon. So an https target needs the control probe
(resolve `https://<nonexistent-host>/`, treat the target as app-backed only if
its resolver set contains a package outside that control set) to decide
**icon vs brand-colour glyph**, even though it never gates the chip.
---
## 5. Gates (all must hold)
1. Setting `showPayToZapChip`**default on**. The chip only ever shows a
target its author chose to publish, to a device that can already open it,
so the discovery gate is doing the real narrowing (`UiSettings.kt:67`
`UiSettingsFlow.kt:59``UISharedPreferences.kt:192`).
2. Note has **no** zap split: `zapSplitSetup().isNullOrEmpty()`. payto can't
fan out and returns no receipt. `RailCapabilityResolver.peek` **already
computes `splits`** — one-line reuse.
3. Recipient (note author) publishes ≥1 handoff-class target.
4. §3 says an app can handle it (or it's https). **This is the substantive
gate**; everything else is a precondition.
No cap: every openable target is offered. Discovery is what bounds the row —
a target with nothing to open it never reaches the picker.
**Handoff-class** excludes the wallet-covered types — `lightning`/`ln`/`lnurl`
and `bitcoin`/`btc`/`onchain` *are* the existing LIGHTNING and ONCHAIN rails.
Without this exclusion the picker grows a second Bolt icon beside the first.
---
## 6. Implementation
### 6.0 Prep — no behaviour change
- `quartz`: `PaymentTarget``data class` (it has no `equals` today; needed
for list keys and dedupe, and it fixes the hand-rolled field-by-field
compare in `PaymentTargetsViewModel.addTarget`).
- `commons/…/model/payments/PaymentTargetTypes.kt` (package exists, holds
`PaymentSourceResolver`): `canonical(raw)`, `isWalletCovered(canonical)`,
`schemeFor(canonical)`. Move `LIGHTNING_TARGET_TYPES` /
`BITCOIN_TARGET_TYPES` (`DisplayPaymentTargets.kt:67,70`) and the scheme half
of `paymentTargetStyleFor` here — today they are duplicated twice inside one
Android UI file, and discovery needs them too.
- `commons/…/model/User.kt`: `paymentTargetsNote` + `paymentTargets()`,
mirroring `nutzapInfoNote` (`User.kt:79`).
**No new relay subscription:** kind 10133 already rides in
`UserMetadataForKeyKinds` beside kind:0 and kind:10019
(`FilterUserMetadataForKey.kt:50`), so the recipient's targets are in cache by
the time the note renders — same as the nutzap rail.
### 6.1 Matcher — pure, headless
`commons/…/model/payments/PayToRailMatcher.kt`: canonicalize both sides, drop
wallet-covered types, intersect on type, dedupe by type. No Android, no
Compose.
### 6.2 Discovery
`amethyst/…/service/payments/PayToAppAvailability.kt` per §3.3 + the manifest
`<queries>` entries per §3.1. Each cache entry holds what §4.2 needs as well as
the yes/no: `{ resolves: Boolean, label: String?, icon: ImageBitmap? }`
decoded once in the warm step at the 18dp target size, never per composition.
Icon and label are null for the no-default (`ResolverActivity`) and
browser-only cases, and the chip falls back to the brand-colour glyph.
### 6.3 Capability
- `RailCapability` += `payToTargets: List<PaymentTarget> = emptyList()`
defaulted, so `RailCapabilityCashuStatusTest` and every existing call site
compile untouched.
- `peek(..., senderTargets = emptyList(), payToEnabled = false, available = emptyMap())`
**defaulted, because `zapClick` also calls `peek`**
(`ReactionsRow.kt:1464`) for the one-tap fast path, which must stay
Lightning-only. Returns empty when splits exist.
- `observeZapRailCapability` (`ReactionsRow.kt:2098`) adds four inputs, each
both a subscription trigger and a `remember` key — the contract spelled out
in the "do NOT delete these as unused" comment at `ReactionsRow.kt:2105`:
`paymentTargetsState.flow`, the author's `paymentTargetsNote`,
`uiSettingsFlow.showPayToZapRail`, and the availability `StateFlow`.
### 6.4 UI
One new `PayToHandoffChip` composable appended to `ZapAmountChoiceGrid`'s
`FlowRow`. Action: `uriHandler.openUri(...)`; keep the existing try/catch →
`no_payment_app_found_for_type` toast (string exists) as a belt-and-braces
fallback for the race where the app is uninstalled between warm and tap. It
must not touch `zappingProgress`, `zapStartingTime` or `accountViewModel.zap`.
### 6.5 Settings + strings
`showPayToZapRail` through the `showOnchainWallet` chain + `SettingsCatalogBuilder`;
new strings; changelog.
---
## 7. Tests
| Level | Test | Asserts |
|---|---|---|
| `commons/commonTest` | `PaymentTargetTypesTest` | alias collapse, case/whitespace, wallet-covered set, scheme mapping |
| `commons/commonTest` | `PayToRailMatcherTest` | empty sender → empty; no overlap → empty; `ln` vs `lightning` → empty (wallet-covered); `Venmo` vs `venmo` → match; dedupe by type |
| `amethyst/test` | sibling of `RailCapabilityCashuStatusTest` | split present → empty; setting off → empty; no author → empty; unavailable scheme → empty; https target → shown without probe; **existing rails unaffected** |
| `amethyst/test` | `PayToAppAvailabilityTest` | key is scheme+host, not scheme; probe count == sender's target count, independent of post count; `ResolverActivity` default → null icon; browser-only https → null icon (control probe) |
| Manual | | chip appears once (not per pill); tap opens the app; **counter does not move**; split note shows no chip; install app → background → foreground → chip appears; adaptive icon is masked round, not floating in padding; https target with no app shows the glyph, not Chrome |
---
## 8. Open decisions
1. **Chip placement** — sibling vs inside the toggle (§2). Recommend sibling:
renders once and deletes the whole `ZapRail` refactor. Flagged because it
diverges from the original sketch.
2. **Default for `showPayToZapRail`** — recommend **off**, matching how
`ReactionRowAction.Pay` already ships disabled.
3. **Private rumors** — on-chain is suppressed there (it would e-tag the
rumor). A payto handoff publishes nothing, so it is arguably safe.
Recommend **allow**, noting the divergence from the on-chain precedent.
4. **`ReactionRowAction.Pay` overlap** — recommend keeping both, `Pay`
disabled by default: `Pay` browses *all* of a recipient's targets, this
chip is the *matched, installed, splitless* shortcut.
5. **Colour icon beside monochrome glyphs** (§4.2). The app icon can't be
tinted, so the chip will be the one full-colour thing in the popup.
Recommend **accepting** it as the "this leaves the app" signal — but it is a
visible break from the rail iconography and worth an explicit yes.
6. ~~**Symmetry heuristic**~~*removed; see the note at the top.* It was
right for closed loops (Venmo, Cash App, UPI),
arguably too strict for open ones (Monero: a sender needs a wallet, not a
published address). Ship strict; relaxing later is additive. Note that
intent discovery already covers much of what symmetry was proxying for, so
dropping symmetry for scheme-based types is a live option.
@@ -0,0 +1,455 @@
# Entity Ratings (kind 34259) — parse, ingest, render, feed
**Status:** implemented — see §11 for what shipped and where this document was wrong
**Modules:** `quartz`, `commons`, `amethyst`
**Scope:** parse kind-34259 entity ratings, ingest them into `LocalCache`, render
them as a star + review card, and make them appear in the Home feed behind a
Settings Home toggle.
Deliberately excluded from v1: composing/publishing a rating from Amethyst,
aggregate ("3.8★ from 12 raters") rollups on the rated object, and the kind-30040
publication *reader*. §8 says why and what each would take.
---
## 1. What we are implementing
Kind 34259 is **not a merged NIP**. The upstream spec is
[`XYZ.md` in `abh3po/nostr-polls`](https://github.com/abh3po/nostr-polls/blob/main/XYZ.md)
(Pollerama) — a deliberately generic "rate anything" addressable kind. It defines
exactly three tags:
| tag | spec meaning |
| --- | --- |
| `d` | id of the rated entity; prefixed with the `m` value when the bare id isn't unique (`hashtag:books`) |
| `m` | mark — entity type: `event`, `profile`, `relay`, `hashtag`, `books`, `movies`…; empty ⇒ a nostr event |
| `rating` | "Number less than 1 and greater than 0" |
Everything else in the wild sample below is an **extension by
`silberengel/jumble`** (the web client that signs `client: imwald`), built in
`src/lib/draft-event.ts:1626` for rating a kind-30040 NKBIP-01 publication:
```json
{ "kind": 34259,
"tags": [
["d", "books:30040:5736…edc:wuthering-heights"],
["m", "books"],
["rating", "1.000"],
["s", "5"],
["a", "30040:5736…edc:wuthering-heights"],
["A", "30040:5736…edc:wuthering-heights"],
["e", "aff2…287", "", "5736…edc"],
["k", "30040"],
["p", "5736…edc"],
["c", "true"]
],
"content": "This is my very favorite book. …" }
```
`s` = raw 15 stars, `a`/`A` = the rated coordinate, `e` = the index event id,
`k` = rated kind, `p` = rated author, `c` = "has a written comment".
**Design consequence:** we implement the *generic* kind with typed accessors, and
treat `m` as the dispatch key for presentation. We do not hardcode books into the
event class. Books is simply the first mark we render richly.
---
## 2. The two parsing traps
These are the whole reason this needs a plan rather than a 40-line event class.
### 2.1 `rating` is ambiguous at exactly 1
The spec says 0 < rating < 1. jumble publishes `"1.000"` for five stars —
outside the spec's own stated range. Other clients publish a plain `1``5`.
So the string `"1"` means **either** 1.0 (⇒ 5 stars) **or** 1 raw star, and the
`rating` tag alone cannot tell you which.
jumble resolves it one way (`event-metadata.ts:1364`: `raw <= 1` ⇒ fraction ⇒
×5), which silently turns a genuine 1-star review from a raw-scale client into a
5-star one. We should not copy that blindly.
**Rule for our parser** (`EntityRatingEvent.stars()`):
1. If an `s` tag is present and parses to 1..5 → that is the star count. Authoritative.
2. Else if `rating` parses to a value in `0.0..1.0` **inclusive**`rating × 5`.
3. Else if `rating` parses to `1.0 < x <= 5.0` → raw stars.
4. Else → `null` (render the comment, no stars) — never 0, never a guess.
Step 1 is what makes `1` unambiguous for every event jumble emits, and step 2
accepts the boundary value the spec's prose excludes. Pin all four branches with
tests, including the `"1"`-without-`s` ambiguity resolving to 5 (documented as
the interop choice, with the reasoning in a comment).
### 2.2 `d` carries the mark prefix
`d` is `books:30040:<pubkey>:<identifier>` — the coordinate with an `m:` prefix.
The parser must strip a leading `<mark>:` before treating the remainder as an
`Address`. And because only Pollerama-derived clients apply the prefix, any REQ
by `#d` must ask for **both** the prefixed and bare forms
(jumble's `publicationRatingDTagsForQuery` does exactly this).
---
## 3. Quartz — `experimental/ratings/`
Not a merged NIP ⇒ `experimental/`, alongside `agora`, `birdstar`, `nipsOnNostr`.
```
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ratings/
├── EntityRatingEvent.kt # kind 34259, AddressableEvent
├── RatingMark.kt # the `m` vocabulary + prefix strip/apply
├── TagArrayBuilderExt.kt # builder DSL (rating/mark/target/stars)
└── tags/
├── RatingTag.kt # ["rating", "0.000".."1.000"]
├── StarsTag.kt # ["s", "1".."5"] (jumble ext)
└── HasCommentTag.kt # ["c", "true"] (jumble ext)
```
`EntityRatingEvent` surface:
```kotlin
class EntityRatingEvent(...) : Event(...), AddressableEvent {
fun mark(): String? // `m`, null ⇒ "event" per spec
fun ratingFraction(): Double? // `rating` clamped to 0.0..1.0, null if unparseable
fun stars(): Double? // §2.1 resolution ladder
fun targetAddress(): Address? // `a`/`A` first, then the de-prefixed `d`
fun targetEventId(): HexKey? // `e`
fun targetKind(): Int? // `k`
fun targetAuthor(): HexKey? // `p`
fun hasComment(): Boolean // `c` == "true", OR content.isNotBlank()
companion object { const val KIND = 34259 }
}
```
`targetAddress()` prefers `a`/`A` because they are unambiguous; the de-prefixed
`d` is the fallback for clients that only publish `d` (which the spec permits —
`a` is not in the spec at all).
Also implement `SearchableEvent.indexableContent()` returning `content` — a
review is prose and belongs in NIP-50 search. Cheap, and the `searchable-events`
skill documents the diff surface external engines mirror, so note it there.
**Registration checklist** (each is a real edit, all found by grep):
| File | Edit |
| --- | --- |
| `quartz/…/utils/EventFactory.kt` | branch in the `when` (before the `else` at :845) |
| `quartz/…/kinds/KindNames.kt` | English name "Entity rating" |
| `commons/…/connectedApps/signers/NostrSignerPermissionLedger.kt:244` | addressable re-sign list |
| `amethyst/…/relays/KindDisplayName.kt` | localized name + `strings.xml` |
| `quartz/…/utils/EventFactoryIsKnownKindTest.kt` | assert `isKnownKind(34259)` |
---
## 4. `LocalCache` ingest
Today the event is **dropped**: unknown kind ⇒ bare `Event` ⇒ the `else` at
`LocalCache.kt:3886` logs `"Event Not Supported"` and returns `false`. It never
becomes a `Note`, so no amount of UI work would show it.
Add `is EntityRatingEvent,` to the addressable group that dispatches to
`consumeBaseReplaceable` at `LocalCache.kt:3799`. One line. `consumeBaseReplaceable`
already handles addressable supersession, so the "one rating per author per
target" replaceable semantics come for free.
### Do **not** add a `computeReplyTo` branch in v1
`computeReplyTo` (`LocalCache.kt:1268`) falls through to `emptyList()` for
unknown types, so `Note.replyTo` stays empty and `Note.isNewThread()`
(`commons/…/model/Note.kt:1364`) returns **true**. That is what we want: the
rating renders as a top-level card in the New Threads tab.
This is the `HighlightEvent` precedent — a highlight also carries `a`/`e` to its
source and also has no `computeReplyTo` branch; it renders the source inline
instead.
The trade-off is explicit: without a `computeReplyTo` branch the rating does
**not** appear in the rated publication's thread view or reply count. Adding one
later moves it *out* of New Threads (because `isNewThread()` flips false) unless
`HomeNewThreadFeedFilter` is taught an exception. Decide once; v1 picks feed
visibility, because that is the stated goal.
---
## 5. Feed visibility — three gates, all must open
This is the part that is easy to half-do. An event only reaches the Home feed if
**all three** of these pass. Verified by reading each file.
### Gate 1 — the REQ must ask for the kind
The kind lists are duplicated across five top-nav strategies. Add
`EntityRatingEvent.KIND` to `HomePostsNewThreadKinds2` in:
- `commons/…/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt:83`
- `commons/…/relayClient/home/nip01Core/FilterHomePostsByHashtags.kt`
- `commons/…/relayClient/home/nip01Core/FilterHomePostsByGeohashes.kt`
- `commons/…/relayClient/home/nip01Core/FilterHomePostsByGlobal.kt`
- `commons/…/relayClient/home/nip72Communities/FilterHomePostsFromCommunities.kt`
(`Kinds2` rather than `Kinds1``Kinds1` is already at 16 kinds and the two
lists exist to keep each REQ's kind array bounded.)
### Gate 2 — a `HomeFeedType` group
`commons/…/model/HomeFeedType.kt` is the Settings Home toggle registry; its
`kinds` drive both the REQ strip (`HomeOutboxEventsEoseManager.removeDisabledHomeKinds`)
and the DAL filter. Add:
```kotlin
RATINGS("ratings", listOf(EntityRatingEvent.KIND)),
```
`code` is the on-disk identifier — never rename it. `HomeFeedTypeTest.kindsAreDisjointAcrossTypes`
enforces that no two groups claim a kind, so 34259 must appear exactly once. Add
a string for the toggle label in `HomeTabsSettingsScreen`.
### Gate 3 — the DAL must accept it
`amethyst/…/home/dal/HomeNewThreadFeedFilter.kt`, two edits:
1. **`ADDRESSABLE_KINDS`** (:108) — required. `feed()` scans `LocalCache.notes`
only for `kind < 10000` ("Avoids processing addressables twice"), so an
addressable kind that is not in this list is invisible no matter what else is
configured.
2. **`acceptableEvent`** (:141) — add `noteEvent is EntityRatingEvent` to the
type disjunction.
Mirror both in `HomeConversationsFeedFilter.kt` only if we later add the
`computeReplyTo` branch; not in v1.
Desktop has its own copy in `desktopApp/…/feeds/DesktopFeedFilters.kt` — out of
scope here, but note it so the two don't silently diverge.
### Anti-spam gate
jumble drops kind-34259 events with no `d` tag on ingest. Ours is weaker-risk
(addressable ⇒ a `d`-less event occupies exactly one slot per author), but a
rating with no resolvable target is unrenderable. Reject in `acceptableEvent`:
`targetAddress() != null || targetEventId() != null`.
---
## 6. Rendering
New `amethyst/src/main/java/…/ui/note/types/EntityRating.kt`:
```kotlin
@Composable
fun RenderEntityRating(note: Note, accountViewModel: AccountViewModel, nav: INav)
```
Layout, modelled on `Classifieds.kt` (compact addressable card) + `Highlight.kt`
(inline source resolution):
```
┌──────────────────────────────────────────────┐
│ ★★★★★ [books] │ stars + mark chip
│ ┌──────────────────────────────────────────┐ │
│ │ 📕 Wuthering Heights │ │ target card, clickable
│ │ by @emilybronte │ │ ← LoadAddressableNote
│ └──────────────────────────────────────────┘ │
│ This is my very favorite book. The fast- │ content, TranslatableRichTextViewer
│ paced and mysterious plot … │
└──────────────────────────────────────────────┘
```
- Target resolution: `LoadAddressableNote(targetAddress(), …)` — same helper
`RenderPostApproval.kt:61` and `Attestation.kt` use.
- **Title fallback while (or if) the target never loads:** derive a label from the
coordinate's `d` (`wuthering-heights` → "Wuthering Heights"), as jumble's
`publicationTitleHintFromRatingEvent` does. Without this the card is a row of
stars attached to nothing — see §7.
- Unknown/absent `m`: render stars + comment + a generic `nostr:` link to the
target. The kind is generic; the card must degrade, not blank.
- Dispatch: add `is EntityRatingEvent -> RenderEntityRating(...)` to
`NoteCompose.kt`'s render `when`, **before** the `else` at :1658 (which
currently routes unknown kinds to `RenderTextEvent`).
### The star icons need a font change
`MaterialSymbols.kt:233-234` defines **both** `Star` and `StarBorder` as
`\uF09A` — the same codepoint. Filled and outline stars are currently
indistinguishable (which also means `FavoriteAlgoFeedToggle.kt:90/97` and
`RelayGroupDiscoveryScreen.kt:400` are drawing the same glyph for on and off
today).
A 3.5-of-5 star row needs three distinct glyphs. So:
1. Give `StarBorder` its real outline codepoint and add `StarHalf`.
2. Regenerate the subset — **mandatory**, per `.claude/CLAUDE.md`:
`./tools/material-symbols-subset/subset.sh`
3. Commit the regenerated `material_symbols_outlined.ttf` with the
`MaterialSymbols.kt` change, or the new glyphs render as tofu.
Fixing the duplicate is a small pre-existing-bug fix that rides along; call it
out in the PR body since it changes two unrelated toggles' appearance.
---
## 7. The kind-30040 dependency
The rated object here is a **kind-30040 NKBIP-01 publication index**. Amethyst
has no class for 30040 either, so `LocalCache` drops it by the same `else` at
:3886. `LoadAddressableNote` will therefore resolve to a permanently empty
`Note`, and the target card in §6 will never show a real title.
Two ways to close this, and they are separable:
**(a) v1 — slug fallback only.** Ship §6's derived-title fallback and no 30040
class. The card reads "Wuthering Heights" from the coordinate. Cheap, honest,
and correct for the common case where the `d` is a slug. Fails softly (shows the
raw identifier) when the `d` is a hash or an opaque id.
**(b) Phase 2 — minimal `PublicationIndexEvent` (30040).** A `title`/`author`/`d`
parser plus `consumeBaseReplaceable` wiring, so the target card shows the real
title and links somewhere. **Explicitly not** the publication *reader* — 30040 is
an index over kind-30041 sections, and rendering a book is a separate feature an
order of magnitude larger than this one.
Recommend shipping (a) in v1 and (b) as an immediate follow-up. Do **not** let
(b)'s scope pull the reader in.
---
## 8. Out of scope for v1, and what each would cost
| Deferred | Why | Rough shape |
| --- | --- | --- |
| **Publishing a rating** | Needs a compose surface (star picker + comment) and a "what am I rating" entry point, which does not exist until 30040 objects are browsable. The quartz builder DSL lands in v1 anyway, so this is UI-only later. | `NewPostScreen` variant + a rate action on the target card |
| **Aggregate rollups** | Needs a per-target index in `LocalCache` (like the zap/reaction indices) plus a REQ by `#a`/`#d`. Meaningful only once there is a target screen to put the average on. | New index + `FeedMetadataCoordinator` assembler |
| **Rating relays / profiles / hashtags** | The kind is generic and these marks are in the spec, but each needs its own target card and entry point. The event class supports them from day one. | Per-mark `RenderEntityRating` branch |
| **Relay reviews (kind 31987)** | Same `rating` tag convention, also unsupported in Amethyst today. Sharing `stars()` between the two is the natural next step. | Reuse `tags/RatingTag.kt` |
---
## 9. Order of work
1. **quartz**`EntityRatingEvent` + tags + builder + `RatingMark`, with unit
tests covering all four `stars()` branches, the `d`-prefix strip, `a`-over-`d`
preference, and a round-trip of the real imwald event above as a fixture.
2. **quartz registries**`EventFactory`, `KindNames`, `isKnownKind` test.
3. **LocalCache** — one line at :3799; test that the fixture becomes an
addressable `Note` and that a newer rating from the same author supersedes it.
4. **Feed plumbing** — the three gates in §5, plus the `HomeFeedType` test update.
5. **Icons** — codepoint fix + `subset.sh` + committed `.ttf`.
6. **Rendering**`EntityRating.kt`, `NoteCompose` branch, `@Preview` in
`ThemeComparisonColumn` (the convention every `ui/note/types/*.kt` follows).
7. **Verify**`./gradlew test`, then drive the real event end-to-end with
`amy` against `wss://pipe.imwald.eu/` (already a known-good relay in this
repo: `quartz/plans/2026-07-16-relay-limits.md`) to confirm ingest and
feed acceptance, not just unit-test green.
8. `./gradlew spotlessApply`.
Steps 13 are independently useful (they stop the drop and make the kind
inspectable) and can merge before the UI lands.
---
## 10. Open questions for the maintainer
1. **`m` vocabulary policy.** Render only known marks richly and fall back to a
generic card for the rest (proposed), or refuse to render unknown marks at
all? The generic fallback risks showing stars for a mark whose target we
cannot resolve.
2. **Default-on or default-off.** `HomeFeedType.ALL` enables every group by
default. A brand-new, single-client, non-NIP kind arriving in everyone's Home
feed on upgrade is a defensible objection — say so and it ships default-off
with an explicit opt-in, at the cost of nobody discovering it.
3. **Is 30040 worth it here**, or should ratings wait until publications are a
real feature? §7(a) makes v1 standalone, but a feed of star-ratings pointing
at books Amethyst cannot open is arguably not worth shipping alone.
---
## 11. What shipped, and where this plan was wrong
Implemented on `claude/event-kind-34259-parsers-jocu9w`. Three things came out
differently once the code was written; the sections above are left as they were
so the reasoning is still legible, and this section is what is true.
### 11.1 The star-icon claim in §6 was wrong
§6 says `MaterialSymbols.Star` and `StarBorder` sharing `\uF09A` is a bug. **It
is not.** Material Symbols expresses fill through the **FILL variable axis**, not
through separate codepoints, so the upstream codepoint table maps `star`,
`star_border`, `star_outline` *and* `grade` all to `f09a`. The duplicate in
`MaterialSymbols.kt` is correct, and the toggles in `FavoriteAlgoFeedToggle.kt`
and `RelayGroupDiscoveryScreen.kt` distinguish their states by tint, not glyph —
also correct. Nothing there needed fixing.
What is true: `star_half` **is** its own glyph (`e839`), and it was not in the
subset. So the font change that actually shipped is one added symbol:
```kotlin
val StarHalf = MaterialSymbol("\uE839")
```
plus the regenerated `material_symbols_outlined.ttf` (241 codepoints, was 240).
Filled vs empty stars are the same glyph at two tints — `primary` and
`onSurfaceVariant`.
**Corrected after review.** `MaterialSymbolPainter` originally drew the glyph as
tinted text with no variable-axis control, so the first pass tinted an outline
star and called it "filled" — five hollow outlines that read as an empty row.
`87a44b97` added FILL-axis support to the painter and `aa54508e` switched the row
to `filled = isOn`, which is the correct fix: an earned star is FILL=1, not a
tint. Tint now only distinguishes earned from unearned, which is what it is
good for.
### 11.2 Gate 1 is one list, not five
§5 says to add the kind to five parallel REQ kind lists. Only one was right:
`HomePostsNewThreadKinds2`, which the Follows, Global and Relay top-nav
strategies all share.
The hashtag, geohash and community lists select by `t` / `g` / community-`a`
tags. A rating carries none of those, so adding the kind there would widen every
such REQ for zero possible matches. If ratings ever start carrying topics it is a
one-line addition to each.
### 11.3 §7 shipped as option (b)
`PublicationIndexEvent` (kind 30040, NKBIP-01) is implemented — title, author,
summary, image, type, version, topics and the ordered `a`-tag table of contents —
so the rated publication resolves to a real title instead of a slug. The reader
is **not** implemented, and kind 30041 sections are still unparsed, exactly as
§7 said they should be.
`titleOrIdentifier()` keeps the slug fallback from option (a) anyway, because the
spec's mandatory `title` tag is missing from events in the wild.
### 11.4 Everything else went as planned
| Area | Where |
| --- | --- |
| Event classes | `quartz/…/experimental/ratings/`, `quartz/…/experimental/publications/` |
| Registries | `EventFactory`, `KindNames`, `NostrSignerPermissionLedger` |
| Ingest | `LocalCache.kt` addressable group (no `computeReplyTo` branch, per §4) |
| Feed gates | `HomePostsNewThreadKinds2`, `HomeFeedType.RATINGS`, `HomeNewThreadFeedFilter` |
| Settings | `home_content_type_ratings`, `HomeTabsSettingsScreen` |
| Rendering | `amethyst/…/ui/note/types/EntityRating.kt` + `NoteCompose` branch |
Tests: 44 new (27 in `quartz`, 7 ingest + 10 existing-suite in `amethyst`),
covering all four branches of the `stars()` ladder, the `"1"` ambiguity, the
`d`-prefix strip, `a`-over-`d` preference, addressable supersession in both
directions, and the `isNewThread()` tripwire that guards §4's decision. Full
suites green: quartz 4491, commons 1788, amethyst 1415.
`stars()` gained one branch the plan did not anticipate: an `s` tag outside
1..5 falls **through** to `rating` rather than winning, so a bogus `s` cannot
shadow a usable score.
### 11.5 Still open
§10's three questions are unanswered and the code takes the defaults:
1. Unknown marks render generically (stars + review + target link).
2. `RATINGS` is **default-on**, because `HomeFeedType.ALL` enables everything and
opting one entry out of that would be a special case. One line in
`HomeFeedType.ALL` changes it.
3. Publishing, aggregate rollups, and the 30041 reader remain unbuilt. The
`EntityRatingEvent.build()` DSL exists and is tested, so publishing is UI-only
work whenever a "rate this" entry point exists.
@@ -0,0 +1,330 @@
# Local search as `filter(Filter)` — retiring the bespoke `find*StartingWith` scans
_Status: **steps 16 shipped**; see §8 for what landed, what changed on contact with the code, and
what is deliberately left. The three decisions in §2 were taken as recommended._
## 0. The shape of the thing
`LocalCache` today answers two different kinds of question with two unrelated mechanisms:
- **Generic**, already built: `filter(filter: Filter): SortedSet<Note>`, plus `observeNotes`,
`observeEvents`, `observeNewEvents` and an indexed observable registry. Every feed filter in the
app goes through it.
- **Bespoke**, five hand-written scans in `CacheSearch`: `findNotesStartingWith`,
`findUsersStartingWith`, and three channel variants.
The generic path cannot answer a search because **`FilterMatcher.match` has no `search`
parameter**. It takes `ids, authors, kinds, tags, tagsAll, since, until` and nothing else, so a
`Filter` carrying only NIP-50 `search` matches *every* event. That one hole is the entire reason
`CacheSearch` exists as a separate mechanism.
Quartz already owns the missing half: `SearchableEvent.indexableContent()` (126 classes),
`SearchQuery.parse` / `stripExtensions` for the wire grammar, and two reference consumers — the
SQLite store (FTS5, bm25 then `created_at`) and the filesystem store (`FsSearchTokenizer`).
So this is not "abandon the model". It is: close one hole in the matcher, move viewer policy out
of the matcher where it never belonged, and delete five scans.
**The payoff is bigger than the tidy-up.** Once local search is `filter(Filter)`, the search field
feeds the *same* `SearchFilterBuilder.build(query)` output to both the relay REQ and the local
cache. `from:`, `to:`, `since:`, `#t`, `group:` then work identically local and remote. Today they
work only on desktop and only against relays; on Android the token chips are cosmetic, because
`SearchBarViewModel` puts the raw text (tokens and all) into the NIP-50 `search` string. This
refactor and that bug are one fix.
## 1. What must not change
`CacheSearch` does five things a `Filter` cannot express. Three are real requirements; two are
misplacements.
Requirements:
- **Bech32 / hex id entry.** `decodeEventIdAsHexOrNull(text)` → resolve the note, preferring the
addressable note when it is the same event. This is a *lookup*, not a search, and stays as a
pre-step that builds `Filter(ids = …)`.
- **Viewer policy.** `isHiddenFor(hiddenUsers)`, `excludeNoteEventFromSearchResults(note)`, and
skipping `isContentEncoded()` events.
- **Tag-value matching** with an excluded-tag-name list (`excludedTagNamesFromSearch`).
Misplacements — these belong outside the filter:
- Viewer policy is not a wire concept. A relay has no idea what you have muted. It becomes a
composed predicate, not a filter field (§3.2).
- Relevance ordering has nowhere to live today: `filter()` returns
`toSortedSet(CreatedAtIdHexComparator)` — recency only, where the SQLite store ranks bm25 first.
## 2. Open decisions
### 2.1 What "matches" means locally
`findNotesStartingWith` does `content.contains(text, true)`**substring**, despite the name. The
stores tokenize. `"itcoin"` finds "bitcoin" today and would not under a tokenizer.
| | Substring (today) | Token (the stores) |
| --- | --- | --- |
| Behaviour preserved | yes | no — mid-word queries stop matching |
| Matches what relays return | no | yes |
| Cost | one scan per field | tokenize per field, or an index |
| Reuse | none | `FsSearchTokenizer`, but it lives in `jvmMain` and would need promoting to `commonMain`/`jvmAndroid` |
Recommendation: **keep substring for v1** and treat tokenization as a separate, later change with
its own before/after. Local and remote results already differ (one is FTS5+bm25, the other a linear
scan); pretending otherwise is not worth a behavioural regression in the same PR that moves the
plumbing. Revisit when local search gets a real index.
### 2.2 Where viewer policy is applied
Recommendation: `LocalCache.filter(filter: Filter, predicate: (Note) -> Boolean = { true })`, with
the mute/kind/encoding rules extracted from `CacheSearch` into a reusable
`NoteVisibility(hiddenUsers)` predicate. Every feed benefits, not just search, and the wire type
stays a wire type.
### 2.3 Relevance ordering
`SearchSortOrder.RELEVANCE` exists app-side (`SearchResultSorter`). Recommendation: leave ranking
above `filter()`, keep `filter()` recency-ordered, and document that local ordering is *not*
bm25 parity. If relevance parity is wanted later it needs a score out of the matcher, which the
`Boolean` return cannot carry — a reason not to paint ourselves into `Boolean` (§4.3).
## 3. Does touching `FilterMatcher` interfere with, or slow down, current callers?
Two separate questions. The speed answer is "no, if done right". The correctness answer is "yes,
and that is the risk".
### 3.1 Correctness — the actual hazard
`Filter.match` has wide reach: 33 feed filters under `amethyst/.../dal/`, `FilterIndex`, and
geode's `MirrorWorker`. Today any filter carrying `search` matches on its NIP-01 fields alone.
The moment the matcher honours `search`, every one of those newly narrows.
Most never set `search`. But "most" is not "none", and a relay mirroring less than it used to is a
bad way to find out.
**Mitigation — do not flip the default first.** Land the search capability as an opt-in the
LocalCache search path composes explicitly (a `SearchMatcher`, or a `matchIncludingSearch`
entry point). Audit every construction site of a `Filter` that reaches `match`, confirm which
carry `search`, then make it the default in a second, separate commit that can be reverted alone.
### 3.2 Speed — the added check is free, but the loop around it is not
A trailing `if (search != null && !matchesSearch(event, search)) return false` costs **one
reference comparison** for every existing caller. Checked last, after the cheap field rejects, it
is not measurable. That part is safe.
The problem is what a search scan *reveals*. `FilterMatcher.match` allocates inside the per-event
loop today:
```kotlin
tags?.forEach { tag ->
val valueSet = tag.value.toSet() // a Set per event, per tag key
if (!event.tags.any { }) return false // stdlib any → an iterator per event
}
tagsAll?.forEach { tag ->
val eventTagValueSet =
event.tags.mapNotNullTo(mutableSetOf()) { } // a MutableSet + full tag scan per event
}
```
These are pre-existing and tolerable at feed-rebuild rates. They are not tolerable on a
full-cache scan per keystroke — and `SearchFilterBuilder` makes them worse, because
`ScopeIds.tagValues` emits up to four spellings per hashtag, so `toSet()` runs on a 4-element list
per note.
`CLAUDE.md`'s hot-path rule already says what to use: `fastAny` / `fastForEach` from
`nip01Core/core/TagArray.kt`, not the stdlib collection operators.
**The structural fix — hoist per-filter work out of the per-event loop.** Everything derived from
the `Filter` (the value sets, the tokenized or `DualCase`-folded search terms) is loop-invariant
and is currently recomputed per event. A prepared matcher built once per query and reused across
the scan removes all of it:
```kotlin
class PreparedFilter(filter: Filter) { // built once per query
private val tagValueSets = // Sets built once, not per event
private val terms = // DualCase terms folded once
fun match(event: Event): Boolean // allocation-free
}
```
This is a win for every existing caller, not just search — feed rebuilds pay the same per-event
allocations today. **Recommendation: land the prepared matcher first, as a pure no-behaviour-change
commit with the existing tests as the guard, before adding `search` at all.**
## 4. The indexable surface — do not build strings
### 4.1 The current cost
Of the 126 implementations:
| Shape | Count | Allocation per call |
| --- | --- | --- |
| `listOfNotNull(…).joinToString("\n")` | 86 | list + `StringBuilder` + joined `String` |
| `content` | 28 | none |
| JSON-parsing (kind 0, channels, stalls) | rest | **re-parses JSON every call** |
Kind 0 is the worst case and the most searched: `contactMetaData()` has no cache, so it runs
`JsonMapper.fromJson<UserMetadata>(content)` on every invocation. Calling `indexableContent()`
across the cache per keystroke would parse every profile, per keystroke.
For a write path — index once on insert — that cost is irrelevant, which is why it looks the way
it does. For a read path over the whole cache it is disqualifying. `indexableContent()` is the
right API for the stores and the wrong API for matching.
### 4.2 The precedent already in the tree
`UserMetadata.anyPropertyContains(terms: List<DualCase>)` is exactly the right shape, and already
exists for kind 0:
```kotlin
fun anyPropertyContains(terms: List<DualCase>): Boolean =
name?.containsAny(terms) == true ||
displayName?.containsAny(terms) == true ||
about?.containsAny(terms) == true ||
```
No list, no join, `||` short-circuits on the first hit, and `DualCase` folds each term's cases
**once** rather than per comparison. The proposal is to generalise this, not to invent something.
### 4.3 Proposed surface
```kotlin
fun interface IndexableFieldVisitor {
/** @return false to stop walking — a hit on the title need not touch the body. */
fun visit(field: String?): Boolean
}
interface SearchableEvent {
fun forEachIndexableField(visitor: IndexableFieldVisitor)
/** Kept for the stores. Derived, so its output stays byte-identical to today's. */
fun indexableContent(): String
}
```
An implementation carries no collection at all:
```kotlin
override fun forEachIndexableField(v: IndexableFieldVisitor) {
if (!v.visit(name())) return
if (!v.visit(description())) return
v.visit(content)
}
```
Notes on the shape:
- **`fun interface`, not a lambda parameter.** An interface method cannot be `inline`, so a lambda
at the call site would allocate. A `fun interface` instance is created **once per scan** and
reused for every event, carrying the loop-invariant query terms on itself. Zero allocation per
event, which is the whole point.
- **Nulls pass through**, so implementors never need `listOfNotNull`.
- **`Boolean` return for short-circuit.** Caveat: it forecloses scoring (§2.3). If bm25-ish
relevance is ever wanted locally, this wants to be an accumulator instead. Worth deciding now,
cheaply, rather than re-touching 126 classes later.
- **Rejected alternative: `fun indexableFields(): List<String>`.** Simpler, but still allocates a
list per event and cannot short-circuit. Acceptable fallback if the visitor proves awkward for
some kind, but it is strictly worse.
### 4.4 The compatibility constraint
`indexableContent()` **must survive with byte-identical output**. The SQLite and filesystem stores
index through it, and `references/searchable-kinds.md` (the `searchable-events` skill) is mirrored
by external engines — the Vespa-backed store's `SearchExtractors` tracks that table at pin bumps.
A changed body means stale results downstream and a `reindexFullTextSearch()` for existing
databases.
If the default `indexableContent()` is *derived* from `forEachIndexableField` with the same field
order and separator, output is identical, the table stays valid, and no reindex is needed. Watch
the separator: most kinds join with `"\n"`, a handful of metadata-ish kinds with `" "`, so the
derived default needs the separator as a per-kind property rather than a hard-coded `"\n"`.
Per that skill's mandatory-maintenance rule: any change here updates
`references/searchable-kinds.md` in the same PR.
### 4.5 JSON kinds still need a cache
Even with the visitor, kind 0 re-parses on every event visited. Either cache the parsed
`UserMetadata` on the event, or route kind-0 matching through the existing
`UserMetadata.anyPropertyContains` against `LocalCache`'s already-parsed `User.metadataOrNull()`
— which is what `findUsersStartingWith` effectively does today, and is the cheaper answer.
## 5. Sequencing
Each step is independently revertible; none but the last changes user-visible behaviour.
| # | Step | Behaviour change |
| --- | --- | --- |
| 1 | `PreparedFilter` — hoist per-filter work, swap stdlib ops for `fast*` (§3.2) | none |
| 2 | `forEachIndexableField` + derived `indexableContent()`; update the kinds table (§4) | none |
| 3 | Search matching as an opt-in entry point, substring semantics (§2.1) | none — nothing calls it yet |
| 4 | `filter(filter, predicate)` + `NoteVisibility` extracted from `CacheSearch` (§2.2) | none |
| 5 | `find*StartingWith` → filter builders; delete the bespoke scans | parity-tested |
| 6 | `SearchBarViewModel` feeds `SearchFilterBuilder` output to *both* relay and cache | **the Android fix** |
| 7 | Audit `search`-carrying filters, then make the matcher honour it by default (§3.1) | audited |
## 6. Parity harness
`desktopApp/src/jvmTest/.../cache/FindUsersTest.kt` already covers `findUsersStartingWith` in 8
tests and is a ready-made before/after gate. There is no equivalent for
`findNotesStartingWith`; write one against the current implementation **before** step 5, so the
rewrite is measured against recorded behaviour rather than against intent.
Call sites to migrate: `SearchBarViewModel`, `UserSuggestionState`, `UserSearchEngine`,
`BuzzNewDmViewModel`, `AgentAttestationScreen`, `ICacheProvider`, `SearchBarState`, and desktop's
`DesktopLocalCache`, `ChatPane`, `ComposeNoteDialog`.
## 7. Related
- `event-store-semantics` skill — the store contract the local matcher should not contradict;
query-side rules are STORE-S01…S06. Read it before fixing the semantics in step 3.
- `searchable-events` skill — the indexing surface, the authoritative kind table, and the
mandatory-maintenance rule for §4.4.
- `2026-09-07` search-field work (`SearchTokenizer` / `SearchFilterBuilder` in `commons/search/`)
— produces the `Filter`s step 6 consumes.
## 8. What actually shipped
Steps 16 landed. Two things changed on contact with the code, both for the better:
**Step 7 is moot, and the blast radius never opens.** The plan assumed local search needed
`FilterMatcher` to honour `search`, which would have narrowed every filter carrying one — 33 feed
filters, `FilterIndex`, geode's `MirrorWorker` — and needed an audit before flipping. It does not.
`LocalCache.filter` grew a **predicate** parameter instead, and search composes an
`EventSearchMatcher` into it. `FilterMatcher` is untouched, so nothing else can change behaviour.
The predicate is also where viewer policy went (§2.2), so one parameter answers both.
**Step 1 turned out to be a pure win with no search in it.** `FilterMatcher` was allocating three
ways per event; fixing that needed no new API and pays for all 33 existing callers. It is pinned by
a differential test that keeps the old implementation as an oracle and fuzzes 20,000 random
event/filter pairs against it.
**Step 2 is scoped, not universal.** `forEachIndexableField` has a default that falls back to
`indexableContent()` — already free for the ~28 kinds whose indexable content is `content` itself —
and only the kinds local search actually scans override it (text notes, long-form, wiki,
highlights, classifieds, live activities, community definitions). The remaining ~79 joining
implementations still allocate on the read path, which costs nothing until something scans them.
A test pins every override's fields to rejoin to `indexableContent()` byte-for-byte, so the
externally-mirrored kind table stays valid and no reindex is needed.
### Deliberately not done
- **`findUsersStartingWith` and the three channel finders stay.** They are name-prefix lookups over
users and channels, not event filters; forcing them through a `Filter` would be worse, not
better. The plan's framing ("retire the bespoke scans") was too broad — only the *note* search is
filter-shaped.
- **`findNotesStartingWith` stays, on one narrow path.** It matches `idHex.startsWith(text)` and
resolves bech32 pointers, neither of which is content and so neither reachable through a
filter's `search`. Text that could name an event — a bech32 pointer, or ≥8 hex characters — is
routed to it; everything else goes through the filters. The first cut fell back to it whenever
the filter path came up empty, which made every zero-result keystroke scan the cache twice.
- **No characterization test for `findNotesStartingWith`.** The parity harness §6 asked for was not
written; the id path it guards is now the only thing still using that scan, and it is unchanged.
Worth writing if that path is ever touched.
### Semantics that changed
Local text matching is now **terms ANDed**, each a case-insensitive substring, where it was one
literal phrase. A single-word query — the common case — is identical; `bitcoin lightning` now finds
notes carrying both words rather than only that exact phrase, which is what the relay already
returned for the same string. Ordering stays recency (§2.3): no local relevance score exists, and
`EventSearchMatcher` answers only yes or no.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
# DVM heartbeat liveness — only show DVMs with a fresh kind-11998 heartbeat
_Status: **implemented** (Android; desktop wiring deliberately out of scope — §8)._
## 0. The shape of the thing
Amethyst shows Data Vending Machines (DVMs) in three places: the Discover "Content" tab
(kind 31990 NIP-89 announcements advertising kind 5300), DVM feeds pinned to the top-nav
(`FavoriteAlgoFeedsOrchestrator`), and the per-DVM content-discovery screen. Today all of
these treat every announced DVM as alive, forever — a DVM that went down months ago still
renders as a usable feed.
DVM operators are now sending a **heartbeat event (kind 11998) every 300 seconds**. The
event is plain-text (`content = "Alive and kicking"`) with three tags:
- `status` — free-text status line (e.g. "My heart keeps beating like a hammer")
- `d` — the DVM's **NIP-89 DTAG**, tying the heartbeat to the announcement's address
- `expiration``createdAt + 300` (NIP-40), so relays drop the beat once the next one lands
Kind 11998 sits in the replaceable range (1000019999), so relays keep only the latest beat
per author. There is no NIP for this yet — the shape above comes from the operator-side
builder and is treated as the wire contract.
The feature: **a DVM counts as alive only if its latest heartbeat is at most 900 seconds
old** (one missed 300s beat plus slack). Dead DVMs disappear from the Discover list; pinned
feeds and the detail surface show an offline state instead.
## 1. Decisions taken
1. **Approach: cache-backed heartbeats.** The heartbeat is a real event class stored through
the standard replaceable path in `LocalCache` (newest per address, standard invalidation).
Rejected alternatives: a side-state registry (duplicates invalidation plumbing) and
regular-note storage (no address matching, pollutes the notes index).
2. **Scope: all three surfaces** — Discover list (hide), pinned feeds (offline state, chip
stays), DVM detail screen (offline banner, requesting still allowed). The manage screen
(`FavoriteAlgoFeedsListScreen`) also gets the badge.
3. **Pinned chips stay when offline** — the user pinned them deliberately; they gray out
with an offline badge rather than vanishing, and tapping still opens the feed.
4. **Threshold: 900 seconds** (raised from the original 420 after field testing: beats arrive every 300s, and a 420s window tolerated barely one delivery hiccup, dropping live DVMs in oscillations). Exactly 900s old counts as fresh.
5. **Strict from cold start.** No grace period: the Discover list starts empty and fills
within ~12s as heartbeat REQs return (same behavior as the existing 31990 load).
## 2. Event model (quartz)
New `quartz/.../nip90Dvms/dvmHeartbeat/DvmHeartbeatEvent.kt`:
- `class DvmHeartbeatEvent(...) : BaseAddressableEvent(...)`, `KIND = 11998` — the codebase
convention for 10xxx events with real `d` tags (e.g. `FollowListEvent`), so `dTag()` /
`address()` / `addressTag()` come from the base. The cache address is
`Address(11998, dvmPubkey, dTag)`, the exact mirror of the announcement's
`Address(31990, dvmPubkey, dTag)`.
- Accessors: `status()`, and `expiration()` via the existing NIP-40 extension.
- `MAX_AGE_SECONDS = 900` and `isFreshAt(now)` live in quartz too (commons imports them).
- Registered in `EventFactory` (kind → constructor) and allowlisted in
`EventFactoryKindRangeTest.knownDTagReaders`: the `d` tag keys the client-side address
while relay storage stays plain-replaceable per the kind range.
## 3. Cache consumption (LocalCache)
One routing line in `LocalCache.justConsumeInnerInner`: `is DvmHeartbeatEvent ->`
`consumeBaseReplaceable(event, relay, wasVerified)`. This yields newest-per-address
replacement, relay tracking, and `LocalCacheFlow` invalidation for free. Unlisted kinds fall
into the `else` branch and are rejected, so the routing line is mandatory.
Stale beats simply sit at their address until overwritten; the age check (§4) makes them
invisible. The cache pruner removes old entries on its own schedule.
## 4. Freshness core (amethyst)
Small helper file in `amethyst/.../model/` (the threshold constant itself lives in quartz):
- `LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent?` — address
lookup `Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())`
- `DvmHeartbeatEvent.isFreshAt(now: Long): Boolean``createdAt >= now - 900`
- `@Composable fun rememberDvmHeartbeatFresh(address: Address, accountViewModel: AccountViewModel): State<Boolean>`
as built (uniform-strict ruling): returns true while the DVM has a heartbeat at most 900s
old; an unresolved/absent beat counts as offline (`false`) on every surface. The returned
`State` identity is stable for the lifetime of the call site (one unconditional
`rememberUpdatedState`), so callers may capture it across recompositions. Composable-scoped
subscription (§5) + staleness re-check tick (§6), shared by every surface that renders
liveness.
## 5. Subscriptions
**Discover screen — all DVM heartbeats.** In
`commons/.../relayClient/discover/nip90DVMs/SubAssemblyHelper.kt`, `makeContentDVMsFilter`
unconditionally appends one filter for every top-filter variant:
`kinds = [11998], since = TimeUtils.now() - 900` — no authors, no tags, scoped to the same
relay set as the 31990 REQs. It deliberately ignores the 31990 `since`-cursor (heartbeats
are a rolling window, not a cursor stream — the cursor would miss re-opened tabs after the
beats expired). It rides the existing assembler lifecycle: subscribes on entering Discover,
closes on leaving.
**Per-surface — pinned chips, home banner, detail screen.** `rememberDvmHeartbeat` opens a
tiny composable-scoped subscription: `kinds = [11998], authors = [dvm pubkey], limit = 1,
since = now - 900`. The home top-bar chips live for the whole session, so they double as
the session-scoped watcher for pinned DVMs. Traffic is negligible (a few pinned DVMs ×
1 event / 5 min).
**Outbox fetcher (added after field testing).** The global REQ above only sees beats that
reach the *user's* discovery relays — but DVMs publish beats to their own write relays, and
relays don't gossip, so alive DVMs whose beats never overlap the user's relay set stayed
invisible (their detail screens proved the beats existed on the outbox). `DiscoveryDvmHeartbeatSubAssembler`
joins the discovery assembler group and, while Discover is composed, batches the cached
content-discovery announcements' authors per **DVM outbox relay** (`kinds = [11998],
authors = [those pubkeys], since = now - 900`, coverage-ranked and capped at 12 relays;
authors with unknown outboxes/hints rely on the global REQ as fallback). It re-issues when
the cached announcement set or the NIP-65 relay lists move.
The announcement source MUST be the **ungated cache scan**
(`LocalCache.cachedDvmAnnouncements` — every cached k=5300 announcement, newest first, capped
at 100), not the gated feed list. Sourcing from the gated list is a death spiral: a DVM
leaves the gated list the moment its beat ages out, the fetcher would stop covering it, and
no beat would ever arrive to bring it back — any transient staleness becomes a permanent
drop. The relay lookup unions the author's NIP-65 outbox with the cached relay hints for the
author (the same mix the event finder's `potentialRelaysToFindAddress` uses).
## 6. Invalidation — closing the two silent gaps
1. **A new heartbeat does not re-rank the list.** The additive feed path
(`FeedContentState.updateFeedWith`) re-filters only the *new* notes, and a heartbeat
note is never a list row — the affected 31990 card would not be re-evaluated. Fix: in
`AccountFeedContentStates.updateFeedsWith`, branch on
`newNotes.any { it.event is DvmHeartbeatEvent }``discoverDVMs.invalidateData()`
(full rebuild re-runs the freshness check on every announcement); otherwise the normal
additive path.
2. **Expiry produces no event.** A 60s timer collector in `AccountFeedContentStates`
(alongside the existing `scope.launch { flows.collect { … } }` observers) calls
`discoverDVMs.invalidateData()` every minute. The rebuild is a cheap scan (≤ a few
hundred 31990s) and `refreshSuspended()` no-ops when the list is unchanged. Composables
using `rememberDvmHeartbeatFresh` tick on a 30s cadence internally.
## 7. UI surfaces
1. **Discover "Content" tab**`DiscoverNIP89FeedFilter.acceptApp` adds
`dvmHeartbeatOf(noteEvent)?.isFreshAt(now) == true`. No fresh beat → card hidden.
2. **Pinned top-nav chips** — chip stays; when the heartbeat is stale or absent the chip is
grayed out with a small offline dot appended to its label. Tapping still opens the feed.
3. **Pinned feed view** — new branch in `HomeAlgoFeedStatusBanner`: when the selected
pinned feed's heartbeat is stale, show an offline banner above the last known content,
shown *even when content exists* (the current banner only handles empty/error states).
Single-feed and all-feeds variants both covered.
4. **DVM detail screen** (`DvmContentDiscoveryScreen`) — same offline banner; requesting is
still allowed (informational, not a block).
5. **`FavoriteAlgoFeedsListScreen`** — offline badge per row so users can spot dead pins.
6. New English string resources (`dvm_offline`, `dvm_offline_banner`); translations flow
via Crowdin.
## 8. Edge cases (accepted limitations)
- Heartbeat without a `d` tag → cache address dTag `""` → matches nothing → DVM hidden
(strict; the wire contract always sends `d`).
- Device/DVM clock skew > 15 min → wrongly hidden (inherent to timestamp-based liveness).
- DVM beats that never reach the relays we query → shows offline (that is the feature).
- One keypair running multiple DVMs → relays keep only the latest beat per (kind, author);
per-d-tag cache slots help only across relays. Most DVMs use one key each.
- **Desktop app: out of scope this round.** The commons subscription helper is shared-ready
and the desktop relay assembler will pick up heartbeat REQs harmlessly (cache fills,
nothing renders), but all UI wiring is Android-only.
## 9. Testing
- **quartz**: parse/build `DvmHeartbeatEvent``dTag()` override, `statusTag()`,
`expiration()`, address assembly.
- **amethyst**: `DiscoverNIP89FeedFilter.acceptApp` matrix — no beat → reject; fresh beat →
accept; 421s-old beat → reject. The `updateFeedsWith` heartbeat branch triggers a full
rebuild. `isFreshAt` boundary (900s fresh, 901s stale).
- Verify with `./gradlew :quartz:test :amethyst:test`, then `./gradlew spotlessApply`.
+1
View File
@@ -5,6 +5,7 @@ _Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0
## In progress
| Plan | Summary |
| ---- | ------- |
| [2026-09-07-generic-local-filter.md](2026-09-07-generic-local-filter.md) | Local note search moved onto `LocalCache.filter(Filter)` so the search box's tokens narrow local and relay results alike; steps 16 shipped, `FilterMatcher` left untouched via a predicate parameter. |
| [2026-05-24-ios-support.md](2026-05-24-ios-support.md) | Incremental KMP-to-iOS port; quartz/commons iOS targets are configured (Phase 1) but no `iosApp` module exists yet. |
## Queued
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.okhttp.EncryptedBlobInterceptor
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.commons.service.http.EncryptedBlobInterceptor
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import okhttp3.OkHttpClient
@@ -25,10 +25,10 @@ import android.graphics.Color
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -21,14 +21,14 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal.NotificationFeedFilter
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -0,0 +1,137 @@
/*
* 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 android.graphics.Bitmap
import android.os.Build
import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.ImageLoader
import coil3.annotation.InternalCoilApi
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.decode.StaticImageDecoder
import coil3.decode.toImageDecoderSourceOrNull
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.commons.service.image.DeferredDeleteFileSystem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toOkioPath
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Pins the platform-side half of [SystemFileSystemFetcher], which the JVM unit tests cannot
* reach: `android.graphics.ImageDecoder` is what Coil's identity check gates access to, so only
* a device can show that the check really does fail on our disk cache's file system and really
* does pass once the source is re-homed.
*
* If these ever start passing without the re-home, Coil has loosened the check and
* [SystemFileSystemFetcher] can go.
*/
@RunWith(AndroidJUnit4::class)
class SystemFileSystemImageDecoderInstrumentedTest {
private lateinit var pngFile: File
private lateinit var path: Path
private lateinit var scope: CoroutineScope
private lateinit var deferredDelete: DeferredDeleteFileSystem
private val imageLoader by lazy { ImageLoader.Builder(appContext).build() }
private val options by lazy { Options(appContext) }
@Before
fun setUp() {
pngFile = File(appContext.cacheDir.also { it.mkdirs() }, "${UUID.randomUUID()}.png")
pngFile.outputStream().use { createBitmap(4, 4).compress(Bitmap.CompressFormat.PNG, 100, it) }
path = pngFile.toOkioPath()
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
deferredDelete = DeferredDeleteFileSystem(FileSystem.SYSTEM, scope)
}
@After
fun tearDown() {
scope.cancel()
pngFile.delete()
}
/** How Coil's `NetworkFetcher` builds the source when the disk cache wraps its file system. */
private fun diskCacheSource() = ImageSource(file = path, fileSystem = deferredDelete, diskCacheKey = KEY)
private fun reHomedSource() = requireNotNull(diskCacheSource().onSystemFileSystem(KEY))
@OptIn(InternalCoilApi::class)
@Test
fun ourDiskCacheFileSystemHidesTheFileFromImageDecoder() {
assertNull(
"still decode path",
diskCacheSource().toImageDecoderSourceOrNull(options, animated = false),
)
assertNull(
"animated decode path",
diskCacheSource().toImageDecoderSourceOrNull(options, animated = true),
)
}
@OptIn(InternalCoilApi::class)
@Test
fun theReHomedSourceReachesImageDecoder() {
assertNotNull(
"still decode path",
reHomedSource().toImageDecoderSourceOrNull(options, animated = false),
)
assertNotNull(
"animated decode path",
reHomedSource().toImageDecoderSourceOrNull(options, animated = true),
)
}
@Test
fun staticImageDecoderDeclinesOurDiskCacheSourceAndAcceptsTheReHomedOne() {
assumeTrue("StaticImageDecoder requires API 29+", Build.VERSION.SDK_INT >= 29)
// Declining is why every still image in the app was decoding through BitmapFactoryDecoder.
assertNull(
StaticImageDecoder.Factory().create(fetchResult(diskCacheSource()), options, imageLoader),
)
assertNotNull(
StaticImageDecoder.Factory().create(fetchResult(reHomedSource()), options, imageLoader),
)
}
private fun fetchResult(source: ImageSource) = SourceFetchResult(source, "image/png", DataSource.DISK)
companion object {
private const val KEY = "https://example.com/blob.png"
}
}
@@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
@@ -62,7 +62,8 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class PlaybackErrorOverlayFitTest {
@get:Rule val rule = createComposeRule()
@get:Rule
val rule = createComposeRule()
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
@@ -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
@@ -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)
}
}
@@ -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() }
}
@@ -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
}
}
@@ -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)
}
}
@@ -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.
}
}
}
@@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
@@ -50,7 +50,8 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class AudioPlayerBoxOverflowTest {
@get:Rule val rule = createComposeRule()
@get:Rule
val rule = createComposeRule()
private class Bounds {
var top = 0f
@@ -0,0 +1,192 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.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.v2.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,
)
}
}
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.core.tween
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The feed's animated elements defer building their `Transition` until a value actually changes,
* because first composition has nothing to animate and building one per card per scroll is pure
* waste (measured: roughly half the composition cost of every reaction-row button).
*
* The whole point of deferring rather than removing is that the animation must still play. These
* tests pin that: they drive the clock manually and assert that the **first** change — the one that
* happens right after the transition is lazily created — still shows outgoing and incoming content
* simultaneously, which only a running animation does. A regression that turned the deferral into a
* plain snap would show exactly one of them and fail here.
*/
@RunWith(AndroidJUnit4::class)
class DeferredAnimationTest {
@get:Rule
val rule = createComposeRule()
@Test
fun deferredCrossfadeStillAnimatesTheFirstChange() {
val state = mutableStateOf("A")
rule.mainClock.autoAdvance = false
rule.setContent {
DeferredCrossfade(
targetState = state.value,
modifier = Modifier,
contentAlignment = Alignment.TopStart,
animationSpec = tween(DURATION_MS),
label = "test",
) { value ->
Text(value, modifier = Modifier.testTag("text_$value"))
}
}
// Before any change the transition has not been built, and only the current value renders.
rule.onNodeWithTag("text_A").assertIsDisplayed()
rule.onNodeWithTag("text_B").assertDoesNotExist()
state.value = "B"
rule.mainClock.advanceTimeByFrame()
rule.mainClock.advanceTimeBy(DURATION_MS / 3L)
// Mid-crossfade both are in the tree. This is the assertion that a snap would fail.
rule.onNodeWithTag("text_A").assertExists()
rule.onNodeWithTag("text_B").assertExists()
rule.mainClock.advanceTimeBy(DURATION_MS * 3L)
rule.onNodeWithTag("text_B").assertIsDisplayed()
rule.onNodeWithTag("text_A").assertDoesNotExist()
}
companion object {
const val DURATION_MS = 300
}
}
@@ -1,112 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.service.okhttp.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
@Test
fun testFiatjafSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = { listOf(fiatjaf) },
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
val newClient = NostrClient(socketBuilder, appScope)
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
val signer = NostrSignerInternal(KeyPair())
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { _, authTemplate, _ ->
listOf(signer.sign(authTemplate))
},
)
newClient
},
scope = appScope,
)
sync.runSync()
}
}
@@ -20,9 +20,16 @@
*/
package com.vitorpamplona.amethyst.service.ai
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistantStatus
import com.vitorpamplona.amethyst.commons.service.ai.WritingResult
import com.vitorpamplona.amethyst.commons.service.ai.WritingTone
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,
@@ -21,8 +21,12 @@
package com.vitorpamplona.amethyst.service.ai
import android.content.Context
import com.vitorpamplona.amethyst.commons.service.ai.WritingAssistant
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()
}
@@ -18,9 +18,15 @@
* 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
package com.vitorpamplona.amethyst.ui.components
// Re-export from commons for backwards compatibility
typealias User = com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
typealias UserContext = com.vitorpamplona.amethyst.commons.model.UserContext
/**
* No translation service in this flavor, so no note is ever translated and the copy-text
* menus never need to offer a "Copy Translated" option.
*/
fun cachedTranslation(
content: String,
accountViewModel: AccountViewModel,
): String? = null
@@ -54,9 +54,19 @@ import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults
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.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.resources.push_server_explainer
import com.vitorpamplona.amethyst.commons.resources.push_server_install_app
import com.vitorpamplona.amethyst.commons.resources.push_server_install_app_description
import com.vitorpamplona.amethyst.commons.resources.push_server_none
import com.vitorpamplona.amethyst.commons.resources.push_server_none_explainer
import com.vitorpamplona.amethyst.commons.resources.push_server_title
import com.vitorpamplona.amethyst.commons.resources.push_server_uses_app_explainer
import com.vitorpamplona.amethyst.commons.resources.quick_action_dont_show_again_button
import com.vitorpamplona.amethyst.commons.resources.select_push_server
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsBlockTile
@@ -100,7 +110,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
if (readableListWithExplainer.size > 1) {
SpinnerSelectionDialog(
title = stringRes(id = R.string.select_push_server),
title = stringRes(id = Res.string.select_push_server),
options = readableListWithExplainer,
onSelect = { index ->
if (list[index] == "None") {
@@ -122,9 +132,9 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
} else {
AlertDialog(
onDismissRequest = { distributorPresent = true },
title = { Text(stringRes(R.string.push_server_install_app)) },
title = { Text(stringRes(Res.string.push_server_install_app)) },
text = {
val content = stringRes(R.string.push_server_install_app_description)
val content = stringRes(Res.string.push_server_install_app_description)
val astNode =
remember {
@@ -149,7 +159,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
sharedPrefs.dontShowPushNotificationSelector()
},
) {
Text(stringRes(R.string.quick_action_dont_show_again_button))
Text(stringRes(Res.string.quick_action_dont_show_again_button))
}
Button(
onClick = { distributorPresent = true },
@@ -163,7 +173,7 @@ fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(stringRes(R.string.error_dialog_button_ok))
Text(stringRes(Res.string.error_dialog_button_ok))
}
}
}
@@ -191,12 +201,12 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
.mapIndexed { index, name ->
TitleExplainer(
name,
stringRes(id = R.string.push_server_uses_app_explainer, list[index]),
stringRes(id = Res.string.push_server_uses_app_explainer, list[index]),
)
}.plus(
TitleExplainer(
stringRes(id = R.string.push_server_none),
stringRes(id = R.string.push_server_none_explainer),
stringRes(id = Res.string.push_server_none),
stringRes(id = Res.string.push_server_none_explainer),
),
).toImmutableList()
@@ -217,8 +227,8 @@ fun PushNotificationProviderTile(sharedPrefs: UiSettingsFlow) {
val selectedIndex = list.indexOf(currentDistributor).coerceAtLeast(0)
SettingsBlockTile(
icon = MaterialSymbols.CloudSync,
title = stringRes(R.string.push_server_title),
description = stringRes(R.string.push_server_explainer),
title = stringRes(Res.string.push_server_title),
description = stringRes(Res.string.push_server_explainer),
) {
TextSpinner(
label = null,
+99 -14
View File
@@ -16,6 +16,69 @@
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- NIP-A3 payment targets. Android 11+ package visibility means
queryIntentActivities returns NOTHING for a scheme not declared here,
so without these the zap picker's pay-to chip is invisible on every
modern device. Specific <intent> filters rather than
QUERY_ALL_PACKAGES, which is policy-restricted on Play.
Unknown target types all fall back to payto://<type>/<authority>,
so the single payto entry covers the open-ended tail.
No <category>: a category here narrows visibility the same way it
narrows an intent match, and would hide any app whose filter declares
only DEFAULT - which is what our ACTION_VIEW hand-off actually uses. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="payto" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="bitcoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="lightning" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="liquidnetwork" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="ethereum" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="monero" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="dash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="zcash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="bitcoincash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="litecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="dogecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="solana" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tron" />
</intent>
</queries>
@@ -52,6 +115,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" />
@@ -115,7 +179,7 @@
android:exported="true"
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|navigation|uiMode|fontScale|density"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.Amethyst">
@@ -124,11 +188,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Health Connect privacy-policy rationale (required by Google when reading health data) -->
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -382,15 +441,28 @@
</intent-filter>
</activity-alias>
<!-- Health Connect privacy-policy rationale on Android 14+. Without this activity-alias
the permission request fails silently (no dialog appears). The system launches it,
guarded by START_VIEW_PERMISSION_USAGE, to show our privacy policy; it routes into
MainActivity. Android 13 and lower use the ACTION_SHOW_PERMISSIONS_RATIONALE
intent-filter declared on MainActivity above. -->
<!-- Health Connect permissions rationale. Both entry points land on the same screen,
which explains what Amethyst reads from Health Connect and why; it needs no account,
so it works even when launched cold from Health Connect itself.
Android 13 and lower open it with ACTION_SHOW_PERMISSIONS_RATIONALE; without a
target for it the permission request fails silently (no dialog appears). -->
<activity
android:name=".ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity"
android:exported="true"
android:label="@string/health_connect_rationale_activity_label"
android:excludeFromRecents="true"
android:theme="@style/Theme.Amethyst">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<!-- Android 14+ route to the same rationale screen. The system launches it, guarded by
START_VIEW_PERMISSION_USAGE, from the Health Connect data-management screens. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".ui.MainActivity"
android:targetActivity=".ui.screen.loggedIn.workouts.health.HealthConnectRationaleActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
@@ -453,7 +525,7 @@
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone|camera|phoneCall"
android:foregroundServiceType="microphone|camera|phoneCall|mediaProjection"
android:stopWithTask="false"
android:exported="false" />
@@ -559,7 +631,9 @@
<!-- Direct-WebView browser for a single web client. Runs in the isolated, keyless `:napplet`
process and hosts the WebView directly (not a streamed surface), so scroll/zoom/keyboard work
natively. adjustResize shrinks the window for the soft keyboard. Its own task/recents entry. -->
natively. The activity insets its own content for the soft keyboard (adjustResize only still
applies below Android 15, where the window is not forced edge-to-edge). Its own task/recents
entry. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
android:process=":napplet"
@@ -577,6 +651,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"
+71
View File
@@ -0,0 +1,71 @@
# Baseline profile — AOT-compile the relay ingest path.
#
# Why this file exists: a symbol profile of a release build during cold-start
# ingest (SM-T220, speed-profile AOT, simpleperf --app) found 42.9% of the
# DefaultDispatcher workers' CPU in ART's nterp interpreter and only 10.5% in
# compiled app code — 36x more CPU interpreting bytecode than verifying
# signatures (secp256k1 was 1.2%).
#
# The cause was profile coverage: the shipped assets/dexopt/baseline.prof was
# 11,425 bytes containing ZERO references to com/vitorpamplona — it was only
# the androidx/Compose profiles AGP merges in automatically. None of the relay
# client, decoder, LocalCache or Jackson paths were AOT-compiled on a fresh
# install, so they ran interpreted through exactly the burst that matters.
#
# Rules are wildcarded per package rather than per method because the ingest
# burst touches a wide surface (every event kind has its own consume path), and
# because a hand-maintained method list would rot. AGP applies the R8 mapping,
# so these are written against original names.
#
# Ideally this is GENERATED by androidx.baselineprofile from a macrobenchmark
# that cold-starts and ingests. That needs a rooted device or an AOSP emulator
# image; neither test device here is rootable (both are `user` builds), so this
# is hand-authored in the meantime. Replacing it with a generated profile is
# strictly better — it would carry call counts and startup/post-startup flags
# that reflect real behaviour instead of whole-package guesses.
#
# Flags: HP = hot + post-startup, deliberately WITHOUT S (startup).
#
# S drives DEX layout: startup-flagged classes are grouped into classes.dex for
# locality, and Android's docs warn that if startup code does not fit there it
# "will overflow into the next DEX files". These are whole-package wildcards for
# ingest, which runs AFTER startup — flagging them S would claim thousands of
# methods are startup-critical and could push genuinely startup-critical code out
# of the first DEX, hurting the thing it is meant to help. For comparison, the
# generated profile marks 32 of its 31,497 rules HSPL; this file should not claim
# more than that about startup. Startup layout is left to the generated profile.
# --- Quartz: protocol core, relay client, crypto, event kinds ---
HPLcom/vitorpamplona/quartz/nip01Core/**->**(**)**
HPLcom/vitorpamplona/quartz/nip10Notes/**->**(**)**
HPLcom/vitorpamplona/quartz/nip19Bech32/**->**(**)**
HPLcom/vitorpamplona/quartz/nip17Dm/**->**(**)**
HPLcom/vitorpamplona/quartz/nip22Comments/**->**(**)**
HPLcom/vitorpamplona/quartz/nip25Reactions/**->**(**)**
HPLcom/vitorpamplona/quartz/nip18Reposts/**->**(**)**
HPLcom/vitorpamplona/quartz/nip57Zaps/**->**(**)**
HPLcom/vitorpamplona/quartz/nip65RelayList/**->**(**)**
HPLcom/vitorpamplona/quartz/utils/**->**(**)**
HPLcom/vitorpamplona/quartz/experimental/**->**(**)**
# --- Amethyst: the in-memory store and the relay wiring around it ---
HPLcom/vitorpamplona/amethyst/model/**->**(**)**
HPLcom/vitorpamplona/amethyst/service/relayClient/**->**(**)**
HPLcom/vitorpamplona/amethyst/service/okhttp/**->**(**)**
HPLcom/vitorpamplona/amethyst/commons/model/**->**(**)**
HPLcom/vitorpamplona/amethyst/commons/richtext/**->**(**)**
# --- JSON: every frame is parsed through Jackson ---
HPLcom/fasterxml/jackson/core/**->**(**)**
HPLcom/fasterxml/jackson/databind/**->**(**)**
HPLcom/fasterxml/jackson/module/kotlin/**->**(**)**
# --- Transport: the socket read path under the relay client ---
HPLokhttp3/internal/ws/**->**(**)**
HPLokhttp3/internal/connection/**->**(**)**
HPLokio/**->**(**)**
# --- Coroutines: every ingested event crosses the dispatcher ---
HPLkotlinx/coroutines/scheduling/**->**(**)**
HPLkotlinx/coroutines/channels/**->**(**)**
HPLkotlinx/coroutines/flow/**->**(**)**
File diff suppressed because it is too large Load Diff
@@ -23,12 +23,16 @@ package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ComponentCallbacks2
import android.os.Build
import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment
import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.service.okhttp.isEmulator
import com.vitorpamplona.amethyst.service.priority.WorkerThreadPriorityGovernor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
@@ -56,6 +60,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" }
}
@@ -81,9 +88,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
@@ -117,8 +129,19 @@ class Amethyst : Application() {
Log.i("AmethystApp") { "Amethyst ${BuildConfig.VERSION_NAME} starting in main process (log level ${Log.minLevel})" }
// Both flags MUST be set before AppModules: its constructor eagerly builds the
// OkHttp factories, whose dispatchers read isEmulator at construction time —
// set afterwards, the emulator-safe limits are never applied.
MediaCallEventListener.verboseLogging = isDebug
HttpClientEnvironment.isEmulator = isEmulator()
instance = AppModules(this)
// Keeps the ~650 relay/ingest worker threads a cold start spawns from starving the UI
// thread out of its frames — worth ~45% off time-to-first-paint on a release build.
// Override or disable with the `amethyst_worker_nice` global setting.
WorkerThreadPriorityGovernor.start(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
FavoriteAppsRegistry.init(this)
@@ -23,15 +23,32 @@ package com.vitorpamplona.amethyst
import android.content.ComponentCallbacks2
import android.content.Context
import android.os.BatteryManager
import android.os.SystemClock
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.relayClient.diagnostics.BootRelayDiagnostics
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.commons.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManager
import com.vitorpamplona.amethyst.commons.service.http.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.commons.service.http.EncryptionKeyCache
import com.vitorpamplona.amethyst.commons.service.http.OnionLocationCache
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
@@ -42,18 +59,18 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzWorkspacePreferences
import com.vitorpamplona.amethyst.model.preferences.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
@@ -62,11 +79,11 @@ import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageDiskCacheReconciler
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -74,13 +91,7 @@ import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServ
import com.vitorpamplona.amethyst.service.notifications.NotificationDispatcher
import com.vitorpamplona.amethyst.service.notifications.NwcPaymentNotificationWatcher
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthInterceptor
import com.vitorpamplona.amethyst.service.okhttp.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
@@ -90,15 +101,10 @@ import com.vitorpamplona.amethyst.service.pow.PowJobStore
import com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.resourceusage.BatteryDrainSampler
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker
@@ -116,7 +122,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncForegroundService
@@ -129,7 +134,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
@@ -180,6 +184,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -189,6 +194,7 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.io.File
@@ -206,6 +212,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.commons.service.image.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()
@@ -267,7 +286,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
@@ -285,23 +304,16 @@ class AppModules(
}
}
// Restore + persist held NIP-OA attestations across restarts (device-global). Eager (not
// lazy) so it loads before the first Buzz-relay AUTH and mirrors later changes to disk.
val buzzAttestationPrefs = BuzzAttestationPreferences(appContext, applicationIOScope)
// Restore + persist the joined Buzz workspace relays across restarts (device-global). Eager so
// the app knows which relays to sync as workspaces on cold start (Buzz membership is
// server-side; there is no join event to rebuild the set from).
val buzzWorkspacePrefs = BuzzWorkspacePreferences(appContext, applicationIOScope)
// Restore + persist the user's starred Buzz workspace channels across restarts (device-global).
val buzzChannelStarPrefs = BuzzChannelStarPreferences(appContext, applicationIOScope)
// Restore + persist the set of relay-group channels deleted (kind-9008) on this device, so a
// deleted channel stays hidden across a restart even if the host relay re-announces a stale
// kind-44100 for it (device-global; a delete is authoritative and terminal for everyone).
val relayGroupDeletionPrefs = RelayGroupDeletionPreferences(appContext, applicationIOScope)
// 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()
@@ -335,6 +347,12 @@ class AppModules(
}
}
// Runs for the whole process lifetime (main process only — the sandbox never builds AppModules).
// See [startHeapPressureWatchdog] for why the OS trim callbacks cannot be relied on.
init {
startHeapPressureWatchdog()
}
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
// on Tor-enabled clients to transparently redirect to .onion addresses.
@@ -405,7 +423,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) }
}
@@ -452,9 +474,8 @@ class AppModules(
// tracks the logged-in account.
blossomReadAuth =
BlossomReadAuthInterceptor(
BlossomReadAuthTokenProvider(
signerProvider = { sessionManager.loggedInAccount()?.signer },
)::authHeader,
cachedHeaderProvider = blossomReadAuthTokens::cachedHeader,
onAuthRequired = blossomReadAuthTokens::warm,
),
)
@@ -627,6 +648,41 @@ class AppModules(
onionCache = onionLocationCache,
)
// Drops pooled connections once per real Tor route change. When the user switches
// Tor on, the direct clients' idle sockets to real hosts would otherwise sit in the
// pool for its 5-minute keepalive after the user has asked for everything to go
// through Tor. No request could use them either way -- OkHttp keys the pool by
// `Address`, which includes the proxy, so a connection on a dead route is already
// unreachable -- which is why this is hygiene and not correctness, and why it is
// fine for it to be a little late.
//
// Every source here is a plain StateFlow, so subscribing costs nothing. Deliberately
// NOT torManager.activePortOrNull: that chains to TorManager.status, whose upstream
// is WhileSubscribed and calls service.start() when collected, so a process-lifetime
// subscription there would hold Arti's control flow open forever -- the same hazard
// the battery ledger above documents and sidesteps the same way.
//
// Also deliberately not the per-feature Tor switches (imagesViaTor, videosViaTor, ...):
// those change which of the two existing clients a request picks, not the route either
// one uses, so no pooled connection goes stale.
init {
applicationIOScope.launch {
combine(
torPrefs.torType,
torPrefs.externalSocksPort,
torService.status.map { it.socksPort },
) { torType, externalPort, artiPort -> Triple(torType, externalPort, artiPort) }
.distinctUntilChanged()
// Only later moves count; the route in force at process construction is the
// status quo, and nothing is pooled yet to evict.
.drop(1)
.collect {
okHttpClients.factory.evictPooledConnections()
okHttpClientForRelays.factory.evictPooledConnections()
}
}
}
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder(
@@ -639,7 +695,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()
},
)
@@ -708,7 +764,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() }
@@ -897,6 +953,17 @@ class AppModules(
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
// Restore + persist the Buzz bookkeeping that has no Nostr event to rebuild from: the
// joined workspace relays (so the app knows which relays to sync as workspaces on cold
// start — Buzz membership is server-side) and the starred channels. Per account: the
// joined set makes a relay first-party for NIP-42, and a star is personal.
startBuzzPersistence = { account ->
BuzzWorkspacePreferences(appContext, account.scope, account.pubKey, account.buzzWorkspaces)
BuzzChannelStarPreferences(appContext, account.scope, account.pubKey, account.buzzChannelStars)
// Eager like the rest, so a held NIP-OA attestation is loaded before this account's
// first Buzz-relay AUTH rather than after it.
BuzzAttestationPreferences(appContext, account.scope, account.pubKey, account.buzzAttestation)
},
)
val sessionManager =
@@ -1076,6 +1143,7 @@ class AppModules(
callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) },
thumbnailCache = thumbnailDiskCache,
backgroundScope = applicationIOScope,
readAuth = blossomReadAuthTokens,
)
}
@@ -1106,6 +1174,22 @@ class AppModules(
}
}
// Reclaim image-cache files orphaned by a process death that lost DeferredDeleteFileSystem's
// queued unlinks — Coil cannot see them, so without this the directory keeps every killed
// process's residue and drifts past its own cap forever. See ImageDiskCacheReconciler.
//
// Rate-limited to once a day: drift accrues over process deaths, not over startups, and this
// runs on every one of them — including the WorkManager wake-ups that cold-start the graph.
//
// Also the one place that forces the `diskCache` lazy, so its build (a statvfs for the size
// budget) and the check both land on IO rather than on whichever thread loads an image first.
applicationIOScope.launch {
val result = ImageDiskCacheReconciler.reconcileIfDue(diskCache)
if (result != null && result.wasOverBudget) {
Log.i("AppModules") { "Image cache was over budget: wiped ${result.reclaimedFiles} files (${result.bytesOnDisk} bytes on disk, ${result.budgetBytes} budget)" }
}
}
applicationIOScope.launch {
// loads main account quickly.
LocalPreferences.loadAccountConfigFromEncryptedStorage()
@@ -1303,6 +1387,53 @@ class AppModules(
accountsCache.clear()
}
/**
* Self-triggered reclaim, because the OS-driven path cannot fire when we need it most.
*
* `onTrimMemory` is the ONLY caller of [trim], and since API 34 the OS delivers just two levels,
* both of which require the app to be backgrounded:
* - `UI_HIDDEN(20)` — activities stopped. Only trims images; never touches [LocalCache].
* - `BACKGROUND(40)` — the process is on the system LRU list, which is what gates every bulk
* reclaim we have (Tier 2 pruning, feed trimming, the hard cache trims).
*
* Two independent situations therefore get NO reclaim at all:
* 1. **Foreground use.** The deprecated `RUNNING_*` levels are never delivered, so a long session
* simply grows until the heap is full.
* 2. **The always-on notification service.** A process hosting a foreground service can never enter
* the cached state, so `BACKGROUND` is unreachable *even while backgrounded* — ActivityManager
* refuses it outright ("Unable to set a background trim level on a foreground process").
*
* Measured consequence: a 3.4-day session sat at 492 MB of a 512 MB heap (3% free), paying 685 ms
* mark-compact GCs every ~10 s with dozens of threads blocked in `WaitForGcToComplete`, until an
* input-dispatch ANR. Reproduced independently on a second device with no foreground service at all.
*
* So we watch our own occupancy instead of waiting to be told. Above [HEAP_HIGH_WATER] we run the
* app's existing `BACKGROUND` reclaim — deliberately the same path, not a parallel policy, because at
* this occupancy "real reclaim pressure" is simply true. [MIN_RECLAIM_INTERVAL_MS] keeps a prune that
* frees little from spinning.
*/
private fun startHeapPressureWatchdog() {
applicationIOScope.launch {
var lastRunAt = 0L
while (isActive) {
delay(HEAP_CHECK_INTERVAL_MS)
val runtime = Runtime.getRuntime()
val max = runtime.maxMemory()
val used = runtime.totalMemory() - runtime.freeMemory()
val ratio = used.toDouble() / max
val now = SystemClock.elapsedRealtime()
if (ratio >= HEAP_HIGH_WATER && now - lastRunAt >= MIN_RECLAIM_INTERVAL_MS) {
lastRunAt = now
Log.w("AppModules") {
"Heap at ${(ratio * 100).toInt()}% (${used / (1024 * 1024)}MB of ${max / (1024 * 1024)}MB) — " +
"self-triggering BACKGROUND reclaim; the OS will not deliver one here."
}
trim(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND)
}
}
}
}
fun trim(level: Int) {
_trimLevelEvents.tryEmit(level)
// Backgrounding is a natural moment to flush the usage ledger too.
@@ -1336,6 +1467,7 @@ class AppModules(
// from scratch. memoryCache is byte-sized (Coil), the rest are entry counts.
memoryCache.trimToSize(memoryCache.maxSize / 10)
CachedRichTextParser.trimToSize(10)
CachedAsciiDocToMarkdown.trimToSize(4)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(10)
}
@@ -1347,4 +1479,23 @@ class AppModules(
}
}
}
companion object {
/**
* Fraction of `Runtime.maxMemory()` above which we stop waiting for an OS trim that is never
* coming and reclaim ourselves. 70% leaves real headroom: the ANR-producing session was pinned at
* 96% (492 MB of 512 MB, 3% free), where every allocation already stalls behind a GC.
*/
private const val HEAP_HIGH_WATER = 0.70
/** Three `Runtime` reads; cheap enough to run often, slow enough to be invisible. */
private const val HEAP_CHECK_INTERVAL_MS = 60_000L
/**
* Floor between self-triggered reclaims. Pruning cannot free events the UI still holds, so a busy
* screen can sit above the high-water mark for a while; without this we would re-prune every
* check and burn CPU on a heap that has nothing left to give.
*/
private const val MIN_RECLAIM_INTERVAL_MS = 120_000L
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlin.time.DurationUnit
@@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot {
private const val STATE_DUMP_TAG = "STATE DUMP"
fun debugState(context: Context) {
// Everything below is logged at DEBUG, and every argument is built eagerly (the
// eager Log.d overload, not the lambda one). Gate on the level that would drop
// those lines, because the arguments are the expensive part: nine materialising
// LargeCache.filter scans over notes/addressables/users/channels, plus three
// passes calling Event.countMemory() — which walks every tag of every cached
// event. MainActivity.onPause() calls this unconditionally, so without the gate
// a release build (minLevel WARN) did all of that on every backgrounding and
// threw the result away. Benchmark builds sit at INFO and paid it too.
if (Log.minLevel > LogLevel.DEBUG) return
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
@@ -25,16 +25,16 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.HomeFeedType
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
@@ -214,9 +214,16 @@ private object PrefKeys {
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val DISMISSED_POLL_NOTE_IDS = "dismissed_poll_note_ids"
const val DISMISSED_CHANNEL_INVITES = "dismissed_channel_invites"
const val MUTED_PUBLIC_CHATS = "muted_public_chats"
const val VIEWED_POLL_RESULT_NOTE_IDS = "viewed_poll_result_note_ids"
const val PENDING_ATTESTATIONS = "pending_attestations"
// Per-account one-shot flag: false only for freshly-GENERATED accounts that
// haven't yet backed up their secret key. Absent (defaults to true) for every
// account logged in via an existing nsec/bunker/external signer — those already
// hold their key elsewhere and must not be nudged.
const val HAS_BACKED_UP_KEYS = "has_backed_up_keys"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
const val LATEST_PAYMENT_TARGETS = "latestPaymentTargets"
@@ -644,6 +651,7 @@ object LocalPreferences {
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
putStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, settings.dismissedPollNoteIds.value)
putStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, settings.dismissedChannelInvites.value)
putStringSet(PrefKeys.MUTED_PUBLIC_CHATS, settings.mutedPublicChats.value)
putString(
PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS,
JsonMapper.toJson(settings.viewedPollResultNoteIds.value),
@@ -690,6 +698,36 @@ object LocalPreferences {
}
}
// Reactive, per-account cache of the "has backed up keys" flag so the home-screen
// nudge updates the instant the user backs up or dismisses it, without a full
// account reload. Keyed by npub. Seeded lazily from encrypted storage.
private val hasBackedUpKeysFlows: MutableMap<String, MutableStateFlow<Boolean>> = mutableMapOf()
private val hasBackedUpKeysMutex = Mutex()
private suspend fun hasBackedUpKeysFlow(npub: String): MutableStateFlow<Boolean> =
hasBackedUpKeysMutex.withLock {
hasBackedUpKeysFlows.getOrPut(npub) {
val stored =
withContext(Dispatchers.IO) {
encryptedPreferences(npub).getBoolean(PrefKeys.HAS_BACKED_UP_KEYS, true)
}
MutableStateFlow(stored)
}
}
/** Reactive flag: true (default) unless a freshly-generated account still needs to back up its key. */
suspend fun hasBackedUpKeys(npub: String): MutableStateFlow<Boolean> = hasBackedUpKeysFlow(npub)
suspend fun setHasBackedUpKeys(
value: Boolean,
npub: String,
) {
withContext(Dispatchers.IO) {
encryptedPreferences(npub).edit { putBoolean(PrefKeys.HAS_BACKED_UP_KEYS, value) }
}
hasBackedUpKeysFlow(npub).value = value
}
val mutex = Mutex()
suspend fun loadAccountConfigFromEncryptedStorage(npub: String): AccountSettings? {
@@ -753,6 +791,7 @@ object LocalPreferences {
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val dismissedChannelInvites = getStringSet(PrefKeys.DISMISSED_CHANNEL_INVITES, null) ?: setOf()
val mutedPublicChats = getStringSet(PrefKeys.MUTED_PUBLIC_CHATS, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
@@ -1012,6 +1051,7 @@ object LocalPreferences {
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
dismissedChannelInvites = MutableStateFlow(dismissedChannelInvites),
mutedPublicChats = MutableStateFlow(mutedPublicChats),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
@@ -1218,6 +1258,8 @@ private class InboxPrefs(
private fun SharedPreferences.readInboxPrefs() =
InboxPrefs(
// Missing key = an account saved before this setting existed. Those keep CUSTOM; only
// brand-new logins get the ALWAYS default from AccountSettings' constructor.
defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
@@ -65,6 +65,19 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_block
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_button
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_how_handle
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_subtitle
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_paranoid_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_reasonable_desc
import com.vitorpamplona.amethyst.commons.resources.nip46_connect_requests_title
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
class SignerConnectActivity : ComponentActivity() {
@@ -164,7 +177,7 @@ private fun SignerConnectScreen(
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_connect_subtitle),
stringRes(Res.string.napplet_connect_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -193,7 +206,7 @@ private fun SignerConnectScreen(
) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
stringResource(R.string.nip46_connect_requests_title),
stringRes(Res.string.nip46_connect_requests_title),
style = MaterialTheme.typography.labelLarge,
)
info.requestedPermissions.forEach { perm ->
@@ -216,7 +229,7 @@ private fun SignerConnectScreen(
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.napplet_connect_how_handle),
stringRes(Res.string.napplet_connect_how_handle),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
)
@@ -230,22 +243,22 @@ private fun SignerConnectScreen(
PolicyOption(
selected = selected == AppSignerPolicy.FULL_TRUST,
symbol = MaterialSymbols.LockOpen,
label = stringResource(R.string.napplet_policy_full_trust),
description = stringResource(R.string.napplet_policy_full_trust_desc),
label = stringRes(Res.string.napplet_policy_full_trust),
description = stringRes(Res.string.napplet_policy_full_trust_desc),
onClick = { selected = AppSignerPolicy.FULL_TRUST },
)
PolicyOption(
selected = selected == AppSignerPolicy.REASONABLE,
symbol = MaterialSymbols.Shield,
label = stringResource(R.string.napplet_policy_reasonable),
description = stringResource(R.string.napplet_policy_reasonable_desc),
label = stringRes(Res.string.napplet_policy_reasonable),
description = stringRes(Res.string.napplet_policy_reasonable_desc),
onClick = { selected = AppSignerPolicy.REASONABLE },
)
PolicyOption(
selected = selected == AppSignerPolicy.PARANOID,
symbol = MaterialSymbols.Lock,
label = stringResource(R.string.napplet_policy_paranoid),
description = stringResource(R.string.napplet_policy_paranoid_desc),
label = stringRes(Res.string.napplet_policy_paranoid),
description = stringRes(Res.string.napplet_policy_paranoid_desc),
onClick = { selected = AppSignerPolicy.PARANOID },
)
}
@@ -262,7 +275,7 @@ private fun SignerConnectScreen(
Text(stringResource(R.string.cancel))
}
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.napplet_connect_button))
Text(stringRes(Res.string.napplet_connect_button))
}
}
@@ -272,7 +285,7 @@ private fun SignerConnectScreen(
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(
stringResource(R.string.napplet_connect_block, info.domain),
stringRes(Res.string.napplet_connect_block, info.domain),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -75,11 +75,31 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_fewer_options
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_hide_event
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_more_options
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_show_event
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_wants_to
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_24h
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_30d
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_all
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_once
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_allow_session
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_deny_once
import com.vitorpamplona.amethyst.commons.resources.napplet_signer_deny_op
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_allow
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_deny
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_remember
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
@@ -184,7 +204,7 @@ private fun SignerConsentDialog(
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_consent_wants_to, info.operationSummary),
stringRes(Res.string.napplet_consent_wants_to, info.operationSummary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -197,7 +217,7 @@ private fun SignerConsentDialog(
// that person as an avatar + name, never as nothing.
if (info.counterpartyName != null) {
Text(
stringResource(R.string.nip46_signer_messages_with),
stringRes(Res.string.nip46_signer_messages_with),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -230,14 +250,14 @@ private fun SignerConsentDialog(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
Text(stringRes(Res.string.napplet_consent_allow_always))
}
} else {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
Text(stringRes(Res.string.napplet_consent_allow_always))
}
}
@@ -246,7 +266,7 @@ private fun SignerConsentDialog(
onClick = { onGrant(SignerOpGrant.AllowOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_once))
Text(stringRes(Res.string.napplet_signer_allow_once))
}
// "More options" toggle: session and time-bound grants
@@ -261,9 +281,9 @@ private fun SignerConsentDialog(
) {
Text(
if (showMoreOptions) {
stringResource(R.string.napplet_consent_fewer_options)
stringRes(Res.string.napplet_consent_fewer_options)
} else {
stringResource(R.string.napplet_consent_more_options)
stringRes(Res.string.napplet_consent_more_options)
},
style = MaterialTheme.typography.bodyMedium,
)
@@ -280,25 +300,25 @@ private fun SignerConsentDialog(
onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_session))
Text(stringRes(Res.string.napplet_signer_allow_session))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_24h))
Text(stringRes(Res.string.napplet_signer_allow_24h))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_30d))
Text(stringRes(Res.string.napplet_signer_allow_30d))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowAll) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_all))
Text(stringRes(Res.string.napplet_signer_allow_all))
}
}
@@ -311,14 +331,14 @@ private fun SignerConsentDialog(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_once))
Text(stringRes(Res.string.napplet_signer_deny_once))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary))
Text(stringRes(Res.string.napplet_signer_deny_op, info.operationSummary))
}
}
}
@@ -390,9 +410,9 @@ private fun SignerConsentPreview(info: SignerConsentInfo) {
) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
stringRes(Res.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
stringRes(Res.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
@@ -468,7 +488,7 @@ private fun BatchedConsentDialog(
)
account.accountName?.let { name ->
Text(
stringResource(R.string.nip46_signer_batch_signing_as, name),
stringRes(Res.string.nip46_signer_batch_signing_as, name),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -553,7 +573,7 @@ private fun BatchedConsentDialog(
) {
Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it })
Text(
stringResource(R.string.nip46_signer_batch_remember),
stringRes(Res.string.nip46_signer_batch_remember),
style = MaterialTheme.typography.bodyMedium,
)
}
@@ -575,7 +595,7 @@ private fun BatchedConsentDialog(
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.nip46_signer_batch_allow, selected.size))
Text(stringRes(Res.string.nip46_signer_batch_allow, selected.size))
}
OutlinedButton(
onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) },
@@ -583,7 +603,7 @@ private fun BatchedConsentDialog(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.nip46_signer_batch_deny, selected.size))
Text(stringRes(Res.string.nip46_signer_batch_deny, selected.size))
}
}
}
@@ -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
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
@@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
* which is why opening that feed and coming back made a favorite suddenly launchable.
*
* This subscribes each favorited coordinate to the shared
* [EventFinder][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler]
* [EventFinder][com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler]
* the same lifecycle-aware loader [observeNote][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote]
* uses so the manifests fetch (via the author's outbox relays) as soon as the launcher opens and are
* already in [LocalCache] by the time the user taps. The loader drops each coordinate from its filter
@@ -31,13 +31,23 @@ 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.marmot.MarmotPublisher
import com.vitorpamplona.amethyst.commons.marmot.MarmotPushCoordinator
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelStars
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzHeldAttestations
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState
import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager
import com.vitorpamplona.amethyst.commons.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState
@@ -51,7 +61,12 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListD
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupListState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
@@ -60,10 +75,25 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.relayClient.auth.InMemoryRelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPermissionCache
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthSessionGrants
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthVenues
import com.vitorpamplona.amethyst.commons.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.notify.NotifyRequestsCache
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
@@ -72,9 +102,12 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.AccountMarmotActions
import com.vitorpamplona.amethyst.model.AccountRelayGroupActions
import com.vitorpamplona.amethyst.model.EventBroadcaster
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.bolt12Offers.Bolt12OfferListState
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.buzz.ChannelInvitesState
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
@@ -95,12 +128,8 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
@@ -108,7 +137,6 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
@@ -133,28 +161,22 @@ 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.nipBCOnchainZaps.OnchainWalletState
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
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithSearchRelayListsState
import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.marmotquic.QuicAgentTextStreamTransport
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot
@@ -184,6 +206,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent
import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.image
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -193,6 +216,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -319,6 +343,7 @@ import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import com.vitorpamplona.quartz.utils.containsAny
import com.vitorpamplona.quic.tls.JdkCertificateValidator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -354,6 +379,24 @@ class Account(
val mlsGroupStateStore: MlsGroupStateStore? = null,
val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null,
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
/**
* Durable publish obligations. Null means publish-before-apply does not
* survive a restart, so a commit interrupted mid-publish is replaced by a
* fresh one for the same epoch a fork against the peers that took the
* first.
*/
val marmotPublishObligationStore: com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore? = null,
/**
* Durable "already decided" markers for inbound events. Null means every
* backdated gift wrap is re-unwrapped on every sync.
*/
val marmotIngestDedupStore: com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore? = null,
/**
* Durable push token records, stamps and tombstones. Null means a restart
* forgets every tombstone, so a relayed but revoked token record can win
* once and start waking a device its owner asked to be forgotten.
*/
val marmotPushStateStore: com.vitorpamplona.quartz.marmot.mip05PushNotifications.MarmotPushStateStore? = null,
val powQueue: () -> PoWPublishQueue? = { null },
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
@@ -377,12 +420,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
@@ -405,6 +452,28 @@ class Account(
// answered without a disk read. Backed by a per-account file (see AccountCacheState).
val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope)
// The `block/buzz` workspaces THIS account joined. Per account, not per device: the invite was
// redeemed by this key and the relay grants membership to it alone — and this set makes the
// relay first-party for NIP-42 (see AuthCoordinator.isFirstParty), so a device-global set would
// hand every other logged-in account an automatic login on a workspace it never joined.
// Restored/persisted per account by BuzzWorkspacePreferences (see AccountCacheState).
val buzzWorkspaces = BuzzWorkspaces()
// The Buzz channels THIS account pinned. A star says which channels this user wants at the top
// of the community view, so a shared set let one account reorder and badge every other one's
// channel list. Restored/persisted per account by BuzzChannelStarPreferences.
val buzzChannelStars = BuzzChannelStars()
// The NIP-OA attestation an owner issued to THIS account's key, attached to its Buzz-relay
// AUTH so the relay grants virtual membership. Restored/persisted per account by
// BuzzAttestationPreferences.
val buzzAttestation = BuzzHeldAttestations(pubKey)
// The relays this account approved by answering the NIP-42 prompt *without* the "remember"
// switch. Deliberately in-memory only: it dies with this Account (i.e. with the process, or at
// logout), which is what makes it a session grant rather than a stored ALLOW.
val relayAuthSessionGrants = RelayAuthSessionGrants()
// Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt),
// reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH
// path (foreground screen + background notification consumer) shares one instance, and so an
@@ -413,6 +482,7 @@ class Account(
RelayAuthPermissionLedger(
store = relayAuthPermissions,
globalPolicy = { settings.defaultRelayAuthPolicy.value },
sessionGrants = relayAuthSessionGrants,
customToggles = {
RelayAuthCustomToggles(
myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value,
@@ -424,11 +494,68 @@ class Account(
isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false },
isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false },
isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors },
isTrustedVenue = { venueId ->
isTrustedVenue = { relayUrl, venueId ->
venueId in publicChatList.flowSet.value ||
venueId in communityList.flowSet.value ||
isJoinedRoomId(relayUrl, venueId) ||
Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true
},
isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false },
)
/**
* Sets the global NIP-42 policy, dropping every session grant when it becomes
* [RelayAuthPolicy.NEVER].
*
* The two halves belong together, which is why they live here instead of in the settings screen
* that used to pair them: a session grant outranks the policy (see
* [com.vitorpamplona.amethyst.commons.relayauth.RelayAuthResolver]), so "never log in" only
* means what it says if the casual one-tap answers go with it. As a composable's `onClick` that
* was a property of one screen rather than of the account, and any other caller of
* [AccountSettings.changeDefaultRelayAuthPolicy] silently reintroduced grants that outlive the
* switch-it-all-off answer.
*
* Stored Always/Never exceptions are deliberately left alone: those outrank the policy by
* design, and the settings screen lists them, so they are a standing answer rather than a
* casual one.
*/
fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) {
settings.changeDefaultRelayAuthPolicy(policy)
if (policy == RelayAuthPolicy.NEVER) relayAuthSessionGrants.clear()
}
/**
* Relays that exist here because *this account* joined a room on them: the host of every NIP-29
* relay group on its kind-10009 list, plus the relays of every Concord community on its
* kind-13302 list.
*
* Both are venues in the [RelayAuthCustomToggles.myRelaysAndVenues] sense but neither shows up in
* a NIP-65/DM/search list, so nothing else in the auth path can see them: a NIP-29 group's content
* is `#h`-scoped and never names the user, and a Concord plane is addressed to a derived stream
* key rather than to anyone's pubkey.
*/
fun venueHostRelays(): Set<NormalizedRelayUrl> =
RelayAuthVenues.hostRelays(
joinedGroups = relayGroupList.liveRelayGroupIds.value,
joinedCommunities = concordChannelList.liveCommunities.value,
)
/**
* True when [venueId], served by [relayUrl], is a room this account joined that the venue *lists*
* above don't cover: a NIP-29 group id (from the kind-10009 list) or a Concord community id (from
* the kind-13302 list). Those are the ids the subscription assemblers declare on their filters, so
* this is what turns a `READ_VENUE`/`POST_VENUE` on a joined group or community into a trusted
* venue.
*/
private fun isJoinedRoomId(
relayUrl: String,
venueId: String,
): Boolean =
RelayAuthVenues.isJoinedRoom(
venueId = venueId,
relayUrl = relayUrl.normalizeRelayUrlOrNull(),
joinedGroups = relayGroupList.liveRelayGroupIds.value,
joinedCommunities = concordChannelList.liveCommunities.value,
)
// Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming
@@ -515,6 +642,21 @@ class Account(
val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer)
val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings)
/**
* Buzz channels somebody else added me to that I haven't answered yet, projected from the cached
* kind-44100/44101 verdicts. Account state rather than screen state because the notifications DAL
* reads it to decide whether a cached 44100 is still a live question.
*/
val channelInvites =
ChannelInvitesState(
me = signer.pubKey,
cache = cache,
buzzWorkspaces = buzzWorkspaces,
relayGroupList = relayGroupList,
dismissed = settings.dismissedChannelInvites,
scope = scope,
)
val concordChannelList = ConcordChannelListState(signer, cache, scope, settings)
/**
@@ -667,11 +809,30 @@ class Account(
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
val notificationHistory = RelayLoadingCursors()
// Per-relay backward-paging cursors for the NIP-60 spending history (kind:7376): how far back each
// outbox relay has been paged by until+limit. Same lifetime rule as notificationHistory — held here
// so paging progress survives leaving and re-entering the wallet screen; the history loader
// ([CashuWalletHistoryEoseManager]) binds its orchestrator to these.
val cashuHistory = RelayLoadingCursors()
/**
* NIP-BC on-chain wallet balance for this account's Taproot address. Cached
* (one Esplora round trip per minute at most) so the zap picker can ask
* synchronously whether an amount is payable on-chain before offering it.
*/
val onchainWalletState =
OnchainWalletState(
pubKey = signer.pubKey,
scope = scope,
backend = { cache.onchainBackend },
)
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
signer = signer,
cache = cache,
client = client,
scope = scope,
outboxRelaysFlow = outboxRelays.flow,
inboxRelaysFlow = notificationRelays.flow,
@@ -700,6 +861,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)
@@ -731,6 +895,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)
@@ -756,7 +944,56 @@ class Account(
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) }
val marmotManager: MarmotManager? =
mlsGroupStateStore?.let {
MarmotManager(
signer,
it,
marmotMessageStore,
marmotKeyPackageStore,
// Publish-before-apply: a group-state change becomes canonical
// only once a relay in the group's own scope returns OK true.
// `publishAndConfirm` is exactly that "at least one
// acknowledged accept" rule; a plain `publish` would report
// success for bytes nobody took.
MarmotPublisher { event, relays -> client.publishAndConfirm(event, relays) },
marmotPublishObligationStore,
marmotIngestDedupStore,
scope = scope,
)
}
/**
* Push token gossip (`features/push-notifications.md`) for the groups this
* account is in.
*
* Present whenever Marmot itself is, because CONSUMING gossip costs nothing
* and is what lets this client answer a peer's kind:447 later. Producing a
* record of our own is a separate decision: it needs a device token and a
* notification server public key, neither of which the protocol discovers.
*/
val marmotPushCoordinator: MarmotPushCoordinator? =
marmotManager?.let {
marmotPushStateStore?.let { store -> MarmotPushCoordinator(it, store) } ?: MarmotPushCoordinator(it)
}
/**
* Raw QUIC for agent text stream previews (`transports/quic.md`).
*
* Only the live preview needs it. A device that cannot open a QUIC
* connection still participates fully it reads every stream's
* authoritative kind:9 like ordinary chat which is why this is a
* separate optional piece rather than part of [marmotManager].
*/
val marmotStreamTransport: MarmotQuicTransport by lazy {
QuicAgentTextStreamTransport(
parentScope = scope,
// Preview brokers are commonly self-signed and the binding expects
// that; the platform trust store is still the default answer, and
// a deployment that pins does it here.
certificateValidator = JdkCertificateValidator(),
)
}
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
@@ -997,6 +1234,15 @@ class Account(
sendNewAppSpecificData()
}
/**
* Local state first, then publish. The local write is what every suppression point
* reads, so it must not wait on the signer publishing is best-effort sync.
*/
suspend fun toggleMutedPublicChat(channelId: String) {
settings.toggleMutedPublicChat(channelId)
sendNewAppSpecificData()
}
suspend fun updateZapAmounts(
amountSet: List<Long>,
selectedZapType: LnZapEvent.ZapType,
@@ -1131,7 +1377,7 @@ class Account(
persistAs = record,
// NIP-13 recommends refreshing created_at while mining; scheduled
// posts keep their intentional future timestamp.
refreshCreatedAtOnStart = replay !is PoWReplay.Schedule,
refreshCreatedAt = replay !is PoWReplay.Schedule,
onMined = onMined,
)
return true
@@ -1235,12 +1481,12 @@ class Account(
val workers = powMinerWorkers()
return if (currentSigner is NostrSignerWithClientTag) {
NostrSignerWithClientTag(
inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive, workers),
inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive, workers, TimeUtils::now),
clientTag = currentSigner.clientTag,
disabled = currentSigner.disabled,
)
} else {
PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive, workers)
PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive, workers, TimeUtils::now)
}
}
@@ -3383,6 +3629,15 @@ class Account(
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
/**
* Blocks a single relay, leaving the rest of the kind-10006 list alone.
*
* Once published, [com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient]
* strips the relay from every REQ, COUNT and publish, so the pool drops the socket as soon as
* the subscriptions that wanted it are recomputed.
*/
suspend fun blockRelay(relay: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(blockedRelayList.addRelay(relay))
/**
* Returns all known signed replaceable events that configure this account
* (profile, contact list, relay lists, mute list, bookmarks, etc.). Events
@@ -3506,6 +3761,24 @@ 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 per-relay "never" answer already drops the grant via
// RelayAuthPermissionLedger.setDecision, so it would be odd for the stronger one not to.
//
// Observed rather than hooked onto the local block action because the kind-10006 list is
// shared: a block published by another client arrives as a flow update with no call of ours
// behind it.
scope.launch {
blockedRelayList.flow.collect { blocked ->
relayAuthLedger.revokeSessionGrantsFor(blocked.map { it.url })
}
}
// Start the Cashu wallet state observers AFTER all field initializers
// complete — auto-redeem can fire as soon as start() returns, and it
// calls back into sendLiterallyEverywhere which depends on
@@ -3516,6 +3789,26 @@ class Account(
// Restore Marmot MLS group state on startup
if (marmotManager != null) {
// Derived kind:1210 rows go straight into the conversation. Only
// DERIVED rows arrive here — one received over the wire is an
// assertion by its sender and is dropped at ingest — so these are
// safe to render with attribution.
marmotManager.onSystemRowDerived = { groupId, row ->
cache.justConsume(row, null, true)
val note = cache.getOrCreateNote(row.id)
note.event = row
marmotGroupList.addMessage(groupId, note)
}
// A disappearing message that is gone from disk but still on screen
// has not disappeared. Drop it from the conversation as it expires,
// rather than waiting for the next read to omit it.
marmotManager.onMessagesExpired = { groupId, expiredIds ->
expiredIds.forEach { id ->
cache.getNoteIfExists(id)?.let { marmotGroupList.removeMessage(groupId, it) }
}
}
scope.launch(Dispatchers.IO) {
marmotManager.restoreAll()
@@ -24,6 +24,9 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
@@ -54,7 +57,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
@@ -201,20 +203,19 @@ class AccountConcordActions(
// served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle
// timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes
// the signer_sk of every link it failed to read, so the two must be told apart.
val reasons = mutableMapOf<NormalizedRelayUrl, String>()
val events =
val result =
account.client.fetchAllWithHooks(
filters = relays.associateWith { listOf(filter) },
doneOut = reasons,
) { _, _ -> true }
val newest =
events
result
.events
.mapNotNull { it.second as? ConcordInviteListEvent }
// Filter by kind BEFORE picking the newest: taking the newest of anything and then
// casting means one stray event at this coordinate reads as "unreadable" forever.
.maxByOrNull { it.createdAt }
?: return if (reasons.anyRelayServed()) {
?: return if (result.anyRelayServed) {
ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one
} else {
null // nobody answered; we know nothing about what is published
@@ -1383,7 +1384,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)
}
@@ -1523,11 +1524,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) }
}
@@ -1602,6 +1602,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)" }
}
}
@@ -20,7 +20,15 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl
import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -52,7 +60,7 @@ class AccountMarmotActions(
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
account.marmotManager
?.groupMetadata(nostrGroupId)
?.groupView(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -135,8 +143,11 @@ class AccountMarmotActions(
?.toSet()
.orEmpty()
val fetchRelays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
KeyPackageFetcher.fetchRelaysFor(
targetOutbox = memberOutbox,
myOutbox = myOutbox,
targetKeyPackageRelays = memberKeyPackageRelays,
)
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
@@ -214,15 +225,24 @@ class AccountMarmotActions(
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}" +
val commit =
commitEvent?.let { "kind=${it.signedEvent.kind} id=${it.signedEvent.id.take(8)}" }
?: "none (founding add, merged locally)"
"addMarmotGroupMember: built commit $commit " +
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
}
// Publish commit first (critical ordering)
// Nothing to publish here either way. A normal commit was already
// published by the manager, which only advances the group once a relay
// acknowledged it (publish-before-apply); publishing it again would
// just duplicate the event. A FOUNDING add has no commit at all — the
// creator was the group's only member, so it merges locally under the
// empty publication obligation and the invitee gets epoch 1 from the
// Welcome.
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
commitEvent?.let { "addMarmotGroupMember: commit kind:${it.signedEvent.kind} published to ${groupRelays.size} relay(s)" }
?: "addMarmotGroupMember: founding add merged locally, no commit published"
}
account.client.publish(commitEvent.signedEvent, groupRelays.toSet())
// Then send the Welcome gift wrap to the new member.
//
@@ -266,11 +286,16 @@ class AccountMarmotActions(
/**
* Relays where this account publishes kind:30443 KeyPackage events.
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
*
* The NIP-65 write set is the discovery rule now the spec removed the
* dedicated kind:10051 KeyPackage relay list. The account's own 10051 is
* still unioned in so peers that have not migrated keep finding us.
*/
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value)
KeyPackageFetcher.publishRelaysFor(
myOutbox = account.outboxRelays.flow.value,
legacyKeyPackageRelayList = account.keyPackageRelayList.flow.value,
)
/**
* Publish or rotate KeyPackage events.
@@ -376,12 +401,40 @@ class AccountMarmotActions(
}
/**
* Create a new Marmot MLS group.
* Create a new Marmot MLS group under the CURRENT profile.
*
* Not the legacy `0xF2EE` shape. A current-profile peer refuses a leaf
* with no account identity proof, and a legacy group cannot be upgraded
* into one afterwards its existing leaves have no proofs to add so the
* profile is decided here, once, and never migrated. Groups made the old
* way are joinable only by other legacy clients.
*
* The name, description and avatar arrive later through
* `updateMarmotGroupMetadata`; the routing component has to exist from
* epoch 0 because it carries the `nostr_group_id` every kind-445 event in
* this group is addressed to.
*/
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
suspend fun createMarmotGroup(
nostrGroupId: HexKey,
name: String = "",
description: String = "",
/**
* Disappearing messages (`0x8005`), or null for off. Fixed at creation:
* promoting a component to required later needs its state installed by
* a prior commit, which this path does not make.
*/
disappearingMessageSecs: ULong? = null,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
manager.createGroup(nostrGroupId)
manager.createCurrentProfileGroup(
nostrGroupId = nostrGroupId,
relays =
account.outboxRelays.flow.value
.map { it.url },
profile = if (name.isEmpty() && description.isEmpty()) null else GroupProfileV1(name, description),
retention = disappearingMessageSecs?.let { MessageRetentionV1(it) },
)
// Creator owns the group — mark it as "known" immediately so it
// doesn't appear under "New Requests" before the first message.
account.marmotGroupList.markAsKnown(nostrGroupId)
@@ -406,9 +459,9 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId)
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
val view = manager.groupView(nostrGroupId)
if (view != null && view.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = view.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
// MIP-03 also rejects any GCE commit that leaves the group with zero
// admins. If we're the only one, promote an arbitrary non-self
// member to admin before stepping down.
@@ -421,9 +474,7 @@ class AccountMarmotActions(
if (heir != null) remaining.add(heir)
}
if (remaining.isNotEmpty()) {
val demoted = metadata.copy(adminPubkeys = remaining)
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
account.client.publish(demoteCommit.signedEvent, groupRelays)
manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList())
}
}
@@ -483,17 +534,16 @@ class AccountMarmotActions(
return
}
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex, groupRelays.toList())
Log.d("MarmotDbg") {
"removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}" +
"to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
"removeMarmotGroupMember: commit id=${outbound.signedEvent.id.take(8)}" +
"published to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
@@ -508,13 +558,112 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
// The MLS commit has already been applied locally — surface the new
// metadata in the chatroom now so the UI reflects it without waiting
// for the relay round-trip.
manager.updateGroupMetadata(nostrGroupId, metadata, groupRelays.toList())
// The commit was published and acknowledged before it became canonical,
// so the local state is already the one peers will see — surface it now
// rather than waiting for our own event to loop back.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
}
/**
* Disband a Marmot MLS group (`marmot.group.lifecycle.v1`, `0x800c`).
*
* Irreversible and absorbing: every member's copy terminalizes when they
* apply the commit, and there is no commit that walks it back. The caller
* MUST have confirmed with a human first this layer only refuses what is
* structurally impossible (a non-admin, a legacy group, a second disband),
* which is not the same as asking.
*
* Deliberately NOT silent on failure the way the other actions here are: a
* disband that did not happen must not look like one that did, so the
* exception propagates to the caller's error path.
*
* It is no longer terminal the moment it is published, either: the Commit
* is admitted as a convergence candidate and only a SELECTED one moves the
* group to `Disbanded`, so between the two the request sits behind a
* durable `Disbanding` gate. Reporting that distinction is the whole point
* of the return value announcing "group disbanded" for a request that is
* still pending is the one thing a terminal action must never do.
*
* @return true when the group is terminal now; false when the request is
* durable and unresolved, which is not a failure.
*/
suspend fun disbandMarmotGroup(
nostrGroupId: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
): Boolean {
val manager = account.marmotManager ?: return false
if (!account.isWriteable()) return false
manager.disbandGroup(nostrGroupId, groupRelays.toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
return manager.lifecycle(nostrGroupId) == GroupLifecycleState.DISBANDED
}
/**
* Commit the `encrypted-media-v2` policy (`0x800b`) for a group.
*
* Creation deliberately leaves this off `CurrentProfileGroupFactory`
* explains why: carrying it at epoch 0 would make our GroupContext differ
* from the reference's for the same inputs, and would force every joiner to
* advertise `0x800b` before it could be added. The spec's answer is that "a
* group that wants a media policy commits one", and until now nothing on
* Android could, so `marmotUsesEncryptedMediaV2` was false for every group
* this app created and attachments always fell back to MIP-04.
*
* Enable-only on purpose. Changing the policy later is the same commit;
* REMOVING it is a different question the component does not answer, and
* inventing a removal that strands members mid-upload is not something to
* guess at.
*
* The endpoints come from the account's own Blossom server list, because a
* policy naming servers the uploader does not use would describe a group
* nobody can actually post media to.
*/
suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val servers = account.blossomServers.flow.value
require(servers.isNotEmpty()) {
"Cannot enable encrypted media without at least one Blossom server configured"
}
val policy =
EncryptedMediaPolicyV2(
allowedLocatorKinds = listOf(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND),
defaultBlobEndpoints =
servers.map {
BlobStoreEndpointV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, it)
},
)
manager.setEncryptedMediaPolicy(nostrGroupId, policy, marmotGroupRelays(nostrGroupId).toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
}
/**
* Set or clear the group's plain-`https` avatar link
* (`marmot.group.avatar-url.v1`, `0x8007`).
*
* The lightweight avatar carrier: no Blossom upload, no key material, just
* a URL every Marmot client can render. A blank [url] clears it, which
* falls the group back to its encrypted Blossom image if it has one the
* two carriers coexist and this one wins while it is set.
*/
suspend fun setMarmotGroupAvatarUrl(
nostrGroupId: HexKey,
url: String,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val avatar = url.trim().takeIf { it.isNotEmpty() }?.let { GroupAvatarUrlV1(MarmotWebUrl.normalize(it, label = "avatar URL")) }
manager.setGroupAvatarUrl(nostrGroupId, avatar, groupRelays.toList())
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
@@ -534,17 +683,10 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (metadata.adminPubkeys.contains(targetPubKey)) return
val view = manager.groupView(nostrGroupId) ?: return
if (view.adminPubkeys.contains(targetPubKey)) return
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
manager.setGroupAdmins(nostrGroupId, view.adminPubkeys + targetPubKey, groupRelays.toList())
}
/**
@@ -561,20 +703,13 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (!metadata.adminPubkeys.contains(targetPubKey)) return
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
val view = manager.groupView(nostrGroupId) ?: return
if (!view.adminPubkeys.contains(targetPubKey)) return
val remaining = view.adminPubkeys.filter { it != targetPubKey }
check(remaining.isNotEmpty()) {
"Cannot revoke the last admin from a Marmot group (MIP-03)"
}
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = remaining)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
manager.setGroupAdmins(nostrGroupId, remaining, groupRelays.toList())
}
}
@@ -22,17 +22,21 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.mergeMutedPublicChats
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
@@ -98,96 +102,6 @@ val DefaultSignerPermissions =
Permission(CommandType.DECRYPT_ZAP_EVENT),
)
@Serializable
sealed class TopFilter(
val code: String,
) {
interface AddressableTopFilter {
val address: Address
}
@Serializable
object Global : TopFilter(" Global ")
/**
* Notifications-only curated mode: like [Global] it admits authors the
* user doesn't follow, but it also applies per-kind relevance heuristics
* to remove less interesting notes (reactions/reposts that don't target
* the user's own notes, unrelated thread replies, etc.). In Notifications,
* [Global] shows every event that p-tags the user instead.
*/
@Serializable
object Selected : TopFilter(" Selected ")
@Serializable
object AllFollows : TopFilter(" All Follows ")
@Serializable
object AllUserFollows : TopFilter(" All User Follows ")
@Serializable
object DefaultFollows : TopFilter(" Main User Follows ")
@Serializable
object AroundMe : TopFilter(" Around Me ")
/**
* Not a real selection: a sentinel for the "Teleport" chip in the top-nav filter.
* The spinner intercepts it to open the map picker and then applies the chosen
* [Geohash] instead it is never persisted or dispatched to a feed flow.
*/
@Serializable
object TeleportPicker : TopFilter(" Teleport ")
@Serializable
object Mine : TopFilter(" Mine ")
@Serializable
class PeopleList(
override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
class MuteList(
override val address: Address,
) : TopFilter(address.toValue()),
AddressableTopFilter
@Serializable
class Community(
override val address: Address,
) : TopFilter("Community/${address.toValue()}"),
AddressableTopFilter
@Serializable
class Hashtag(
val tag: String,
) : TopFilter("Hashtag/$tag")
@Serializable
class Geohash(
val tag: String,
) : TopFilter("Geohash/$tag")
@Serializable
class Relay(
val url: String,
) : TopFilter("Relay/$url")
@Serializable
class FavoriteAlgoFeed(
val address: Address,
) : TopFilter("FavoriteAlgoFeed/${address.toValue()}")
@Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
@Serializable
class InterestSet(
val address: Address,
) : TopFilter("InterestSet/${address.toValue()}")
}
@Stable
class AccountSettings(
val keyPair: KeyPair,
@@ -330,6 +244,14 @@ class AccountSettings(
* still lists you, and Leave (kind 9022) is the separate action that actually removes you.
*/
val dismissedChannelInvites: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
/**
* NIP-28 channel ids the user has silenced. Local device state ON PURPOSE, even
* though it also syncs via NIP-78: the push dispatcher must answer "is this muted?"
* during a cold start, before (or without) the settings blob having been decrypted
* for a NIP-55 account that decrypt is an Amber IPC round-trip that may never
* complete in the background. See AppSpecificState.kt:70-75.
*/
val mutedPublicChats: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val viewedPollResultNoteIds: MutableStateFlow<Map<String, Long>> = MutableStateFlow(mapOf()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow(mapOf()),
var backupNipA3PaymentTargets: PaymentTargetsEvent? = null,
@@ -338,7 +260,9 @@ class AccountSettings(
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
// New accounts authenticate with every relay that asks. Existing accounts keep whatever they
// had saved (LocalPreferences falls back to CUSTOM for prefs written before this key existed).
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.ALWAYS),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = MutableStateFlow(ConcordViewMode.DEFAULT),
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
@@ -778,6 +702,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)
}
@@ -1513,6 +1493,14 @@ class AccountSettings(
backupAppSpecificData = appSettings
syncedSettings.updateFrom(newSyncedSettings)
// Null means an older client rewrote the blob without this key — leave the
// local set alone rather than treating "absent" as "unmute everything".
// The decision lives in mergeMutedPublicChats so it is unit-testable; this
// class cannot be constructed in a JVM test.
mutedPublicChats.tryEmit(
mergeMutedPublicChats(mutedPublicChats.value, newSyncedSettings.chats.mutedPublicChats),
)
saveAccountSettings()
}
}
@@ -1612,6 +1600,17 @@ class AccountSettings(
saveAccountSettings()
}
// ---
// muted public chats
// ---
fun toggleMutedPublicChat(channelId: String) {
mutedPublicChats.update {
if (channelId in it) it - channelId else it + channelId
}
saveAccountSettings()
}
// ---
// viewed poll results
// ---
@@ -90,7 +90,7 @@ class AccountSyncedSettings(
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
)
fun toInternal(): AccountSyncedSettingsInternal =
fun toInternal(mutedPublicChats: Set<String>): AccountSyncedSettingsInternal =
AccountSyncedSettingsInternal(
reactions = AccountReactionPreferencesInternal(reactions.reactionChoices.value, reactions.reactionRowItems.value),
zaps =
@@ -120,7 +120,12 @@ class AccountSyncedSettings(
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats = AccountChatPreferencesInternal(chats.pinnedChatrooms.value.map { it.users.sorted() }),
chats =
AccountChatPreferencesInternal(
chats.pinnedChatrooms.value.map { it.users.sorted() },
// sorted so the serialized form is deterministic
mutedPublicChats.sorted(),
),
proofOfWork =
AccountPoWPreferencesInternal(
proofOfWork.difficulty.value,
@@ -242,4 +242,14 @@ class AccountChatPreferencesInternal(
// pubkeys (hex) sorted ascending, so the serialized form is deterministic
// regardless of set iteration order.
var pinnedRooms: List<List<String>> = emptyList(),
// NIP-28 channel ids (hex) whose notifications are silenced, sorted ascending
// for the same determinism reason as pinnedRooms.
//
// NULLABLE ON PURPOSE. The default has to tell two cases apart:
// null = key absent — an older client rewrote the blob and dropped it, so
// the local mute set must be left alone.
// [] = an explicit "unmute everything" from a client that knows the field.
// A non-null default would collapse them and let an old client silently erase
// the user's mutes on every launch. See updateAppSpecificData.
var mutedPublicChats: List<String>? = null,
)
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
@@ -35,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
@@ -72,7 +77,15 @@ class AccountZapActions(
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
// Where the provider should publish the receipt. Zapping group content pins that to the room's
// host relay: the receipt belongs where the message it pays for lives, so the room can show it
// and the recipient's group query can find it — and, for a private or closed group, so a
// kind-9735 naming the room never lands on a relay outside it. Everything else keeps the
// ordinary NIP-65 inbox routing.
relays =
account.cache.relayGroupHostsFor(event).ifEmpty {
account.nip65RelayList.inboxFlow.value
} + (additionalRelays ?: emptySet()),
signer = account.signer,
pollOption = pollOption,
message = message,
@@ -91,18 +104,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
}
@@ -119,12 +134,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))
}
@@ -142,6 +165,16 @@ class AccountZapActions(
?.supportsMethod(NwcMethod.PAY) == true
}
/**
* True when this account can settle a BOLT12 zap at all: an NWC wallet is
* configured and the default one advertises `pay`. The sender-side half of the
* BOLT12 route; the recipient-side half is a published kind:10058 offer.
*/
fun canZapViaBolt12(): Boolean =
account.settings.nwcWallets.value
.isNotEmpty() &&
defaultWalletSupportsBolt12Pay()
/**
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
*
@@ -153,6 +186,14 @@ class AccountZapActions(
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
* external-wallet or LNURL fallback because only NWC returns the proof.
*
* Outcomes are split by what they say about the money:
* - [onNotPaid]: the wallet answered with an error. The wallet does the offer
* invoice exchange itself, so a stale or dead offer lands here too. Whether a
* retry is safe depends on the code `PAYMENT_FAILED` may be a timeout with the
* HTLC still in flight see `Bolt12LightningFallback`.
* - [onError]: paid but no valid receipt, or nothing conclusive. Never retry.
* - [onTimeout]: the wallet never answered. Unknown state never retry.
*/
suspend fun sendBolt12Zap(
zappedEvent: Event?,
@@ -163,15 +204,21 @@ class AccountZapActions(
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> Unit,
// (code, detail) — the wallet refused or failed the payment; no funds moved.
onNotPaid: suspend (NwcErrorCode?, String?) -> Unit,
onTimeout: () -> Unit,
onProcessed: () -> Unit,
) {
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
if (zapType == LnZapEvent.ZapType.NONZAP) {
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats), onTimeout) { response ->
account.scope.launch {
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
onProcessed()
try {
if (response is IErrorResponseLike) onNotPaid(response.nwcErrorCode(), response.errorMessage())
} finally {
onProcessed()
}
}
}
return
@@ -191,7 +238,7 @@ class AccountZapActions(
val payerNote = Bolt12ZapBuilder.payerNote(intent)
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote), onTimeout) { response ->
account.scope.launch {
// try/finally so a failure while assembling/publishing the receipt (e.g. a
// remote signer error) still steps progress and surfaces an error, instead
@@ -214,7 +261,7 @@ class AccountZapActions(
}
}
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
is IErrorResponseLike -> onNotPaid(response.nwcErrorCode(), response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
}
@@ -275,16 +322,18 @@ class AccountZapActions(
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
return OnchainZapSender
.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
.alsoRefreshBalanceIfSpent()
}
/**
@@ -300,14 +349,15 @@ class AccountZapActions(
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
return OnchainZapSender
.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
).alsoRefreshBalanceIfSpent()
}
/**
@@ -324,14 +374,38 @@ class AccountZapActions(
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
return OnchainZapSender
.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
.alsoRefreshBalanceIfSpent()
}
/**
* Once a transaction is on the chain the cached balance is wrong and it is
* what the zap picker gates the on-chain rail on, so leaving it would keep
* offering amounts the wallet just spent. Covers the failure case too: a
* receipt that fails to publish still broadcast the payment.
*/
private fun OnchainZapSendResult.alsoRefreshBalanceIfSpent(): OnchainZapSendResult {
val broadcast =
this is OnchainZapSendResult.Success ||
(this is OnchainZapSendResult.Failure && broadcastTxid != null)
if (broadcast) account.onchainWalletState.invalidate()
return this
}
}
/** The NIP-47 error code on a failed reply, whichever error shape the wallet used. */
private fun Response.nwcErrorCode(): NwcErrorCode? =
when (this) {
is NwcErrorResponse -> error?.code
is PayInvoiceErrorResponse -> error?.code
else -> null
}
@@ -20,7 +20,10 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
@@ -395,7 +398,7 @@ class CachePruner(
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
cache.getUserIfExists(it.pubKey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
@@ -20,33 +20,29 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.commons.search.RenderableKinds
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip50Search.EventSearchMatcher
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.CancellationException
@@ -114,22 +110,14 @@ class CacheSearch(
}
/**
* Will return true if supplied note is one of events to be excluded from
* search results.
* True when the note is one of the kinds a search never returns.
*
* The list is [RenderableKinds.NEVER_IN_RESULTS], not a chain of `is` checks here: it is the
* reader's policy rather than the cache's, and the `kind:` vocabulary has to be able to read
* it offering `kind:repost` while dropping every repost from results is a filter that draws
* a chip and can never return anything, which is exactly what it did.
*/
private fun excludeNoteEventFromSearchResults(note: Note): Boolean =
(
note.event is GenericRepostEvent ||
note.event is RepostEvent ||
note.event is CommunityPostApprovalEvent ||
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is FileHeaderEvent ||
note.event is MetadataEvent ||
note.event is ContactListEvent ||
note.event is AppSpecificDataEvent
)
private fun excludeNoteEventFromSearchResults(note: Note): Boolean = note.event?.kind in RenderableKinds.NEVER_IN_RESULTS
/**
* Tag names whose values should not match text searches: the `client` tag
@@ -146,9 +134,60 @@ class CacheSearch(
AltTag.TAG_NAME,
)
/**
* Every note in the cache matching [filters], as the search screen asks for them.
*
* This is the generic path: the same `Filter`s the REQ carries are run against the cache, so
* `from:`, `to:`, `since:`, `#t` and the rest narrow local results exactly as they narrow
* relay results. Before this, local search could only ever match one substring, and every
* token the search box drew as a chip was ignored on the way in.
*
* The two things a `Filter` cannot say are supplied here instead:
* - **the NIP-50 `search`**, which [FilterMatcher] does not read. One [EventSearchMatcher] is
* built per filter and reused across the whole scan; it allocates nothing per note.
* - **viewer policy** the mute list, the kinds no one means to search, encrypted content
* which is the reader's business and not a relay's.
*/
fun findNotesMatching(
filters: List<Filter>,
hidden: LiveHiddenUsers,
): List<Note> {
checkNotInMainThread()
if (filters.isEmpty()) return emptyList()
// Distinct across filters: a union of arms (a hashtag asks #t, #l and the comment tags)
// routinely returns the same note down more than one of them.
val found = LinkedHashSet<Note>()
filters.forEach { filter ->
val search = EventSearchMatcher(filter.search)
cache.filter(filter) { note -> isSearchable(note, hidden) && (search.isEmpty || matchesSearch(note, search)) }.forEach(found::add)
}
return found.toList()
}
private fun matchesSearch(
note: Note,
search: EventSearchMatcher,
): Boolean {
val event = note.event ?: return false
return search.match(event)
}
/** The kinds and authors a reader means to see in results, independent of any query. */
private fun isSearchable(
note: Note,
hidden: LiveHiddenUsers,
): Boolean {
if (excludeNoteEventFromSearchResults(note)) return false
// Encrypted content cannot be matched and must not be offered.
if (note.event?.isContentEncoded() != false) return false
return !note.isHiddenFor(hidden)
}
fun findNotesStartingWith(
text: String,
hiddenUsers: HiddenUsersState,
hidden: LiveHiddenUsers,
): List<Note> {
checkNotInMainThread()
@@ -188,11 +227,11 @@ class CacheSearch(
if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
note.idHex.startsWith(text, true)
) {
return@filter !note.isHiddenFor(hiddenUsers.flow.value)
return@filter !note.isHiddenFor(hidden)
}
if (note.event?.isContentEncoded() == false) {
return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) {
return@filter if (!note.isHiddenFor(hidden)) {
note.event?.content?.contains(text, true) ?: false
} else {
false
@@ -209,11 +248,11 @@ class CacheSearch(
if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
addressable.idHex.startsWith(text, true)
) {
return@filter !addressable.isHiddenFor(hiddenUsers.flow.value)
return@filter !addressable.isHiddenFor(hidden)
}
if (addressable.event?.isContentEncoded() == false) {
return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) {
return@filter if (!addressable.isHiddenFor(hidden)) {
addressable.event?.content?.contains(text, true) ?: false
} else {
false
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/** The cache slot a DVM's heartbeat lives in: the announcement's own address, kind 11998. */
fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent? = getAddressableNoteIfExists(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))?.event as? DvmHeartbeatEvent
/**
* A DVM counts as alive only if its latest heartbeat is at most 900s old. The registry (not the
* WeakReference-held beat note) is the freshness source: beat notes have no strong holder on the
* Discover screen, and a GC sweep cleared them all at once, collapsing the list.
*/
fun LocalCache.hasFreshDvmHeartbeat(
appDef: AppDefinitionEvent,
now: Long = TimeUtils.now(),
): Boolean =
DvmHeartbeatRegistry
.latestAt(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))
?.let { it >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS } == true
/**
* Every cached content-discovery announcement, WITHOUT the freshness gate this is the source the
* heartbeat outbox fetcher must use. Sourcing from the gated feed list would drop a DVM the moment
* its beat went stale, remove it from the fetch batch, and make the drop permanent (the fetcher
* could only ever help DVMs that were already visible). Applies the gate's other eligibility
* checks (a real content-discovery DVM, not a paid subscription app), newest first, capped.
*/
fun LocalCache.cachedDvmAnnouncements(limit: Int = 100): List<AppDefinitionEvent> =
addressables
.filterIntoSet(AppDefinitionEvent.KIND) { _, note ->
(note.event as? AppDefinitionEvent)?.let {
it.appMetaData()?.subscription != true && it.includeKind(NIP90ContentDiscoveryRequestEvent.KIND)
} == true
}.mapNotNull { it.event as? AppDefinitionEvent }
.sortedByDescending { it.createdAt }
.take(limit)
@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.util.concurrent.ConcurrentHashMap
/**
* Strong, process-wide record of the latest heartbeat per DVM announcement address
* (`Address(11998, dvmPubKey, dTag) -> createdAt`).
*
* Beat Notes themselves live in `LocalCache.addressables`, a WeakReference store with no strong
* holder on the Discover screen every GC sweep cleared them all at once and the freshness gate
* collapsed for every DVM simultaneously (the list emptied and rebuilt one beat at a time). This
* registry is the freshness source the gate and the liveness composables read: strong references,
* fed by every beat-arrival path (global REQ, outbox batches, per-surface fetches all beat
* consumption routes through [record]).
*
* One entry per DVM address ever seen; timestamps only, so it stays tiny. `0` means "no beat".
*/
object DvmHeartbeatRegistry {
private val latestBeatCreatedAt = ConcurrentHashMap<Address, MutableStateFlow<Long>>()
private fun flowFor(address: Address): MutableStateFlow<Long> = latestBeatCreatedAt.getOrPut(address) { MutableStateFlow(0L) }
/** Observable latest-beat timestamp for this address; `0` means "no beat seen yet". */
fun flowForPublic(address: Address): StateFlow<Long> = flowFor(address)
/** Records a beat's createdAt; older beats never move the entry backwards. */
fun record(
address: Address,
createdAt: Long,
) = flowFor(address).update { current -> if (createdAt > current) createdAt else current }
/** The latest recorded beat's createdAt for this address, or null when no beat was ever seen. */
fun latestAt(address: Address): Long? = flowFor(address).value.takeIf { it > 0L }
}
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -35,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
@@ -111,6 +115,11 @@ class EventBroadcaster(
val channelRelays = account.cache.getAnyChannel(event)?.relays()
if (channelRelays != null && channelRelays.isNotEmpty()) return false
// A group-scoped event whose room this cache doesn't know yet: it still must not go to the
// broadcast list. Its `h` tag names a room only its host can serve, so broadcasting it says
// "I am in this group" to relays that can do nothing with the content.
if (event.isGroupScoped()) return false
return true
}
@@ -143,6 +152,16 @@ class EventBroadcaster(
return emptySet()
}
// NIP-29 group content, and everything that refers to it — a kind-9 message, a kind-1111 comment,
// a like, a zap request — exists in a room on a host relay and nowhere else. The room's members
// read it there; the author's outbox and the broadcast list can neither serve it to them nor do
// anything else useful with it, and for a private or closed group publishing it there advertises
// who is in which room. So the host wins outright rather than being one more relay in the union.
// Same rule the group reply composer already applies (CommentPostViewModel), applied to every
// group-scoped event instead of just that one path.
val groupHosts = account.cache.relayGroupHostsFor(event)
if (groupHosts.isNotEmpty()) return groupHosts
val includeBroadcast = wantsBroadcastRelays(event)
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
@@ -25,9 +25,15 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.MintDirectoryIndex
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Dao
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites
import com.vitorpamplona.amethyst.commons.model.RelayGroupTargetCandidate
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.UserContext
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzCommunityMembership
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzPresenceState
@@ -36,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.model.buzz.BuzzTypingState
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaceStates
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
@@ -43,17 +50,19 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollTallyPolicy
import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator
import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter
import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter
import com.vitorpamplona.amethyst.commons.model.observables.NoteListMatchingFilter
import com.vitorpamplona.amethyst.commons.model.observables.Observable
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.commons.model.redirectStrayRelayGroupContent
import com.vitorpamplona.amethyst.commons.service.BundledInsert
import com.vitorpamplona.amethyst.commons.service.nwc.NwcPaymentTracker
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.buzz.aeEngrams.EngramEvent
@@ -146,6 +155,9 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.citations.ExternalCitationEvent
import com.vitorpamplona.quartz.experimental.citations.HardcopyCitationEvent
import com.vitorpamplona.quartz.experimental.citations.PromptCitationEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -155,6 +167,9 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent
import com.vitorpamplona.quartz.experimental.library.BlossomPieceIndexEvent
import com.vitorpamplona.quartz.experimental.library.BookshelfDirectoryEvent
import com.vitorpamplona.quartz.experimental.library.LearningResourceEvent
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
@@ -168,6 +183,10 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent
import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent
import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.experimental.ratings.RelayReviewEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -213,18 +232,10 @@ import com.vitorpamplona.quartz.nip18Reposts.BaseRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip19Bech32.entities.Entity
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip19Bech32.entities.NNote
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip19Bech32.isATag
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip25Reactions.ExternalReactionEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent
@@ -307,7 +318,10 @@ import com.vitorpamplona.quartz.nip53LiveActivities.nestsServers.NestsServersEve
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiMergeAcceptanceEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiMergeRequestEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiRedirectEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -367,6 +381,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.dvmHeartbeat.DvmHeartbeatEvent
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryRequest.NIP90UserDiscoveryRequestEvent
import com.vitorpamplona.quartz.nip90Dvms.userDiscoveryResponse.NIP90UserDiscoveryResponseEvent
@@ -541,6 +556,25 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
/** Prefix/content search over users, notes, and channels. */
val search = CacheSearch(this)
// The search entry points of ICacheProvider, so a shared state holder can ask a cache what it
// holds without naming this module. The policy stays in CacheSearch; these are the door.
override fun findNotesMatching(
filters: List<Filter>,
hidden: LiveHiddenUsers,
) = search.findNotesMatching(filters, hidden)
override fun findNotesStartingWith(
text: String,
hidden: LiveHiddenUsers,
) = search.findNotesStartingWith(text, hidden)
override fun findPublicChatChannelsStartingWith(text: String) = search.findPublicChatChannelsStartingWith(text)
override fun findEphemeralChatChannelsStartingWith(text: String) = search.findEphemeralChatChannelsStartingWith(text)
override fun findLiveActivityChannelsStartingWith(text: String) = search.findLiveActivityChannelsStartingWith(text)
fun Filter.match(note: Note): Boolean {
val event = note.event
return if (event != null) {
@@ -550,7 +584,20 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
}
}
fun filter(filter: Filter): SortedSet<Note> {
fun filter(filter: Filter): SortedSet<Note> = filter(filter) { true }
/**
* Every note matching [filter]'s NIP-01 fields that also satisfies [predicate].
*
* [predicate] is where anything the wire type cannot express belongs a NIP-50 `search` the
* matcher does not read, and viewer policy like the mute list, which a relay has no knowledge
* of and a `Filter` therefore has no field for. Keeping it a separate parameter is what lets
* search reuse this path instead of hand-rolling its own scan.
*/
fun filter(
filter: Filter,
predicate: (Note) -> Boolean,
): SortedSet<Note> {
val byKinds = filter.kinds?.filter { it.isAddressable() || it.isReplaceable() }
val addressableMatches =
@@ -561,7 +608,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
byKinds.flatMap { kind ->
byAuthors.flatMap { pubkey ->
addressables.filter(kind, pubkey) { _, note ->
filter.match(note)
filter.match(note) && predicate(note)
}
}
}
@@ -569,13 +616,13 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
// optimized
byKinds.flatMap { kind ->
addressables.filter(kind) { _, note ->
filter.match(note)
filter.match(note) && predicate(note)
}
}
}
} else {
addressables.filter { _, note ->
filter.match(note)
filter.match(note) && predicate(note)
}
}
@@ -583,22 +630,20 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
notes.filter { _, note ->
val event = note.event
if (event != null && event.kind.isRegular()) {
filter.match(event)
filter.match(event) && predicate(note)
} else {
false
}
}
val limit = filter.limit
val all = (addressableMatches + noteMatches).toSortedSet(CreatedAtIdHexComparator)
val limit = filter.limit ?: return all
val limitedSet =
if (limit != null) {
(addressableMatches + noteMatches).take(limit)
} else {
(addressableMatches + noteMatches)
}
return limitedSet.toSortedSet(CreatedAtIdHexComparator)
// Sorted first, then cut. Both halves arrive in hash-walk order, so taking before sorting
// dropped whichever matches the walk happened to reach last — the newest ones as often as
// not — and a query with 200 addressable matches never showed a single regular note.
if (all.size <= limit) return all
return all.asSequence().take(limit).toCollection(sortedSetOf(CreatedAtIdHexComparator))
}
fun observeNotes(filter: Filter): Flow<List<Note>> =
@@ -735,7 +780,9 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key)
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
override fun getRelayGroupChannelsOnRelay(relay: NormalizedRelayUrl): List<RelayGroupChannel> = relayGroupChannels.filter { key, _ -> key.relayUrl == relay }
override fun allRelayGroupChannels(): List<RelayGroupChannel> = relayGroupChannels.values().toList()
/**
* The [RelayGroupChannel] a group-scoped content [note] belongs to, resolved the same way
@@ -749,6 +796,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
return relayGroupChannels.filter { key, _ -> key.id == groupId }.singleOrNull()
}
/**
* Every host relay of the NIP-29 group [event] is scoped to (its `h` tag), or an empty set when the
* event carries no group scope or the group is unknown to this cache.
*
* Keyed by group id alone rather than by [GroupId]: an event about to be *sent* (a reaction, a zap
* request, a comment) knows which room it belongs to but not which relay hosts it that is exactly
* what this resolves. Group ids are relay-minted UUIDs, so the same id on two hosts is a
* theoretical case, and answering with both is the safe reading of it: the content reaches every
* host that claims the room, and none that don't.
*/
fun relayGroupHostsFor(event: Event): Set<NormalizedRelayUrl> {
val groupId = event.groupId() ?: return emptySet()
return relayGroupChannels.filter { key, _ -> key.id == groupId }.mapTo(mutableSetOf()) { it.groupId.relayUrl }
}
fun getLiveActivityChannelIfExists(key: Address): LiveActivitiesChannel? = liveChatChannels.get(key)
fun getNoteIfExists(event: Event): Note? =
@@ -810,6 +872,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,
@@ -845,6 +913,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) }
/**
* Any known channel of Concord community [communityId], or null when we hold none.
*
* A community is not itself a cached object it has no metadata event, only a folded Control
* Plane so its display fields (`communityName`, icon, relays) are carried on every one of its
* channels. Callers that need to *name* a community therefore ask for whichever channel we have.
*/
fun getAnyConcordChannelOfCommunity(communityId: HexKey): ConcordChannel? = concordChannels.filter { key, _ -> key.communityId == communityId }.firstOrNull()
/**
* Lands a decrypted Concord chat rumor in the cache as a real Note and, for
* message-like kinds, attaches it to its channel so the shared chat feed and
@@ -928,7 +1005,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
return Hex.isHex64(key)
}
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? =
override fun checkGetOrCreateAddressableNote(key: String): AddressableNote? =
try {
val addr = Address.parse(key)
if (addr != null) {
@@ -1248,6 +1325,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) }
}
@@ -1864,7 +1957,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) }
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubKey) }
val eventsReported =
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
@@ -1885,7 +1978,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
// report can `p`-tag an incidentally-mentioned third party with no type of its own,
// and there is no threshold here to absorb that noise the way
// `receivedReportsByAuthor`'s hide path does.
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey }
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubKey }
authorsReported.forEach { author ->
if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note)
}
@@ -2317,20 +2410,19 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
}
/**
* A kind-44101 "you were removed from a channel". Consumed like any other Buzz event, then used to
* withdraw any pending add-prompt for that channel: once the relay has taken the membership away
* there is nothing left to accept, so leaving the card up would offer an action that cannot succeed.
* A kind-44101 "you were removed from a channel". Stored like any other Buzz event and nothing more:
* withdrawing the matching add-prompt is not a side effect of ingest but a consequence of the stored
* event, since
* [com.vitorpamplona.amethyst.commons.model.buzz.BuzzChannelInvites.pendingInvites] resolves each
* channel to its newest verdict. That ordering is what makes the two kinds arriving out of order
* routine on a re-subscribe, where the relay replays the whole history produce the same answer as
* them arriving in order.
*/
private fun consume(
event: MemberRemovedNotificationEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean =
consumeBuzzRegularEvent(event, relay, wasVerified).also {
val target = event.target() ?: return@also
val channelId = event.channel() ?: return@also
BuzzChannelInvites.remove(target, channelId)
}
): Boolean = consumeBuzzRegularEvent(event, relay, wasVerified)
/**
* Attach a group-scoped content event (a kind-9 chat, kind-1068 poll,
@@ -2946,6 +3038,11 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
pollNote.pollState().addResponse(responseNote)
// Responses and their poll race each other. If the poll is already here, hand the
// tally its rules now; if it isn't, consume(PollEvent) does it on arrival.
(pollNote.event as? PollEvent)?.let {
pollNote.pollState().updatePolicy(PollTallyPolicy.from(it))
}
}
return new
}
@@ -2953,6 +3050,21 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
return false
}
fun consume(
event: PollEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val new = consumeRegularEvent(event, relay, wasVerified)
attachToRelayGroupIfScoped(event, relay)
// Not gated on `new`: the tally may have been built from responses that arrived before this
// poll did, and updatePolicy is idempotent for the usual re-delivery from another relay.
getOrCreateNote(event.id).pollState().updatePolicy(PollTallyPolicy.from(event))
return new
}
fun consume(
event: FileStorageEvent,
relay: NormalizedRelayUrl?,
@@ -3059,6 +3171,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 -> {
@@ -3078,9 +3200,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
}
@@ -3094,7 +3219,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)) {
@@ -3213,53 +3339,8 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
wasVerified: Boolean,
): Boolean = !event.isDeleted() && consumeBaseReplaceable(event, relay, wasVerified)
fun consume(nip19: Entity) {
when (nip19) {
is NSec -> {
getOrCreateUser(nip19.toPubKeyHex())
}
is NPub -> {
getOrCreateUser(nip19.hex)
}
is NProfile -> {
nip19.relay.forEach { relayHint ->
relayHints.addKey(nip19.hex, relayHint)
}
getOrCreateUser(nip19.hex)
}
is NNote -> {
getOrCreateNote(nip19.hex)
}
is NEvent -> {
nip19.relay.forEach { relayHint ->
relayHints.addEvent(nip19.hex, relayHint)
}
val note = getOrCreateNote(nip19.hex)
if (note.author == null) {
nip19.author?.let { note.author = checkGetOrCreateUser(it) }
}
}
is NEmbed -> {
justConsume(nip19.event, null, false)
}
is NRelay -> {}
is NAddress -> {
val aTag = nip19.aTag()
nip19.relay.forEach { relayHint ->
relayHints.addAddress(aTag, relayHint)
}
getOrCreateAddressableNote(nip19.address())
}
else -> { }
}
override fun consumeEmbedded(event: Event) {
justConsume(event, null, false)
}
override fun justConsumeMyOwnEvent(event: Event) = justConsumeAndUpdateIndexes(event, null, true)
@@ -3528,11 +3609,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
}
}
is PollEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
attachToRelayGroupIfScoped(event, relay)
}
}
is PollEvent -> consume(event, relay, wasVerified)
is ThreadEvent -> {
consumeRegularEvent(event, relay, wasVerified).also {
@@ -3686,6 +3763,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
is CommunityDefinitionEvent,
is CommunityListEvent,
is ContactListEvent,
// DVM heartbeat (11998): stored per Address(11998, author, d) so liveness checks find the
// beat at the announcement's mirror address (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md),
// AND recorded into the strong registry — beat notes are WeakReference-held with no strong
// holder on the Discover screen, so the gate must not depend on them surviving GC.
is DvmHeartbeatEvent,
->
consumeBaseReplaceable(event, relay, wasVerified).also {
DvmHeartbeatRegistry.record(event.address(), event.createdAt)
}
is EmojiPackEvent,
is EmojiPackSelectionEvent,
is EphemeralChatListEvent,
@@ -3765,6 +3851,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
is VideoVerticalEvent,
is WebBookmarkEvent,
is ExerciseTemplateEvent,
is PublicationIndexEvent,
is WikiRedirectEvent,
is LearningResourceEvent,
is BookshelfDirectoryEvent,
is BlossomPieceIndexEvent,
is PublicationContentEvent,
is RelayReviewEvent,
is EntityRatingEvent,
-> consumeBaseReplaceable(event, relay, wasVerified)
// ============================================================
@@ -3840,6 +3934,12 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
is RoadEventConfirmationEvent,
is SealedRumorEvent,
is SoftwareAssetEvent,
is ExternalReactionEvent,
is ExternalCitationEvent,
is HardcopyCitationEvent,
is PromptCitationEvent,
is WikiMergeRequestEvent,
is WikiMergeAcceptanceEvent,
is TextNoteEvent,
is TorrentEvent,
is TorrentCommentEvent,
@@ -56,12 +56,16 @@ object MediaAspectRatioCache : MutableMediaAspectRatioCache {
override fun get(url: String): Float? = entry(url).value
// Both sides are checked, not just the divisor: a zero width divides cleanly to 0f, and 0f is
// just as unusable downstream as a division by zero — `Modifier.aspectRatio` throws on it. A
// reported size that cannot be laid out is stored as no size at all, leaving the entry empty
// for a later, better report to fill.
override fun add(
url: String,
width: Int,
height: Int,
) {
if (height > 1) {
if (width > 0 && height > 1) {
entry(url).value = width.toFloat() / height.toFloat()
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.quartz.nip01Core.core.HexKey
class ParticipantListBuilder {
@@ -60,6 +60,11 @@ data class UiSettings(
// on-chain rail in the Send Payment screen. Defaults to true (shown) so the
// behavior is unchanged for everyone who doesn't turn it off.
val showOnchainWallet: Boolean = true,
// Whether the zap picker offers a NIP-A3 pay-to hand-off chip when the sender
// and recipient share a payment protocol. Defaults to false: those targets can
// be bank or Venmo handles carrying legal names, and this puts them one tap
// from every note in the feed.
val showPayToZapChip: Boolean = true,
)
enum class ThemeType(
@@ -56,6 +56,7 @@ class UiSettingsFlow(
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(true),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -88,6 +89,7 @@ class UiSettingsFlow(
fontSize,
composeSignature,
showOnchainWallet,
showPayToZapChip,
)
// emits at every change in any of the propertyes.
@@ -124,6 +126,7 @@ class UiSettingsFlow(
flows[26] as FontSizeType,
flows[27] as String,
flows[28] as Boolean,
flows[29] as Boolean,
)
}
@@ -158,6 +161,7 @@ class UiSettingsFlow(
fontSize.value,
composeSignature.value,
showOnchainWallet.value,
showPayToZapChip.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -279,6 +283,10 @@ class UiSettingsFlow(
showOnchainWallet.tryEmit(torSettings.showOnchainWallet)
any = true
}
if (showPayToZapChip.value != torSettings.showPayToZapChip) {
showPayToZapChip.tryEmit(torSettings.showPayToZapChip)
any = true
}
return any
}
@@ -333,6 +341,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.fontSize),
MutableStateFlow(uiSettings.composeSignature),
MutableStateFlow(uiSettings.showOnchainWallet),
MutableStateFlow(uiSettings.showPayToZapChip),
)
}
}
@@ -26,17 +26,20 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.marmot.AndroidIngestDedupStore
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPushStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -73,6 +76,13 @@ class AccountCacheState(
val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
/** App-global store of connected NIP-46 client display + relay info. */
val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
/**
* Starts per-account persistence of the Buzz client-side bookkeeping that has no Nostr event to
* rebuild from the joined workspaces and the starred channels (restore now, mirror later
* changes). A lambda because those stores need an Android `Context` and this class deliberately
* takes none; no-op by default so tests and non-Android hosts build an Account without it.
*/
val startBuzzPersistence: (Account) -> Unit = { },
) {
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
@@ -83,6 +93,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()
@@ -104,7 +117,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)
}
}
}
@@ -137,7 +150,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}" }
}
}
@@ -153,7 +166,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}" }
}
}
}
@@ -256,6 +269,45 @@ class AccountCacheState(
null
}
val marmotPublishObligationStore =
try {
AndroidPublishObligationStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidPublishObligationStore " +
"(a Marmot commit interrupted mid-publish will NOT be retried after a restart)",
e,
)
null
}
val marmotIngestDedupStore =
try {
AndroidIngestDedupStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidIngestDedupStore " +
"(every backdated gift wrap will be re-decided on each sync)",
e,
)
null
}
val marmotPushStateStore =
try {
AndroidPushStateStore(accountDir)
} catch (e: Exception) {
Log.e(
"AccountCacheState",
"Failed to initialize AndroidPushStateStore " +
"(a revoked push token could be resurrected by a relayed token list after a restart)",
e,
)
null
}
// Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one
// account never leaks into another (the store used to be a single app-wide file).
val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir)
@@ -275,17 +327,24 @@ 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,
marmotMessageStore = marmotMessageStore,
marmotKeyPackageStore = marmotKeyPackageStore,
marmotPublishObligationStore = marmotPublishObligationStore,
marmotIngestDedupStore = marmotIngestDedupStore,
marmotPushStateStore = marmotPushStateStore,
powQueue = powQueue,
relayAuthPermissionStore = relayAuthPermissionStore,
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
).also { newAccount ->
// Per account, not per device: the joined set makes a relay first-party for NIP-42, so a
// shared one hands every other logged-in account an automatic login on a workspace it
// never joined, and a shared star set reorders everyone's channel list at once.
startBuzzPersistence(newAccount)
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))
}

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