Commit Graph
18314 Commits
Author SHA1 Message Date
Claude d6b8a54d8a NEG-ERR: state the relay's max_sync_events on an overflow refusal
A client that is refused for matching too much has exactly one thing to
decide — how much smaller to ask next time — and no way to find out. NIP-11
has no field for max_sync_events, so the only route to a window the relay
will answer is to guess and halve, and every wrong guess costs the relay the
snapshot scan that produces the refusal. strfry already states the number in
its rejection text; this makes it a first-class part of the frame.

  ["NEG-ERR", <subId>, <reason>]           unchanged, still what NIP-77 says
  ["NEG-ERR", <subId>, <reason>, <cap>]    when the refusal is about size

Both mappers write the fourth element only when there is one, so a refusal
with nothing to state is byte-identical to before, and both tolerate a
non-numeric fourth element from someone else's relay.

NegErrMessage.statedCap reads either form — the wire field or strfry's
"(2431002 > 1000000)" prose — but only for a refusal that is about SIZE.
That gate is the point of the property: a rate limit or a quota can carry
numbers too, and it does not shrink when the window shrinks, so a client
that mistook one for a cap would shrink its windows forever against a relay
that has no size limit at all.

The relay side sends its own configured cap for the same reason it is cheap:
it had to know the number to refuse.
2026-08-06 16:21:32 +00:00
Vitor PamplonaandGitHub 3f1b8ec833 Merge pull request #3869 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-05 21:08:04 -04:00
vitorpamplonaandgithub-actions[bot] f4fc9917f8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-06 00:48:28 +00:00
Vitor PamplonaandGitHub 0c4ea9031b Merge pull request #3870 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk
fix(ime): settle the keyboard in Nav so every screen stops stranding imePadding
2026-08-05 20:45:43 -04:00
Claude 72d05e2eb8 refactor(ime): drop KeyboardAwareBackHandler now that Nav settles
Its job was to stop a composer's pop from racing the IME close animation,
and that pop is `nav.popBack()` in all 11 call sites — exactly what the
ImeSettler on Nav now serializes. So it no longer carries the fix; a plain
BackHandler reaches the same place safely.

What it did still provide was the two-back convention: first back dismisses
the keyboard (via the system's own animation, which on recent Android follows
the gesture), second back leaves. That came at a price it did not used to
have. The mechanism is to NOT consume back while the keyboard is up and let
the IME consume it instead — so on any device or API level where the IME does
not, back reaches the NavController, which pops without ever running onBack
and silently drops the draft, since nothing else saves one. Now that Nav
settles, that failure would also be invisible: no stranded padding to hint at
it, just a missing draft.

A plain BackHandler has no such failure mode. It always consumes, so the
draft is always flushed, on the first back rather than the second.

KeyboardState.kt keeps keyboardAsState(), which is a separate concern —
AppBottomBar uses it to hide the bottom bar while typing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 23:58:24 +00:00
Claude bcbf78d8ea fix(ime): settle the keyboard in Nav so every screen is covered
Leaving a screen while the soft keyboard is still animating strands
`imePadding()` at keyboard height for the whole app — `WindowInsets.ime` is a
single shared holder, so the padding survives leaving the screen that caused
it. PR #3864 fixed this for the post composers, at their call sites. That was
the wrong altitude: Search strands it too, and Search has no BackHandler and
no top bar of ours.

Search is the clearest case: it focuses its field on arrival, so the keyboard
is up before the user has done anything, and every way out is a navigation — a
bottom-nav tab, a tapped result, back. Any destination that can focus a text
field can strand the padding on the way out. There are 174 files with text
input in this module; enumerating the screens was never going to converge.

Two facts make a central fix possible: every in-app navigation goes through
INav (there is not one `controller.navigate` outside navigation/navs/, and
nothing touches OnBackPressedDispatcher, navigateUp or popBackStack directly),
and every Nav method already runs inside `navigationScope.launch`. So Nav
awaits an ImeSettler before each transition: keyboard down, it returns
immediately and nothing changes; keyboard up, it clears focus, hides the IME
and waits for the inset to actually reach zero, bounded, so the two animations
never overlap. ObservableNav delegates to Nav and inherits it.

That subsumes #3864's call-site patches, so they are removed rather than left
as a second mechanism: ActionTopBar goes back to plain callbacks (which also
drops the composition-scoped deferral of onPost, so posting no longer depends
on the top bar staying composed), and KeyboardAwareBackHandler keeps only its
imeAnimationTarget gate — the part that stops back falling through and
silently dropping a draft. It is now a UX preference (let the system animate
the dismissal) rather than the safety mechanism.

NavImeSettleTest pins the ordering: each transition must settle before it
navigates, and a settler that suspends must hold the navigation back rather
than run alongside it. All four fail with the settle calls removed.

Known gap: on a screen with no BackHandler the system's back pops through the
NavController directly, not Nav.popBack(), so a second back landing inside the
~250ms retraction can still race. Closing it needs a shell-level handler
registered after the NavHost to outrank its back callback, which is a
composition-order dependency subtle enough to break silently — worth a
deliberate decision rather than smuggling in here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 23:28:48 +00:00
Vitor PamplonaandClaude Opus 5 32331ecd3d Merge PR: feat(gitRepositories): add ngit-specific search on the Git Repositories screen
Merges nostr proposal 9142a140 (v2) into main:
- feat(gitRepositories): add ngit-specific search on the Git Repositories screen
- refactor(commons): move GitRepositorySearchMatcher to commons/search

Adds a client-side filter to the Git Repositories screen that searches the
fields NIP-34 kind:30617 announcements actually carry — name, `d` identifier,
description, hashtags, clone/web/relay URLs, maintainer and author pubkeys
(hex or npub), and the earliest-unique-commit hash. Before this, the only
search affordance on the screen navigated away to the generic Route.Search,
which matches people/notes/hashtags/channels but never repositories. A filter
icon toggles an inline text field over the feed; the generic search icon stays
beside it. Whitespace-separated terms are ANDed, matching is case-insensitive
substring, and `npub1…` queries are decoded to hex before matching.

