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
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
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
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
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
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
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
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
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
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
`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>
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>
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>
"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>
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>
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>
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>
`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>
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>
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>
`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
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
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
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
`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
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
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
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
`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
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
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
`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
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
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
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
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