The matcher itself lives in commons/search (per commons/ARCHITECTURE.md:
"event search filtering/ranking", non-UI and CLI-safe) rather than in
amethyst, since it is pure platform-agnostic Kotlin over a Quartz event type.
Desktop can reuse it when that screen grows the same affordance. Its test sits
in commons/commonTest on kotlin.test, so it also covers the iOS targets that
source set compiles for.

Verified before merge: :commons:jvmTest 15/15, :commons:verifyKmpPurity,
:commons:compileTestKotlinIosSimulatorArm64, :amethyst:compileFdroidDebugKotlin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:54:58 -04:00
Vitor PamplonaandGitHub 3353e591d0 Merge pull request #3868 from davotoula/fix/file-header-non-media-fallback
fix(media): stop rendering non-media NIP-94 files as video
2026-08-05 18:20:41 -04:00
Vitor PamplonaandClaude Opus 5 221214e545 refactor(commons): move GitRepositorySearchMatcher to commons/search
The matcher is pure platform-agnostic Kotlin over a Quartz event type —
no Compose, no Android, no platform APIs — so per the sharing philosophy
it belongs in commons rather than amethyst. commons/ARCHITECTURE.md
assigns "event search filtering/ranking" to the `search` package
(non-UI, CLI-safe), which is where it lands. The desktop Git
Repositories screen can now use the same matcher instead of growing its
own copy.

The test moves to commons/src/commonTest and swaps org.junit for
kotlin.test, matching every other test in that source set. That gains
iOS coverage for free, and keeps the source set compiling for the
native targets — commonTest is built for iosArm64/iosSimulatorArm64 too,
so a JUnit import there is a build break, not a style nit. The test
builds GitRepositoryEvent from raw tags and never signs, so it needs no
secp256k1 binding.

Also drops `filter()`. It had no caller — the screen filters the loaded
feed itself with `matches` — and its KDoc promised behaviour it never
implemented ("duplicate `d` tags collapse to the newest event"; it did
no deduplication at all). Better to delete the unused API than to ship
a dedup nobody asked for or a doc comment that lies. Its two tests go
with it; the empty-query contract the screen does rely on stays
covered.

Verified: :commons:jvmTest (15/15 in the new location),
:commons:compileTestKotlinIosSimulatorArm64, :commons:verifyKmpPurity,
:amethyst:compileFdroidDebugKotlin, spotlessApply — all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:13:31 -04:00
mstrofnoneandVitor Pamplona ff9855aad6 feat(gitRepositories): add ngit-specific search on the Git Repositories screen
Adds a client-side filter for the Git Repositories page that only searches
fields relevant to ngit repository announcements (kind:30617 NIP-34):
repo name, `d` identifier, description, hashtags/topics, clone URLs, web
URLs, maintainer relays, maintainer/author pubkeys (accepting both hex and
`npub…` bech32 in the query), and the earliest-unique-commit hash.

Before this change, the only search affordance on the screen was the
generic Nostr search icon that navigated away to `Route.Search`, which
matches people, notes, hashtags, and channels — none of which are ngit
repositories. Users who wanted to find a repo they'd already discovered
had to scroll through the full follow-list-scoped feed.

UX:
- A filter icon in the top bar toggles an inline `OutlinedTextField`
  directly above the feed. First-appearance focus opens the keyboard
  without a second tap.
- The general search icon is preserved beside the filter icon so
  outbound searches still work.
- While filtering, results render with the same `NoteCompose` cells the
  feed uses so every affordance (bookmark, open, share) still works.
- Filtered rendering uses a scoped `LazyListState` because the item-key
  set of the filtered list is not stable against the feed's cached
  scroll offset; sharing them would jump the user to an unrelated repo.
- Closing the filter icon clears the query, restoring the full feed
  in one tap.

Filter semantics:
- Whitespace-separated terms are ANDed against each repo (`amethyst
  nostr` matches only repos that carry both terms in some indexed field).
- Case-insensitive substring match on each indexed field.
- `npub1…` queries are decoded to hex before matching, so a maintainer
  can be found by either encoding.

Tests: `GitRepositorySearchMatcherTest` (17 hermetic cases) pins every
indexed field, plus the "empty query returns nothing / filter blank
returns everything" contract that the caller relies on to skip the
filter path.

Build check: `./gradlew :amethyst:compileFdroidDebugKotlin
:amethyst:testFdroidDebugUnitTest --tests
'com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositorySearchMatcherTest'
:amethyst:spotlessCheck` all green.
2026-08-05 18:08:45 -04:00
Vitor PamplonaandClaude Opus 5 372964194d Merge PR: ci: publish windows-arm64 desktop + windows amy/geode release assets
Merges nostr proposal 3ac62492 (v2) into main:
- ci: publish windows-arm64 desktop + windows amy/geode release assets
- ci(release): build windows-arm64 desktop as a portable zip only

Extends the release matrix to Windows on Arm using the free public-repo
windows-11-arm runner, and adds Windows legs (x64 + arm64) to build-cli and
build-geode. amyImage / geodeImage now emit both a POSIX launcher and a
.bat launcher so the flat image layout is uniform regardless of build host;
collect_cli_assets / collect_geode_assets package it as .zip on Windows and
tar.gz everywhere else.

The arm64 desktop leg ships the portable .zip only — no MSI. jpackage
--type msi shells out to WiX 3's heat/candle/light and the Windows 11 Arm64
runner image has no WiX (windows-latest has WiX 3.14 preinstalled, which is
why the x64 leg still packages an MSI). Installing it would mean pulling an
archived, x86-only toolchain (wixtoolset/wix3 archived Feb 2025; WiX 4+
dropped the candle/light CLI jpackage drives) into the job that publishes
signed release assets.

BUILDING.md now enumerates the expected release assets per matrix leg
instead of carrying a stale total: 14 desktop + 13 Android + 10 amy +
10 geode = 47.

The arm64 runner legs themselves are first exercised by the next real
release run; nothing about them can be verified harder locally, since
jlink/jpackage cannot cross-compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:54:54 -04:00
Vitor PamplonaandClaude Opus 5 f3104f0a6c ci(release): build windows-arm64 desktop as a portable zip only
The windows-11-arm leg added by the previous commit runs
`packageReleaseMsi`, which cannot succeed on that runner: jpackage
--type msi shells out to WiX 3's heat.exe / candle.exe / light.exe
(JDK 21 jpackage guide names WiX 3.11.1), and the Windows 11 Arm64
runner image ships no WiX at all.

Verified against actions/runner-images:
  images/windows/Windows2025-Readme.md    -> "WiX Toolset 3.14.1.8722"
  images/windows/Windows11-Arm64-Readme.md -> no WiX entry
(7zip 26.02, Python 3.13 and Java 21 aarch64 ARE present on the arm64
image, so the rest of the leg — createReleaseDistributable, the 7z
portable zip, collect_assets — is unaffected.)

Installing WiX in the job instead was the alternative and is worse:
wixtoolset/wix3 was archived in Feb 2025, WiX 4+ replaced the
candle/light CLI that jpackage drives with `wix build`, and the WiX 3
binaries are x86-only (emulated on arm64). That would mean pulling an
archived, unpinned third-party toolchain into the job that publishes
signed release assets, for one asset we already ship in portable form.

So: arm64 Windows gets the portable .zip, which is already the
documented Windows install path for amy and geode. The x64 leg is
untouched and still produces the MSI.

collect_assets needs no change — it globs with nullglob and skips the
absent msi/ directory.

BUILDING.md: replace the release-verification asset count, which this
branch had left vague ("5 formats x 2 arches shipped as one merged set
of 5 ... see the previous release"), with a per-leg enumeration counted
off the matrix: 14 desktop + 13 Android + 10 amy + 10 geode = 47. Also
corrects the Windows prerequisites note, which claimed CI produces
arm64 MSIs natively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:31:52 -04:00
mstrofnoneandVitor Pamplona cd2ce05ee8 ci: publish windows-arm64 desktop + windows amy/geode release assets
Follow-up to the linux-arm64 CI leg (feat/release-linux-arm64). Extends the
release matrix to Windows in three places, all on free public-repo hosted
GitHub runners:

* build-desktop: adds windows-11-arm (arm64) alongside the existing
  windows-latest (x64). jpackage/jlink on Windows arm64 produce arm64 MSIs
  natively; the same packageReleaseMsi + createReleaseDistributable task
  list is used unchanged and the portable-archive step already parameterises
  on ${{ matrix.arch }}.

* build-cli: adds windows-latest (x64) and windows-11-arm (arm64) legs
  running :cli:amyImage. Windows has no jpackageDeb/Rpm and MSI-for-CLI is
  deferred (portable zip is the documented Windows install path); the
  headless-lib assertion runs unchanged under git-bash. amyImage now emits
  both a POSIX `bin/amy` shell launcher AND a Windows `bin/amy.bat`
  launcher into the flat image so the tree layout is uniform regardless of
  build host. The .bat pins UTF-8 (chcp 65001) for sun.jnu.encoding, same
  reason the installDist .bat was already patched.

* build-geode: adds windows-latest + windows-11-arm legs running
  :geode:geodeImage. The existing --port smoke test is generalised to pick
  bin/geode.bat on Windows; NIP-11 fetch via curl works unchanged under
  git-bash on GH windows runners. Same dual-launcher pattern as amy.

scripts/asset-name.sh: collect_cli_assets and collect_geode_assets now
package the flat image as .zip on Windows (7z when available, falling
back to `zip`, then a portable python3 zipfile.ZipFile invocation). Every
other OS continues to use tar.gz. Adds the expected Windows examples to
the header block.

BUILDING.md: mentions the windows-11-arm runner and updates the asset
count in the Release runbook. No asset-naming contract changes — the
existing amethyst-desktop-<v>-windows-<arch>.<ext>, amy-<v>-windows-<arch>.zip,
and geode-<v>-windows-<arch>.zip shapes were already in scope, they just
weren't produced by any CI leg before.

Local validation on macOS arm64 (build host: JDK 21, gradle 9.5.0):
  ./gradlew :cli:amyImage     -> bin/amy + bin/amy.bat both present
  ./gradlew :geode:geodeImage -> bin/geode + bin/geode.bat both present
  ./bin/amy --help            -> parses (unix launcher unbroken)
  ./bin/geode --port 17447    -> NIP-11 served, "supported_nips" present
  collect_cli_assets windows arm64 ...   -> valid .zip with bin/amy.bat
  collect_geode_assets windows x64 ...   -> valid .zip with bin/geode.bat
  actionlint .github/workflows/create-release.yml   -> no new findings
  Cross-compile is impossible for jlink/jpackage, so end-to-end
  Windows-runtime validation still happens on GH CI on the first PR
  build; nothing in this change can be verified any harder locally.
2026-08-05 17:31:52 -04:00
Vitor PamplonaandClaude Opus 5 79d7d27198 Merge PR: ci(release): add libegl1 to arm64 .deb Depends
Merges nostr proposal 948bc249 into main:
- ci(release): add libegl1 to arm64 .deb Depends

The aarch64 skiko native (libskiko-linux-arm64.so) has libEGL.so.1 in
DT_NEEDED, unlike the x86_64 build which links only libGL.so.1. jpackage
generates deb Depends from dpkg-shlibdeps over the bundled JRE under
lib/runtime/ only, never the app payload under lib/app/, so the arm64 .deb
never listed libegl1 and Amethyst died at startup on minimal aarch64
installs with UnsatisfiedLinkError: libEGL.so.1.

scripts/add-deb-libegl-dep.sh rewrites the .deb after the fact — same
approach as the existing scripts/relax-deb-libicu.sh. It only touches
payloads that actually contain libskiko-linux-arm64.so and is idempotent;
the workflow step is gated on matrix.arch == 'arm64' so the x64 .deb is
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:31:25 -04:00
Vitor PamplonaandGitHub a02066a700 Merge pull request #3866 from davotoula/feat/configurable-left-drawer
Make the left drawer configurable
2026-08-05 17:04:18 -04:00
David KasparandGitHub fdbad9396f Merge pull request #3867 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-05 22:37:37 +02:00
davotoulaandgithub-actions[bot] 5c18cee6ad chore: sync Crowdin translations and seed translator npub placeholders 2026-08-05 20:29:50 +00:00
davotoula 3565f75847 Code review:
- gate the file-attachment card
- move prettyMime to commons
- add tests
2026-08-05 22:29:07 +02:00
davotoula 0cf1534861 fix(media): stop rendering non-media NIP-94 files as video
A kind-1063 file header was classified by a binary `isImage` test: anything
that wasn't an image fell through to MediaUrlVideo. A webxdc app
(application/x-webxdc, a zip) therefore reached ExoPlayer and buffered
forever, as did every archive, installer and — since MediaUrlPdf was never
constructed here — every NIP-94 PDF.
2026-08-05 22:28:29 +02:00
davotoula 64019dbd7c update cs,pt,de,sv 2026-08-05 22:21:47 +02:00
davotoula 93fe3ac727 Code review:
- fold the pickers onto shared row/expand-state UI
2026-08-05 21:30:38 +02:00
Claudeanddavotoula dda0f0d9e8 Make the left drawer configurable
style: apply spotless to the configurable drawer code
docs: record why the settings state holders are deliberately unkeyed
refactor: address cleanup review of the configurable drawer
fix: rename the shared picker section header to avoid an overload clash
2026-08-05 21:30:38 +02:00
Vitor PamplonaandGitHub 9373c0a22c Merge pull request #3861 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-05 14:35:23 -04:00
Vitor PamplonaandGitHub a6f41072a9 Merge pull request #3863 from vitorpamplona/feat/suspend-subscription-onevent
Suspend the incoming-message chain down to SubscriptionListener.onEvent
2026-08-05 14:35:03 -04:00
vitorpamplonaandgithub-actions[bot] d9ea3ef86a chore: sync Crowdin translations and seed translator npub placeholders 2026-08-05 18:33:02 +00:00
Vitor PamplonaandGitHub 673184dd7b Merge pull request #3864 from vitorpamplona/claude/thread-reply-ime-padding-j7c9rk
Replace BackHandler with KeyboardAwareBackHandler in posting screens
2026-08-05 14:29:46 -04:00
Claude 8b4220019a fix(ime): close the two remaining stuck-padding paths
Two exits still popped a composer while the IME was mid-animation — the race
that strands imePadding() at keyboard height app-wide.

1. Top-bar X and Post. KeyboardAwareBackHandler only guards the back gesture;
   ActionTopBar wired both buttons straight to nav.popBack(), with nothing
   dismissing the keyboard first. Tapping either while typing reproduced the
   original bug exactly. The earlier fix leaned on the back arrow as the
   "always-available exit" without noticing it was also a race source.

2. A ~250ms hole in the back gate. It read the animated WindowInsets.ime,
   which stays above zero for the whole close animation — a window in which
   the IME had already stopped consuming back but the handler was still
   disabled, so a second back fell through to the NavController and popped
   without ever running onBack. That silently dropped the draft the handler
   exists to save: nothing else saves it, onCleared() only closes the writing
   assistant and there is no autosave.

Both are the same underlying requirement — serialize the IME and window
animations instead of overlapping them — so both now route through one
helper, rememberAfterKeyboardCloses(): keyboard down, the action runs inline
and nothing changes; keyboard up, clear focus, hide, wait for the inset to
actually reach zero, then act. The wait is bounded so a stale inset (the very
failure being guarded) can never trap the user on screen, and re-entrant calls
are dropped since the deferral widens the window for a double-tap on Post to
fire twice.

The back gate now reads WindowInsets.imeAnimationTarget, which flips to zero
the moment the hide begins, so back keeps reaching onBack throughout the
animation. Re-enabling that early means onBack can fire mid-animation, which
is exactly what the helper absorbs.

Not covered: this is verified by compile and the unit suite only. The race
reproduces on release builds on a device, which this environment cannot run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 17:40:03 +00:00
Vitor PamplonaandGitHub d6e78a7857 Merge pull request #3862 from vitorpamplona/fix/sync-coverage-per-kind-bands
SyncCoverage: one band interval cannot speak for several kinds
2026-08-05 13:32:14 -04:00
Vitor PamplonaandClaude Opus 5 bb95cad98b Audit fixes: one clock per record, no shared mutable list, pin the file format
Deep-audit pass over the branch. Nothing here changes what a band claims;
these are the defects that pass tests and bite later.

- record() read the clock twice PER KIND. A 40-kind map took 80 readings,
  and worse, a span's floor and ceiling were judged against two different
  instants — so a span could be accepted at one end and rejected at the
  other on a clock tick. One read, one instant, for the whole call. The
  aggregate path had the same double read and now shares it.

- legs() handed the SAME MutableList instance to every Filter in a group,
  publishing its accumulator through a public return value. Filters are
  treated as immutable everywhere else; this keeps that true by
  construction rather than by nobody having tried yet.

- The state file's round trip was asserted only for the fields, never for
  the behaviour. Three tests now pin it: per-kind spans survive a restart
  AND still narrow per kind afterwards; the ALL_KINDS sentinel survives
  its negative key through toString/toInt; and a pre-split file (min/max,
  no spans) loads as the claim it always was. Plus the rollback contract
  — `min`/`max` must remain the OUTER edges, since a binary from before
  per-kind spans reads those and would otherwise skip ground it has not
  covered.

Checked and found sound, recorded so the next reader need not re-derive
it: ConcurrentMap.snapshot() copies, so export() cannot be mutated under
a writer; Band is immutable (widen() copies its map), so a shared Band
across threads is safe; merge() keeps old.fullAt, preserving the
re-walk clock across widening; and coveringWindow does NOT regress —
a paged band gave >1 leg before this change too, and a reconciled band
still collapses to one leg and narrows the shared snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:09:45 +00:00
Vitor PamplonaandClaude Opus 5 a3fac4fc08 Suspend the incoming-message chain down to SubscriptionListener.onEvent
A consumer that cannot suspend has to block, and blocking here deadlocks
the whole client.

Measured on a mirror built against this library, twice, ~13 minutes after
each start: all 64 shared coroutine workers parked in `runBlocking` beneath
`trySendBlocking`, called from the websocket message callback. The consumer
draining that channel needed threads from the same pool to reach its store,
so it could never make room, so the producers never woke. Every stream, the
health reporter, all of it stopped, at 2% CPU with a healthy, idle backend.
A full queue was the symptom; producers eating the threads the drain needed
was the cause.

The coroutine context was already there — BasicOkHttpWebSocket has always
processed messages inside `scope.launch { for (message in incomingMessages) }`
— so the only thing forcing a blocking hand-off was that the hops in between
were declared non-suspend. Now they are not:

    WebSocketListener.onMessage
    RelayConnectionListener.onIncomingMessage
    PoolRequests/PoolCounts/PoolEventOutbox.onIncomingMessage
    SubscriptionListener.onEvent
    fetchAllPages / negentropy accessories' onEvent parameter

A consumer that fills its buffer now suspends and releases its thread rather
than holding it, which is the same reasoning BasicOkHttpWebSocket already
documents for keeping its own channel UNLIMITED so a slow consumer cannot
block OkHttp reader threads. This extends it one layer down.

BLE is the one transport whose callback genuinely cannot suspend — the
platform hands notifications to a plain callback — so BleNostrClient gets
the same treatment the websocket transport already had: an UNLIMITED
hand-off channel so the BLE stack is never blocked, drained by ONE coroutine
so message order survives the boundary.

Tests that drove these entry points directly now do so from `runTest`, or
from `runBlocking` where the call sits inside a raw thread or Runnable that
models a platform callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:45:02 -04:00
Vitor PamplonaandClaude Opus 5 74145ee8f3 Code-review fixes: two ways per-kind spans could be recorded and not used
Both found in the review pass over the previous commit, both the same
shape — a band written that no lookup can reach, or reaches wrongly.

- Spans for kinds the filter never named were stored as given. Inert for
  legs(), which only looks up the filter's own kinds, but NOT for
  Band.minCreatedAt — and that is what SyncCoverageFile writes as its
  rollback-compat `min`/`max`. A relay answering with more than it was
  asked for (or a caller whose containment check runs against a
  different filter than the band is keyed by) would push that floor
  below anything the filter's kinds support, so a binary from before
  per-kind spans would read the file and over-claim. The fix, undone
  through the compatibility path it added.

- observedByKind on a filter that names NO kinds was stored per kind,
  while legs() for such a filter reads only ALL_KINDS. The band was
  recorded, persisted, and never consulted: a resume that silently did
  not resume. Collapsed to the union, which is the only claim a
  kind-less filter can make.

Why these were not in the initial diff: both live where the new per-kind
path meets an OLD assumption — that record()'s input is already scoped
to the filter, and that a band's keys are always the filter's kinds.
Neither held once callers began supplying the map themselves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:08:56 +00:00
Vitor PamplonaandClaude 42a91ffb79 SyncCoverage: one band interval cannot speak for several kinds
A band held ONE created_at interval per (relay, filter). For a filter
naming several kinds that is a claim no walk can support: ask for
`kinds: [0, 30382]`, find profiles going back years and score cards only
from last month, and the band records 2020..now for the pair. The next
run then skips that whole interior for BOTH — so score cards written
inside it are never asked for again, and nothing anywhere says so. A
long-lived kind vouched for a short-lived one.

Band.spans is now per kind. Each carries only the evidence actually
collected for it, so the profile kind keeps its wide interval and the
score kind keeps its narrow one, and legs() re-opens the interior for
the second while still skipping it for the first.

Three things keep the cost of that where it was:

- legs() REGROUPS kinds by the windows they want. Identical coverage —
  the common case, and the only case until they diverge — collapses back
  into one ask, so a filter that produced two legs still produces two
  rather than two per kind. Only a kind whose evidence genuinely differs
  earns its own.
- A finished reconcile needs no per-kind evidence and is given none:
  negentropy compares the filter's whole id set in one pass, so it
  covers every kind in the filter or none. Only the PAGED path changed.
- Filters naming no kinds keep a single span under ALL_KINDS, which is
  the same claim as before, correctly scoped to the case where it is the
  only claim available.

record() takes observedByKind, and SyncCoverage.observe() accumulates it
as events arrive — replacing the pair of hand-rolled vars each caller
kept, and moving the per-event isPlausible guard in with it. A paged
walk over a MULTI-kind filter that supplies none earns no band at all,
loudly, once: attributing one interval to every kind is exactly the
over-claim this removes, and a band that over-claims skips events
silently, which is worse than re-reading them. Single-kind filters are
untouched — there the aggregate always was the per-kind answer.

The state file gains a per-kind `spans` object and keeps `min`/`max` as
the outer edges, so a rollback to a binary from before this reads the
file and behaves as it always did. A file written BEFORE this loads its
one interval under ALL_KINDS — the old, wider claim, kept rather than
discarded because discarding it would re-download every upstream's
corpus once on upgrade. The first per-kind walk replaces it.

All 26 existing SyncCoverage tests pass unchanged, which is the evidence
that single-kind behaviour did not move. The five new ones were checked
against the pre-fix rule reinstated in place: the two behavioural ones
fail there and pass here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:02:26 +00:00
Claude 9ac033effc fix(ime): use the keyboard-aware back handler in the post composers
The thread reply composer (and every other full-screen draft-saving editor)
still consumed back with a raw `BackHandler`, so `KeyboardAwareBackHandler` —
added for exactly this case — only protected the three chat composers.

Popping the screen while the keyboard is still up races the predictive-back
window animation against the IME close animation. When the window animation
wins, the IME `WindowInsetsAnimationCompat` is cancelled before its terminal
zero frame reaches Compose, the shared `WindowInsets.ime` holder stays
"animating", and every `Modifier.imePadding()` freezes at keyboard height —
the keyboard vanishes but its padding stays behind, even after leaving
the screen.

Switching these composers to `KeyboardAwareBackHandler` lets the first back
(or back-swipe) fall through to the system, which dismisses the keyboard with
its own animation that completes cleanly; the next back saves the draft and
pops as before. The top bar's cancel arrow remains an always-available exit.

Covers `ShortNotePostScreen` (which also backs `PollPostScreen`),
`GenericCommentPostScreen`, `LongFormPostScreen`, `NewProductScreen`,
`NewPublicMessageScreen`, `NewGoalScreen`, `NewWorkoutScreen` and
`AwardBadgeScreen`. `VoiceReplyScreen` keeps the plain handler — it has no
text input or `imePadding()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LfUMGWYu2uTSyh17JJonfN
2026-08-05 15:41:29 +00:00
Vitor PamplonaandGitHub e822911d09 Merge pull request #3860 from vitorpamplona/claude/bridge-relay-url-parsing-okxtyw
Fix URL detection to exclude quotes from parsed URLs
2026-08-05 10:44:28 -04:00
Claude 5c56444054 fix: strip the whole punctuation tail from a detected url
Audit follow-up to the quote fix. The path, query and fragment readers only
stop on a space, and readEnd dropped a single trailing delimiter, so a quoted
link that closed a sentence kept its quote:

  He linked "https://example.com/some/path".  ->  https://example.com/some/path"
  (see "https://example.com/some/path")       ->  https://example.com/some/path"

readEnd now strips the tail in a loop. The balance check runs on every round,
so a url that legitimately ends in a matched closer still stops the strip:
`[link](…/Bitcoin_(disambiguation)).` keeps `(disambiguation)` and drops the
`).` that belongs to the sentence.

Differential run over a 4000-string corpus against the previous commit: 8 rows
change, every one of them the removal of extra trailing punctuation. No url is
gained, lost or truncated mid-string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
2026-08-05 14:28:24 +00:00
Claude dc45477deb fix: don't glue quotes onto detected urls
A bare host wrapped in quotes ("relay.momostr.pink") was detected with the
opening quote attached, so the rendered link read `"relay.momostr.pink` and
pointed at a host that does not exist. The mirror case was also wrong: a
quoted url with a path/query/fragment kept the closing quote, because those
readers only stop on a space.

Quotes are not host characters, so they now end the current token exactly
like a space does in readDefault (covering the leading quote and a quote
glued to a previous word, e.g. `href="www.google.com"`), and they were added
to CANNOT_BEGIN_URLS_WITH / CANNOT_END_URLS_WITH so a trailing quote read as
part of a path, query or fragment is stripped on readEnd. The set covers the
ascii quotes plus the typographic family, including the guillemets below the
international-character threshold that the ascii boundary rule never cut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKCAegYMF9V9Nb8FHcMvT7
2026-08-05 14:08:01 +00:00
mstrofnone f1d2bdee49 ci(release): add libegl1 to arm64 .deb Depends
The compose-desktop skiko native shipped under
${app}/lib/app/libskiko-linux-arm64.so declares libEGL.so.1 in
DT_NEEDED — the aarch64 skiko uses EGL alongside GLX, unlike the
x86_64 skiko which only links libGL.so.1.

jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
against the bundled JRE under lib/runtime/, NOT the app payload under
lib/app/. As a result the arm64 .deb produced by the newly-added
linux-arm64 CI leg lists libgl1/libglvnd0/libglx0 in Depends but not
libegl1. On minimal aarch64 installs — Armbian Server + a lightweight
WM, Raspberry Pi OS Lite + LXDE, or any distro base image without an
EGL implementation pulled in transitively — Amethyst desktop crashes
at startup with:

  Exception in thread "main" org.jetbrains.skiko.LibraryLoadException:
    Failed to loade library …/libskiko-linux-arm64.so
  Caused by: java.lang.UnsatisfiedLinkError:
    libEGL.so.1: cannot open shared object file: No such file or directory

Neither jpackage nor the Compose Multiplatform 1.11 DSL exposes a way
to add extra deb Depends, so we rewrite the .deb after the fact —
same approach as scripts/relax-deb-libicu.sh (which handles the
libicu SONAME divergence across Debian/Ubuntu releases).

scripts/add-deb-libegl-dep.sh only touches .debs whose payload
actually contains libskiko-linux-arm64.so, and is idempotent (skips
if libegl1 is already listed). The workflow step is gated on
matrix.arch == 'arm64' so the x64 .deb is untouched (its skiko does
NOT NEED libEGL and its GLX-only path stays as-is).

Local validation on Apple Silicon (native linux/arm64 in Docker):
  1. Rebuilt v1.13.1 arm64 .deb from a110ce0a30's CI leg.
  2. readelf -d libskiko-linux-arm64.so | grep NEEDED
     → confirms libEGL.so.1
  3. Ran scripts/add-deb-libegl-dep.sh over the .deb; Depends line
     now ends `..., zlib1g, libegl1`. Idempotent on re-run.
  4. `apt-get install -y -f ./amethyst_*.deb` in a base
     eclipse-temurin:21-jdk-noble aarch64 container (which lacks
     libegl1 by default) now pulls libegl1 as a dep.
  5. Amethyst launches under Xvfb, `xwininfo -root -tree` shows the
     1200×800 "Amethyst" window + Content window + sun-awt-X11-XCanvasPeer
     Skia canvas. No UnsatisfiedLinkError.
2026-08-05 15:56:29 +10:00
Vitor PamplonaandClaude Opus 5 f9bef87160 style(desktop): import Compose symbols in NotificationSettingsScreen
Follow-up to 8f8713d8 (nostr proposal 259a0bb1). CLAUDE.md forbids
fully-qualified class names inline in function bodies; the merged
proposal introduced one (androidx.compose.runtime.LaunchedEffect) and
the file already carried four more that predate it. Import them all and
reference them by simple name: LaunchedEffect, snapshotFlow,
rememberCoroutineScope, LocalWindowInfo.

Also rewrites two comments the proposal added:
- the auto-enable comment was written in the first person and described
  the author's own earlier mistake; restate it as what the code does and
  which two paths it covers.
- the "Turn on desktop notifications" comment claimed the button renders
  only when the user explicitly disabled notifications, but the guard is
  `!enabled` alone. Describe the actual condition and why it is enough.

Drops a redundant `enabled = true` on that OutlinedButton (the default).

No behaviour change. :desktopApp:compileKotlin and :commons:jvmTest green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:49:30 -04:00
Vitor PamplonaandGitHub a58289b62c Merge pull request #3859 from vitorpamplona/claude/yggdrasil-ipv6-compat-03mctx
Support IPv6 overlay relays (Yggdrasil) with RFC 5952 canonicalization
2026-08-05 00:36:13 -04:00
Claude 70d51cc98b fix(relay): parse the authority instead of substring-matching the url
Audit of the IPv6 work found a family of bugs in isLocalHost/isOnion, most
predating this branch, all with one root cause: the predicates ran `contains`
over the whole url rather than parsing the authority. These decide whether a
relay is exempt from Tor, and relay urls arrive from other people (NIP-65
lists, relay hints, r tags), so they are attacker-controlled input.

- A path could impersonate the host. `wss://evil.example.com/127.0.0.1`
  answered isLocalHost() == true, so any relay list could hand the app a url
  that silently dropped its own Tor routing. The IPv6 lookup added earlier on
  this branch had the same flaw via `/[fd00::1]`, and IPv6 canonicalization
  could rewrite a path outright, corrupting the url.

- `.onion:8080` never matched the `.onion/` test, so an onion relay on an
  explicit port was not treated as onion at all: never forced onto Tor, and its
  hostname went to the clearnet DNS resolver. The fully-qualified `.onion.`
  spelling missed the same way.

- Host tests were case-sensitive, but fix() asks them before the RFC 3986 pass
  folds case, so LOCALHOST:8080 and ABC.ONION:8080 were handed a wss:// scheme
  neither host can serve.

- Private IPv4 was substring-matched, which missed 10.0.0.5, 172.16.3.4 and
  127.1.2.3 — a LAN relay got wss:// and was dialed through Tor — while
  matching 192.168.evil.com and 127.0.0.1.evil.com, registrable domains that
  could therefore exempt themselves from Tor. Same for notlocalhost.example.com
  against `contains("localhost")`.

- A `://` inside a path was read as a scheme separator, so
  `relay.com/x://127.0.0.1` read its path as the authority.

Fixes: a shared hostStart/hostEnd/hostEndWithoutPort trio bounds every test to
the authority, strips :port and trailing dots and validates the scheme; private
ranges are parsed via a new Ipv4 util rather than substring-matched;
comparisons are case-insensitive per RFC 4343; NormalizedRelayUrl.isOnion()
delegates instead of keeping a second, weaker copy of the test.

No performance regression: the old form ran six full-string scans, the new one
bounds its work to the authority and rejects a DNS host from an IP parse on one
character. Ipv6.isLiteral gained a two-colon gate so the schemeless host:port
case answers without allocating the parser's buffer.

Ipv6 is now pinned by a differential test: 4000 random addresses round-trip
against java.net.InetAddress in both directions, and the canonical form is
asserted equal to OkHttp's host for the same address, so the relay identity the
app stores provably matches the host it dials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-05 04:22:59 +00:00
Claude 067d68b89c feat(relay): canonicalize IPv6 relay urls and support overlay meshes
Closes the four gaps the previous commit characterized for relays on an
Yggdrasil overlay, where every relay is an IPv6 literal in 0200::/7 served
over plain ws:// (no DNS, no CA-issuable certificate).

New quartz/utils/Ipv6.kt: pure-Kotlin literal parsing, RFC 5952 canonical
formatting and range classification. No java.net, so it works on every KMP
target.

- Canonicalize the bracketed host in RelayUrlNormalizer.norm(). RFC 4291 lets
  one address be spelled many ways and the RFC 3986 pass only folded hex case,
  so two spellings survived as two NormalizedRelayUrl values for one host —
  and that value keys the connection pool, the relay-list sets, the NIP-11
  cache and the per-relay stats, so the app dialed one relay twice. The
  canonical form matches what OkHttp renders when it dials; the tests assert
  that agreement differentially. Relay lists rehydrate through normalizeOrNull,
  so stored entries fold on load and no migration is needed.

- Add isOverlayNetwork() for 0200::/7 and default those relays to ws://:
  nothing can issue a certificate for the range, so wss:// could only fail its
  handshake, and the overlay already encrypts end to end.

- Teach isLocalHost() the IPv6 twins of the literals it already knew — ::1,
  fc00::/7 and fe80::/10 — so a relay on one skips TLS and Tor and stays out of
  published relay lists, as its IPv4 equivalent already did.

- Never route an overlay relay through Tor: the range is unroutable there, so
  proxying guaranteed failure rather than privacy. TorRelayEvaluation covers
  both the Android and desktop relay paths; RoleBasedHttpClientBuilder covers
  non-relay HTTP.

- Bracket a bare IPv6 literal automatically (what yggdrasilctl getSelf prints),
  but only when the whole string parses as an address, so host:port and
  addressable pointers still fall through. RelayUrlEditField now shows an error
  instead of no-opping, fixing the silent Add button for all invalid input.

Mesh relays are still published in NIP-65 and offered by the outbox model; the
plan doc explains why that is left as a maintainer's call, and records that no
live socket test was possible here (the container has no IPv6 stack).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-04 22:28:07 +00:00
Claude 129401bdaf test(relay): characterize Yggdrasil/IPv6 relay handling
Assesses how the app fares when relays live on an Yggdrasil overlay, where
every relay is a bracketed IPv6 literal in 0200::/7 served over plain ws://
(no DNS, no CA-issuable certificate).

The happy path works: a hand-typed ws://[...]:port normalizes, survives the
RFC 3986 pass and is dialed by OkHttp; nothing in the stack is IPv4-only and
cleartext is already permitted globally.

Four gaps are pinned by the new characterization tests:

1. RelayUrlNormalizer folds hex case but not zero-compression, so two legal
   spellings of one address yield two NormalizedRelayUrl values while OkHttp
   collapses them to one host — duplicate sockets, REQs and stat entries.
2. isLocalHost() does not know 0200::/7, so a schemeless literal defaults to
   wss:// and can only fail its TLS handshake.
3. An unbracketed literal (what yggdrasilctl getSelf prints) is rejected, and
   RelayUrlEditField.submitRelay has no else branch — the Add button silently
   does nothing.
4. TorRelayEvaluation classifies mesh relays as "new", so with Tor on they are
   dialed through the SOCKS proxy, which cannot route 0200::/7.

No behavior is changed. quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md records
the full assessment, the NIP-65/outbox propagation consequences of publishing a
key-derived mesh address, and what could not be verified here (the analysis
container has no IPv6 stack, so nothing below the socket was exercised).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk
2026-08-04 21:36:22 +00:00
Vitor PamplonaandClaude Opus 5 8f8713d825 Merge PR: fix(desktop): auto-enable master notif switch when OS permission already granted
Merges nostr proposal 259a0bb1 into main:
- fix(desktop): auto-enable master notif switch when OS permission already granted

Follow-up to e9475dd0, which only flipped the master notification switch on
the NotRequested -> Granted path. Adds NotificationSettings.wasExplicitlyDisabled()
(backed by a new java.util.prefs "explicitly_disabled" key) so the Settings
screen can tell "off because it defaults off on first launch" from "off
because the user turned it off", and a LaunchedEffect that auto-enables the
switch in the former case when the OS permission is Granted or NotApplicable.
Adds a "Turn on desktop notifications" recovery button for the deliberate
opt-out path.

Note: on Windows/Linux permissionState is NotApplicable from startup, so the
master switch now auto-enables the first time the user opens Notification
Settings, overriding the off-by-default first-launch state.

Verified before merge: :commons:jvmTest (4 new hermetic tests) and
:desktopApp:compileKotlin both green on top of current main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:34:02 -04:00
Vitor PamplonaandClaude Opus 5 21b02d780f Merge PR: ci: publish linux-arm64 desktop, amy, and geode release assets
Merges nostr proposal 1d98285c into main:
- ci: publish linux-arm64 desktop, amy, and geode release assets

Adds ubuntu-24.04-arm legs to the build-desktop, build-cli and build-geode
release matrices so aarch64 Linux users get .deb/.rpm/.AppImage/.flatpak/
.tar.gz for the desktop app plus amy and geode bundles. Parametrizes the
appimagetool fetch, the portable archive names and the Flatpak bundle name
by arch, computes the AppImage multiarch lib path from uname -m at launch,
and extends the desktop release-deb smoke test to arm64.

Verified before merge: the appimagetool 1.9.0 aarch64 SHA256 pin matches
the upstream release, and secp256k1-kmp-jni-jvm-linux ships a
linux-aarch64 libsecp256k1-jni.so so signing works on arm64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:32:30 -04:00
Vitor PamplonaandGitHub d3bd7a45b9 Merge pull request #3852 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-04 11:56:22 -04:00
vitorpamplonaandgithub-actions[bot] a5d2d153c8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-04 15:40:56 +00:00
Vitor PamplonaandGitHub 3a702add57 Merge pull request #3857 from vitorpamplona/claude/relay-url-normalizer-mggnkh
NIP-66 relay monitoring: streaming probes, read/write checks, URL fixes
2026-08-04 11:38:07 -04:00
Claude 5ec35c7772 feat(quartz): default the read test to kind 0, limit 1
A kind-0, limit-1 REQ works everywhere: purpose relays (purplepag.es)
reject kind-less filters outright, and practically every relay stores
some profile. Verified against production — purplepag.es's read side now
measures instead of going unobserved. Pass a different kinds list to
probe a specific shelf, or null for a kind-less query on relays known to
allow one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:35:22 +00:00
Claude 9a4f6c6cdd feat(quartz): optional kinds on the read-test filter (production finding)
Verified the new probe surface end-to-end against production relays
(probeFlow streaming, readWriteCheck, signed 30166 templates). One
compatibility finding: purpose relays like purplepag.es reject any REQ
that names no kind ('blocked: filters must specify at least one kind'),
leaving their read side unobserved. readTestFilter/readWriteCheck now
take an optional kinds list for those; the default stays kind-less
because naming kinds also narrows the query on every other relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:26:54 +00:00
Claude c767298368 fix(quartz): audit fixes — foreign-OK confirmation bug, normalizer hot-path allocation
Audit findings across the branch, each verified with a failing test or
measurement before the fix:

- publishAndCollectResults counted an OK from a relay OUTSIDE relayList
  (same event id — a probe-wave straggler, or any republish of the same
  event to a different relay set) toward its confirmation window, ending
  the wait loop early and misreporting still-pending listed relays as
  NO_RESPONSE. The OK branch now carries the same relayList guard the
  onCannotConnect/onDisconnected branches always had. Regression test
  proves the failure without the guard. readWriteCheck additionally
  varies the probe event content per wave so wave N's confirmation window
  can never match wave N-1's event id at all.

- RelayUrlNormalizer.fix() called trimEnd('%','2','0') unconditionally,
  allocating a full string copy for ANY url merely ending in '%', '2' or
  '0' — which includes every relay port ending in zero (wss://host:3030).
  Now gated on endsWith("%20"), keeping the hot path allocation-free;
  semantics unchanged (test pins both the trim and the untouched-port
  cases).

- amy relay probe --file: unreadable file is now a clean bad_args error
  instead of a stack trace, and skipped onion urls are counted and
  reported (file_onion_skipped) instead of vanishing from the tally.

- probeFlow KDoc now states that a slow collector eats into the current
  wave's absolute deadline (answers are still recorded; silent relays get
  less listening time), not just that it delays the next wave.

Verified non-issues: androidx.collection LruCache is internally locked
(safe for CachedNip11Fetcher/normalizer concurrency); probeWave's
per-terminal emission cannot lose or double-emit verdicts (remaining-set
guard, data maps read at emission time); existing publish callers all
benefit from the OK guard rather than depending on the old behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
2026-08-04 15:14:17 +00:00