Follow-up to the toolchain update, from an audit of that diff.
Build script:
- The NDK pin rejected machines that have the pinned revision installed.
An exported ANDROID_NDK_HOME or ANDROID_NDK_ROOT short-circuited the
search and then failed the revision check, and GitHub runners export both
at their own bundled NDK. Every candidate is now checked against its own
source.properties and a mismatch moves on, so the build fails only when
the pinned revision is genuinely absent, listing what it found instead.
- verify_jni_symbols printed missing exports and exited 0, so a library that
would throw UnsatisfiedLinkError on every call could ship. It now fails the
build, and checks only the ABIs this run built.
- Both post-build checks now use the pinned NDK's own llvm-readelf and
llvm-nm. The stamp check silently skipped on macOS, which has no readelf,
and Apple's nm cannot read ELF at all, so the symbol check would have
reported every symbol missing there.
- The stamp check read its note through `readelf | grep -q`, the same
SIGPIPE-plus-pipefail shape this branch removed from the symbol check.
- $HOME is expanded with a default, so `set -u` no longer aborts before the
"NDK not found" message in an environment without HOME.
verify-reproducible.sh hashed every .so under jniLibs, so --release, which
rebuilds arm64 only, hashed the untouched x86_64 library identically in both
runs and reported the whole tree reproducible and matching the commit. It now
hashes and diffs only the ABIs the run builds, and prints which those are.
lib.rs:
- initialize() signalled "already initialized" out of the JNI closure as an
empty string, re-tested after it. A destroy() landing in between would let
the empty string through as the data directory, which resolves to relative
state/ and cache/ paths against the process working directory. The check
now reads the whole Option outside the closure and no sentinel exists.
- Corrected the comments claiming the error policy keeps a panic from
crossing extern "C". It does not: the policy's panic arm runs through
catch_unwind, which catches nothing under this crate's panic = "abort"
profile. The Err arm, which is what the code relies on, is unaffected.
README: the troubleshooting section still told readers to install cargo-ndk
unpinned and to export ANDROID_NDK_HOME at an arbitrary revision, which was
the exact way to trip the old gate.
Verified: two clean builds byte-for-byte identical, both ABIs stamped r30,
JNI exports present, 16 KiB alignment kept. JVM tier-3 smoke test green, and
a scratch harness drove getVersion, setLogCallback, initialize, a second
initialize on a live client (the reuse path the sentinel used to carry) and
destroy over real JNI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
Moves every pin in tools/arti-build forward and rebuilds all three shipped
libraries from them. Arti 2.5.0 carried two medium-severity fixes that a
client reaches in normal use, so the shipped 2.3.0 was the reason to do this
now: TROVE-2026-24, where a malicious directory mirror crashes the
tor-netdoc parser and eventually stops tor-dirmgr, and TROVE-2026-27, an
inefficient algorithm an attacker can drive into a CPU stall.
Pins:
- Arti 2.3.0 -> 2.6.0 (arti-client / tor-rtcompat 0.42 -> 0.46). New MSRV is
1.91, satisfied by the pinned toolchain.
- Android NDK 27.3.13750724 (r27d) -> 30.0.16248370 (r30), the current LTS.
clang and lld move 18.0.4 -> 21.0.0.
- rustc 1.94.1 -> 1.98.1.
- jni 0.21 -> 0.22.
- Cargo.lock regenerated, so every transitive dependency moves to its latest
semver-compatible release. cargo-ndk was already on the pinned 4.1.2.
Source changes the upgrades required:
- Arti 2.4.0 made every TorClient constructor return an Arc<TorClient> and
dropped Clone from TorClient, so the wrapper no longer wraps it itself.
- jni 0.22 splits the FFI environment pointer (EnvUnowned) from the API type
(Env), which is only borrowed inside a closure. Native methods now acquire
it via with_env and map failures through an ErrorPolicy instead of
unwinding out of extern "C", which aborts. initialize() reads everything
JNI-owned up front and resolves through Option<String>, because the policy
default for jint is 0, the value that API reports as success. GlobalRef
became Global<JObject>, thread attachment takes a closure (which also
scopes a local-reference frame per log line), and the method name and
signature are encoded at compile time via jni_str! / jni_sig!.
Verification:
- verify-reproducible.sh: two clean builds byte-for-byte identical, JNI
symbols exported, 16 KiB LOAD alignment kept, both ABIs stamped r30, same
libc/libm/libdl dependency set as before.
- JVM tier-3 smoke test passes against the rebuilt host shim, and a scratch
harness drove setLogCallback, getVersion, initialize and destroy through
real JNI: log lines arrive over the migrated callback and initialize
returns 0.
- cargo audit: rsa 0.9.10 (RUSTSEC-2023-0071) remains, with no fixed version
published upstream; it arrives via ssh-key-fork-arti and needs RSA private
key operations, which a client without hosted onion services never does.
The event-listener unsound and spin yanked warnings are gone.
Not verified here: the network-dependent integration tier and the on-device
instrumented test. This container blocks most outbound TCP (directory
authority port 9131 among them), so Tor circuits time out regardless of
which library is loaded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
The committed libraries were built with NDK r25b (25.1.8937393) while the
build docs told everyone to install r27. Nothing pinned the NDK, so
build-arti.sh took the first directory matching ~/Android/Sdk/ndk/*/. The
NDK supplies the clang that compiles Arti's C dependencies (ring, zstd-sys,
libsqlite3-sys) and the lld that links the cdylib, so its revision is baked
into the output bytes exactly like rustc's is. The reproducible-build
promise therefore only held by accident of which NDK a verifier happened to
have installed.
- Pin the revision in ANDROID_NDK_VERSION (27.3.13750724, r27d) and resolve
it by name. A different revision now fails the build with the sdkmanager
line that fixes it, instead of silently producing unverifiable bytes.
- Record the verified cargo-ndk release in CARGO_NDK_VERSION. Warning only:
it wraps the NDK rather than generating code.
- Re-read .note.android.ident after each build, so the output has to carry
the pinned NDK's stamp to pass.
- Rebuild both ABIs on r27d (clang 18.0.4, lld 18.0.4, rustc 1.94.1).
verify-reproducible.sh: two clean builds byte-for-byte identical, all 8
JNI symbols exported, 16 KiB LOAD alignment kept, same libc/libm/libdl
dependency set as before.
- Fix verify_jni_symbols reporting every exported symbol as missing: piping
nm into `grep -q` per symbol lets grep exit first, nm dies of SIGPIPE, and
`set -o pipefail` fails the pipeline. Pre-existing, reproduces on the old
binary too.
- Docs: the 16 KiB page alignment comes from rustc's Android target spec,
not from "NDK 25+".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011cSuXeu4bUTNRAUCZJcLLW
The adapters compared the socket OkHttp named in each callback against
the one they held, and took a monitor to make the compare-and-null
atomic and to close a window in connect() where a callback could arrive
before the field was assigned. Neither is needed: the relay client builds
a fresh adapter per dial and OkHttp binds exactly one socket to the
listener created in connect(), so anything that reaches that listener is
from this session by construction. The only question a callback has to
ask is whether the session already ended, which is one AtomicBoolean
claimed by whichever of onClosed, onFailure or disconnect() gets there
first. A compare-and-set keeps the "exactly one terminal report"
guarantee without a monitor, and there is no assignment window left to
guard.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
**Tor's control flow was being pinned alive for the process lifetime.** The
eviction wiring subscribed to `torManager.activePortOrNull` from a never-cancelled
coroutine on `applicationIOScope`, in both managers. That flow chains to
`TorManager.status`, whose upstream is `WhileSubscribed` and runs
`launch { service.start() }` when collected — so a permanent subscriber starts
Arti at process construction and never lets it unsubscribe on background.
AppModules documents this exact hazard for the battery ledger and deliberately
watches the raw `TorService.status` instead; I wired the poisoned well four lines
away from the warning.
Rewired from signals that are plain StateFlows and therefore free to observe:
`torPrefs.torType`, `torPrefs.externalSocksPort`, and `torService.status`'s socks
port. It moves to AppModules, which is where those live and where the precedent
is; commons had no business knowing Tor's subscription hazards anyway.
**`drop(1)` promised more than it delivered.** In the manager it skipped whatever
was present when the *coroutine started*, not when the call was made, so a route
change landing in that window was swallowed. In its new home the two coincide —
this runs during AppModules construction, before anything is pooled — so the
operator now means what the comment says.
**Animated media in a Crop cell flashed its raw URL.** The loading ladder keyed
its last branch on `ratio != null`, but `mediaSizingModifier` also bounds the
height for `ContentScale.Crop`. MyAsyncImage passes dimensions/blurhash/thumbhash
all null, so every gif in a card slot — DVM covers, long-form headers,
follow-set/calendar/music cards — hit the unbounded branch on first load and drew
URL text where it used to draw nothing. The predicate wanted "is the height
bounded", so it now says so: `contentScale == Crop || ratio != null`.
Drops ProxyRouteChange.kt and its tests with the rewire. The trigger is now three
stdlib flow operators; what needed judgement was which signals are safe to watch,
and that is recorded in the comment rather than in a test of combine().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
The first commit on this branch added INostrClient.connectedRelays(), a
snapshot read from the pool members, because connectedRelaysFlow could not
be trusted: a relay the pool had dropped could sit in it for minutes. That
has since been fixed at the source -- the pool clears the flow itself when
it lets a relay go, and every transport reports its session end exactly
once -- so the flow's value and the members' isConnected() move on the
same transitions, and re-reading the members on every emission was a
redundant pool walk. The notification takes its count from the emitted
set, the breakdown and the Active Subscriptions screen go back to the
flow, and the snapshot API is removed. The pool test keeps pinning what
the app actually reads: the flow drops a relay on removal and on
disconnect even when the socket layer never confirms the close.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
Nothing in the version catalog or any module references an
info.guardianproject artifact anymore (the old tor-android/jtorctl
dependencies were replaced by kmp-tor from Maven Central). Resolving
every module's dependency graph with and without the repository yields
an identical result, so the repository is dead weight that only adds
a network lookup to every miss.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pmSZEn7Ub9URvxow4etZc
setExecutable's result was ignored (Sonar). The runner launches the wrapper
via sh -c <path>, so a missing exec bit only surfaced later as an opaque
permission-denied; check it at extraction time instead.
Clears Sonar 'Using HTTP protocol is insecure' hits. None were real requests:
six are @Preview dummy strings, four are picture-URL field hints that
suggested http:// to users. The Namecoin RPC onion placeholder stays
http:// on purpose (Tor encrypts; Namecoin Core RPC has no TLS).
`strict lookup keeps the strict contract for aliases outside the vault`
drove the miss through the macSecurityLookup seam, which getPrivateKeyOrThrow
only consults when isMacOs(). On Linux and Windows the strict path is
javakeyring, which cannot tell a miss from a denial and deliberately throws,
so the test's assertNull failed there — red on the Android job and the
Linux/Windows desktop builds of main.
Branch on the host like SecureKeyStorageOrThrowTest does: off macOS assert
the miss throws SecureStorageException with the PasswordAccessException in
its cause chain (coroutine stack-trace recovery may wrap it); on macOS keep
the null-on-miss / throw-on-ambiguous assertions.
Verified with os.name forced to Linux and natively on macOS (29/29
SecureKeyStorage* tests).
SecureKeyStorageVaultTest's "strict lookup keeps the strict contract for
aliases outside the vault" fails on Linux (red on main's own CI run for
38bbfa8c and reproduced in a clean worktree of origin/main): the test wires a
fake mac `security` probe through macSecurityLookup, but the strict lookup
gates that path on the real os.name, so on a Linux runner it takes the
javakeyring branch, where an unknown alias throws instead of answering null.
Make the OS check injectable like the other test hooks (keyringFactory,
macSecurityLookup) and have wireMacProbe force it on, so the suite exercises
the probe path it was written for on any host.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
Removing the per-rebuild eviction left one sliver: when the user switches Tor ON,
the direct client's idle sockets to real hosts stayed pooled for the 5-minute
keepalive. Nothing could route a request through them — OkHttp keys the pool by
`Address`, which includes the proxy — but they are real connections to real hosts
outliving the moment the user asked for everything to go through Tor.
[evictOnProxyRouteChange] closes that by watching the one signal that means the
route actually moved: `torManager.activePortOrNull`. Tor coming up (null -> 9050),
going away (9050 -> null), or moving (9050 -> 9150) each evict exactly once.
`drop(1)` keeps subscribing from counting as a change.
Deliberately not driven by the two things that misled the old code:
- Client rebuilds. One factory mints both the proxied and the direct client and
they share a pool, so a per-rebuild check fired on every isMobileDataProvider
emission and every resubscribe — constantly, and never specifically on a Tor
toggle.
- The per-feature Tor switches (imagesViaTor, videosViaTor, …). Those change
which of the two existing clients a request picks, not the route either one
uses, so no pooled connection goes stale.
Wired in both managers' init, so the media and relay pools behave identically.
Six tests cover the trigger, including that re-emitting the same port never
evicts — the regression the old design had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
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
Runs SecureKeyStorage.enableConsolidatedVault at the top of
loadSavedAccount, before any other keychain read on the cold-boot
hot path, so migrated setups pay exactly one Keychain Access prompt
regardless of how many accounts, per-account bunker-ephemerals, and
NWC URIs the user has.
Phase 1 migrates only account-metadata-key (the AES key that
decrypts accounts.json.enc). Nothing else can be enumerated before
that file is readable, so this phase runs against a single-alias
candidate list. It is a no-op on fresh installs (no legacy item)
and on already-migrated setups (vault-v1 exists).
Phase 2 runs after accounts.json.enc has been decrypted and the
full npub list is known. For each npub we add the nsec alias
itself, the per-account bunker-ephemeral alias, and the NWC alias.
The legacy shared bunker-ephemeral alias is included for the
pre-per-account migration compatibility branch in
loadBunkerAccount. Phase 2 is idempotent so it is safe to run on
every startup, including when the vault already covers every alias.
DesktopAccountStorage.METADATA_KEY_ALIAS is promoted from private
to internal so AccountManager.bootstrapConsolidatedVault can name
it without duplicating the alias string. The alias itself, its
storage, and its read/write path all still live in
DesktopAccountStorage.
Bootstrap failures are swallowed: SecureKeyStorage falls back to
legacy per-alias reads for anything the vault does not cover, so
the worst case is the old two-prompt behaviour. Nothing on the
account-load path breaks.
No API change on AccountManager (bootstrapConsolidatedVault is
private) and no visible behaviour change on the fallback
(no-keyring) storage path, which already uses a single encrypted
file and does not have the per-item ACL problem the vault exists
to solve.
Follow-up to ae3218249a. Caching the Keyring handle collapsed
Keyring.create() calls but did not fix the double prompt on macOS,
because macOS Keychain gates access per item, not per session.
Amethyst's cold-boot path reads at minimum two distinct items,
account-metadata-key (the DesktopAccountStorage AES key) and the
active account's nsec, so the OS still surfaces two Keychain Access
prompts unless the user explicitly picked "Always Allow" on every
single item, which many users don't.
Fix: consolidate all Amethyst-managed keychain items into a single
vault-v1 item, so the OS sees exactly one item to gate.
SecureKeyStorage.enableConsolidatedVault(candidateAliases):
1. If vault-v1 already exists, load it and mark the vault active.
Legacy per-alias items in candidateAliases are still folded in
on this pass so a crash-during-migration leaves nothing stranded.
Aliases not in candidateAliases are ignored, so pre-existing
items from unrelated accounts do not get pulled in and re-prompt.
2. If vault-v1 is absent, batch-read each candidate alias in the
legacy per-item layout (paying the migration prompt once), pack
the recovered entries into vault-v1, and delete the originals.
The vault item is written first, legacy items are deleted only
after that write succeeds, so a crash mid-migration leaves the
legacy items in place and the next run retries cleanly.
3. Fresh installs write an empty vault so future savePrivateKey
calls stay inside it.
Migration is idempotent and cheap when the vault already exists
(one keychain read plus a JSON parse). Safe to call on every cold
boot, and safe to call twice per process for the two-phase
bootstrap pattern (see the AccountManager change in the next
commit).
Once the vault is active, savePrivateKey / getPrivateKey /
deletePrivateKey / hasPrivateKey read and write the in-memory
LinkedHashMap and persist the whole map back to the vault item on
mutation. Fresh SecureKeyStorage instances that have not opted into
the vault continue to use the legacy per-alias layout, so callers
that never call enableConsolidatedVault keep working unchanged.
The vault contents are stored as a JSON envelope of the form
{"schemaVersion":1,"entries":{alias: base64(secret), ...}}.
schemaVersion reserves room for future migrations. Values are
base64-encoded so alias / secret contents that contain quotes,
backslashes, control chars, or non-ASCII round-trip cleanly through
the hand-rolled JSON codec (kept hand-rolled so the keystorage
module does not need Jackson; Jackson lives in desktopApp and
quartz). Amethyst does not layer additional crypto over the OS
keyring for legacy per-item storage; the OS keychain is the trust
boundary. The vault follows the same policy.
Tests (SecureKeyStorageVaultTest, hermetic CountingKeyring
KeyringHandle fake, no real OS keychain):
- fresh install writes an empty vault item
- legacy items are migrated into one vault-v1 item and originals
deleted
- an existing vault is loaded without re-probing aliases it already
contains
- legacy leftovers from an interrupted earlier migration get
absorbed on the next boot when still named in the candidate list
- savePrivateKey after vault enabled persists to the vault
- getPrivateKey after vault enabled reads from in-memory contents
without further keychain traffic
- enableConsolidatedVault is idempotent across repeated calls
- two-phase migration folds in aliases discovered after phase 1
- partial legacy migration survives a relaunch (two SecureKeyStorage
instances against one backing store)
- delete removes from the vault and unlinks the whole item when the
last alias goes
- vault round-trips keys with quotes, newlines, backslashes, and
Unicode
All existing SecureKeyStorageKeyringCacheTest cases still pass;
the changes are additive and back-compatible when
enableConsolidatedVault is never called.
Both factories emptied the whole connection pool whenever the proxy differed
from the last one they were handed. It was defensive, and it was not free.
It is not needed. OkHttp keys the pool by `Address`, and `Address.equalsNonHost`
compares `proxy` — so a call is only ever given a connection opened through the
very same route. A connection left over from an old proxy is already unreachable
by anything using the new one; it just ages out of the pool on its own. There was
never a stale-route connection to protect against.
The cost was real, though. `evictAll()` empties the ENTIRE shared pool, and each
factory mints BOTH clients: `DualHttpClientManager` builds defaultHttpClient
(always SOCKS, since buildLocalSocksProxy falls back to 9050 rather than
returning null) and defaultHttpClientWithoutProxy (always null) from one
instance, and they share one `rootClient.connectionPool`. A single "last proxy"
field therefore alternated forever, and every rebuild read as a route change and
dropped every warm connection the other client was relying on. Both are
stateIn(WhileSubscribed(1000)) flows collected from a composable, so it fired on
each isMobileDataProvider change and each foreground round trip — and the next
image then paid a fresh DNS + TCP + TLS. Plausibly the "first image after a pause
takes forever" stall the pingInterval above it was added for.
DualHttpClientManagerForRelays has the identical shape, so the relay factory is
fixed the same way. Less damaging there — evictAll spares connections with active
calls, so live relay sockets survived — but it was still discarding idle pooled
connections on every network flap.
This replaces the ProxyRouteTracker approach from earlier on this branch, which
kept the eviction and merely made its bookkeeping correct. Deleting the mechanism
is the better answer, and it takes the class and its tests with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
availableRelaysFlow was merged in when connectedRelaysFlow could not be
trusted to move on a removal: a relay the pool had dropped could sit in it
for minutes, so the one flow that did move on membership changes was used
as an extra wake-up and the count re-read from the pool members. The pool
now clears the connected flow itself whenever it lets a relay go, and every
transport reports its session end exactly once, so that flow emits on every
change the count can reflect and the extra trigger only added wake-ups on
membership churn. The count still comes from client.connectedRelays(),
the members' live socket state, which is what it is meant to show.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
The previous commit taught BasicRelayClient to remember which listener it
had wired and to ignore callbacks from any other. That put the knowledge
"this report is about a socket I already threw away" in the wrong layer:
the transport adapter is the one that owns the socket, and OkHttp names
the socket in every callback, so the adapter can tell for free.
BasicRelayClient goes back to exactly its previous code. The contract it
relies on is now written on the WebSocket interface and kept by every
transport: a session ends with exactly one terminal callback, and
disconnect() reports onClosed synchronously and forwards nothing from that
socket afterwards -- what InProcessWebSocket has always done. Both OkHttp
adapters (quartz's BasicOkHttpWebSocket and the Android app's
OkHttpWebSocket) now check ownership on every callback, claim the terminal
report under a lock so a session cannot be reported twice, and answer
disconnect() themselves instead of waiting for OkHttp, which raises nothing
for a cancel when no reader is left to fail and otherwise raises it later
on its own thread. The reconnect race this closes is the same one as
before: with disconnect()+connect() back to back, the old socket's late
failure used to land on the new connection and cancel it.
The client-level stale-socket test is replaced by adapter-level tests on
both modules: disconnect() reports once and synchronously, OkHttp's own
reaction to the cancel never surfaces, and a relay-initiated close followed
by disconnect() is still one report.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
One conflict, in BlossomReadAuthTokenProvider.signOnce(). Main already carries
the same fix via PR #4108 (davotoula), landed while this branch was open. The two
are functionally identical — same third cache look, in the same place, for the
same reason — so the conflict is resolved by taking main's wholesale and dropping
mine. The file is now byte-identical to main; my add7bfe3 contributes nothing
beyond it.
What this branch still adds over main:
- GifVideoView loading fallback (the invisible no-imeta note)
- ProxyRouteTracker + its tests (the shared connection pool being evicted
whenever the proxied and direct clients alternate)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
I lowered these to 32/8 on reasoning that does not survive reading the source.
`maxRequests` is the thread ceiling — Dispatcher's executor really is
corePoolSize=0 / maxPoolSize=MAX_VALUE over a SynchronousQueue, and its own kdoc
notes a pool sized exactly to maxRequests is not even sufficient. That much was
right. The conclusions drawn from it were not:
- "128 concurrent TLS handshakes" is wrong. These hosts are HTTP/2, so concurrent
calls to one host multiplex over a single connection; handshake count is bounded
by distinct hosts and the pool, not by maxRequests.
- "A tighter total lets the visible images finish first" is backwards.
promoteAndExecute walks readyAsyncCalls as a strict FIFO with no priority, and
PrefetchFeedMedia enqueues notes before the user reaches them — so a lower cap
makes the on-screen image queue behind those prefetches rather than start
immediately. That is the very symptom under investigation.
- Halving maxRequestsPerHost also halves HTTP/2 stream concurrency against the
single Blossom host a feed pulls from, which is what these were tuned for.
What is left is thread memory, and blocked threads commit little. No measurement
justified the change, so the values go back as they were. The comment now carries
the analysis so the next reader does not re-derive the same wrong intuition.
The ProxyRouteTracker fix from the same commit is unaffected and stands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
test-quartz-ios failed on :commons:compileKotlinIosSimulatorArm64 with
"Unresolved reference 'okio'" in service/image/DeferredDeleteFileSystem.kt.
The file always used okio, but commons only ever got it transitively:
through Coil on every target, and through OkHttp on JVM. Moving Coil to
:commonsUI removed the only Apple-side provider, and the JVM builds kept
passing, so nothing caught it locally.
Declare okio (3.18.1, the version already resolved everywhere; Apache-2.0
per its POM) in commons commonMain. Reproduced and re-verified on Linux with
:commons:compileCommonMainKotlinMetadata, which resolves commonMain against
the shared-dependency set exactly like the iOS compile. That task, for all
three KMP library modules, is now part of the lint job so this class of gap
fails fast on Linux.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
Follow-ups from an audit of the two previous commits on this branch.
The Android app does not use quartz's BasicOkHttpWebSocket: AppModules wires
its own OkHttpWebSocket, a near-twin that decides needsReconnect() from the
OkHttpClient in use. The onClosing answer therefore only reached desktop,
CLI and geode. Mirror it here, with the same loopback RFC 6455 test. The
unit-test android.util.Log stub gets a primitive-signature isLoggable (the
boxed one it had was a different method to the JVM, which is why OkHttp
could never be built in this module's tests) plus println, so the socket can
be exercised for real.
RelayPool now clears its connected flow itself on every path where it lets
a relay go -- removeRelay, removeAllRelays and disconnect -- instead of
waiting for a callback the socket layer may never deliver. The previous
commit added a members-read snapshot and migrated three callers; the other
consumers of connectedRelaysFlow (drawer status, connection-time accounting,
"wait until connected" loops) were still reading the stale set.
BasicRelayClient retires its listener before tearing a socket down and
ignores anything a retired socket reports afterwards. OkHttp delivers
onClosed from its writer thread and a cancelled socket's failure later
still; with the pool rebuilding sessions via disconnect()+connect(), a late
callback from the old socket could null the new one, orphaning a live
connection and dialing a third. disconnect() also reports onDisconnected
itself now, so a client-initiated teardown never depends on the socket
layer confirming it.
KDoc and comments that described the unanswered-close behaviour in the
present tense, or claimed a still-desired relay reads isConnected()=false
on a silent close, are corrected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
The merge of origin/main resolved quartz/build.gradle.kts with the branch
side wholesale, which kept the shared purity-gate apply but dropped the
`by getting` -> `getByName(...)` cleanup from 6e3af61e. Re-applied on top.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
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
signOnce() looked at the cache a second time to catch a leader that had already
finished, but it looked too early — before claiming the in-flight slot. The gap
between that look and putIfAbsent still spans signerProvider() and an allocation,
and a leader caches its token and retires its entry inside it. A straggler in
that gap therefore put into a map the leader had just emptied, won the slot, and
signed a duplicate.
Winning the slot is not proof that nobody signed; only a look from inside it is.
Once we hold the entry no one else can be leader, and our successful put observed
the map after that leader's removal, which its cache write is ordered before — so
a token visible at that point is the last word. Hand it over and stand down.
This is pre-existing, not fallout from the dispatcher change: the same test fails
on an unmodified origin/main worktree, and aFastSignerStillSharesOneSignature
already says the window is "microseconds wide, so one round hits it only now and
then" and runs 200 rounds to catch it. It cost a duplicate signature — with a
NIP-55 external signer that is a second IPC round trip, and potentially a second
prompt, for a burst of images from one gated host.
Verified with 8 consecutive runs of the suite (1600 signing rounds), all green,
against a baseline that failed inside the first run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
OkHttp fires onClosed only once both peers have sent a CLOSE frame, and
sending ours is the application's job (WebSocketListener KDoc; RealWebSocket
emits onClosed solely from the writer once our Close is dequeued with the
peer's code already set; the bundled WebSocketEcho recipe answers onClosing
with close(1000, null)). BasicOkHttpWebSocket never implemented onClosing,
so a relay-initiated close left the socket half-closed: no onClosed, no
onFailure, send() still accepted and silently discarded, and a later
cancel() silent as well because no reader was left to fail. The relay
client kept believing it was connected, with its REQs live, until the 120s
ping path finally failed up to two intervals later.
Answer onClosing with close(1000, null). Verified against OkHttp 5.5.0:
onClosed then fires at once whether the relay still holds the TCP session
or has already dropped it, and the existing onClosed path in
BasicRelayClient marks the connection closed and lets the pool reconnect
under its normal backoff. Always 1000 rather than echoing the relay's code,
since close() validates the code it writes and relays may send reserved
ones.
The test drives the wrapper against a minimal RFC 6455 server on a loopback
ServerSocket (no new dependency): the relay sends CLOSE, the client must
answer with its own CLOSE frame and report onClosed with the relay's code.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
Second finding from the Marmot headless harness on geode. wn's `keys
publish` mints a KeyPackage (kind 30443, same `d` tag) in the same second
as the one its bootstrap already published; NIP-01's lowest-id-wins tie
keeps the stored one, and the insert of the loser trips the addressable
unique index. The store classified that as a rejection carrying SQLite's
text — "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey,
event_headers.d_tag" — so the relay answered OK false with a reason no
client can classify, and MDK filed it as "publish acknowledgement
unknown" and retried forever.
nostr-rs-relay, which this harness was validated against, does not even
attempt the insert when a newer version exists and acknowledges the event
as `OK true "duplicate: ..."` (its Duplicate status maps to true). Match
that: the replaceable and addressable unique-index failures now classify
as RejectionReason.SUPERSEDED, a `duplicate:`-prefixed reason the session
already answers with OK true. The stored version is untouched, nothing is
fanned out, and the STORE-W01/W02 contract in the event-store-semantics
skill is updated to say so.
Tests: NostrServerTest covers an older kind-0 re-insert and the
same-second kind-30443 tie, asserting the OK true duplicate: reply and
that the winner remains the only stored version.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
Running the Marmot headless harness against the embedded geode relay
failed 10 of 29 scenarios, every one on the same reply: the relay
answered a resent EVENT with
["OK", <id>, false, "Error code: 2067, message: UNIQUE constraint
failed: event_headers.id"]
NIP-01 says a relay that already holds the event answers
["OK", <id>, true, "duplicate: already have this event"], and every
client here depends on that: amethyst's outbox writes an event as soon
as the socket is ready and resends it when the connection finishes
syncing, so one of the two copies is always a duplicate; MDK's wn
counts a `duplicate:` prefix as idempotent success but files an
unclassified OK false as "publish acknowledgement unknown" and keeps
retrying. Both amy's group commits and wn's KeyPackage publish were
failing on it, while nostr-rs-relay had answered the resend correctly.
SQLiteEventStore now recognises the unique-index violation on
event_headers.id and reports RejectionReason.DUPLICATE, the constant
that already carried NIP-01's exact wording but was never produced;
RelaySession sends `OK true` for a `duplicate:` reason and keeps
`OK false` for every other rejection. The store outcome stays Rejected,
so a duplicate is still not fanned out to live subscriptions or counted
as a new write by the mirror worker and importer. The filesystem store
already treated a duplicate insert as a no-op.
Two tests pinned the old OK false behaviour (NostrServerTest,
KtorRelayTest) and now assert the NIP-01 reply.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
Three read-only sweeps over commons (feeds, relayClient, model, viewmodels,
services) and commonsUI produced 44 candidates; each was re-read in the
source, 38 held up, 34 are fixed here, 4 are deferred with rationale in
commons/plans/2026-09-12-audit-findings.md.
Correctness highlights:
- SecureKeyStorage (jvm): AES-GCM was initialised with IvParameterSpec, which
every JDK rejects, so the no-keyring fallback never worked. GCMParameterSpec.
- OnionLocationInterceptor cached any Onion-Location header and re-pointed all
Tor traffic at it for 24h over plain http; only .onion targets now.
- BasicBundledInsert wedged forever after one exception (no finally).
- CachedRichTextParser keyed parses on a 32-bit hash without checking inputs.
- noProtocolUrlValidator backtracked exponentially per composer keystroke.
- Base83 indexed a 255-entry table with any char code from an imeta tag.
- MetadataRateLimiter never flushed a batch smaller than 20 pubkeys.
- FeedMetadataCoordinator mutated six HashSets from two threads and marked
pubkeys 101+ as requested without asking for them.
- Note: two discarded boolean/relay expressions, an NPE window in flow(),
removeReport leaving empty buckets, unlocked read-modify-write on
replies/boosts/edits/reactions/reports/labels.
- EventCollectionState restarted its flush timer on every insert.
- Chatroom prune outside the lock; top-zappers publish outside the mutex;
hashCode used as dedup/feed keys; OnlyLatestVersionSet.addAll always true.
- UI: ClickableTexts remembered a stale onClick, ZonedSwipeModifier a stale
openDrawer, two robohash light-theme predicates thrashed one cache, a chess
remember key summed two counters.
Performance: user-cache search off the Compose dispatcher, regexes hoisted
in SearchResultSorter, frame-rate animation reads moved out of composition
in Shimmer/LoadingAnimation/BunkerHeartbeat, GlowingCard allocations cached,
emoji inlineContent remembered, itemsIndexed in the search pickers.
Ten regression tests added (rate limiter flush/dedup/rate, GCM round trip,
regex timing, Base83 bounds, CosineCache key). Verified: commons and
commonsUI jvmTest, compiles of cli, desktopApp, nappletHost, amethyst.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
Follow-ups from a review of the commons/commonsUI split:
- `verifyKmpPurity` was copy-pasted into quartz, commons and commonsUI and the
three copies had already drifted (checked dirs, hint text). It now lives
once in gradle/kmp-purity.gradle.kts and each module applies it; the
checked-dir list is the union, filtered by existence. Verified the shared
task still fails on a deliberate java.util.UUID reference in commons.
- amethyst/src/main/res/CLAUDE.md and the compose-expert catalog reference
still pointed at the pre-split composeResources / ui paths.
- Same-package imports left behind in the files moved to commons.feeds and
commons.model are removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
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
Three independent bugs in DesktopAccountStorage / SecureKeyStorage could
turn one ambiguous macOS Keychain reply, one transient read error, or
one Homebrew upgrade race into permanent account-metadata loss on
~/.amethyst/accounts.json.enc.
1. Silent AES metadata-key rotation on ambiguous keychain miss.
getOrCreateKey() treated null from getPrivateKey("account-metadata-
key") as "no key exists" and generated a fresh AES key. On macOS
javakeyring collapses errSecItemNotFound (-25300), errSecAuthFailed
(-25293), errSecUserCanceled (-128), and errSecInteractionNotAllowed
(-25308) into the same PasswordAccessException; getFromKeyring turns
them all into null. A single Deny click on the OS Keychain dialog
silently rotated the AES key and destroyed the ability to decrypt
the existing accounts.json.enc.
Fix: add a new strict SecureKeyStorage.getPrivateKeyOrThrow(npub)
on the common expect. On JVM/macOS it wraps /usr/bin/security
find-generic-password whose exit codes (0 = found, 44 = not found,
others = ambiguous) are documented and unambiguous. On JVM
Windows/Linux it uses javakeyring but throws on any
PasswordAccessException from the strict path. On Android it uses
EncryptedSharedPreferences.contains(). On iOS it mirrors the
existing "pending (iOS Phase 4)" stub. getOrCreateKey now calls
getPrivateKeyOrThrow and propagates SecureStorageException without
ever rotating the key. The permissive getPrivateKey(npub) is
unchanged; its callers (per-account nsec, ephemeral bunker keys)
still tolerate null on any error.
2. Any read failure resets the file. readMetadataFromDisk() used to
rename to accounts.json.enc.corrupt.<ts> and return empty
AccountMetadata() on any exception, including transient IO and
the newly-throwing keychain path from bug 1.
Fix: distinguish exception types.
- AEADBadTagException / BadPaddingException: back up to
.corrupt.<ts>, reset, fire StorageCorruption.FileCorrupted
(unchanged).
- JacksonException: back up but to .jsonerror.<ts> so it is
distinguishable from ciphertext corruption; fire
StorageCorruption.JsonMalformed.
- Anything else (IO error, OOM, thrown keychain path): do NOT
rename; rethrow to caller and fire a new
StorageCorruption.TransientError(cause) subtype. The file stays
untouched. AccountManager.loadSavedAccount already wraps in
try/catch and turns the throw into Result.failure.
- Truncated file (size < GCM IV size): still backup + reset,
genuinely unusable.
3. No cross-process advisory lock. Homebrew replacing the .app while
the old process is mid-save, or an accidental double-launch of
Compose Desktop (no built-in single-instance guard), could produce
a truncated file that trips bug 2.
Fix: withAccountsFileLock helper (mirrors SecureKeyStorage.
withFileLock) wraps read + write in a
RandomAccessFile(lockFile, "rw").channel.lock() on
~/.amethyst/accounts.json.enc.lock (0600). Because FileChannel.lock
is per-JVM, an in-process Mutex is held before acquiring the
channel lock. A separate stateMutex guards the read-modify-write
cycle in saveAccount / deleteAccount / setCurrentAccount so two
concurrent writers cannot each read the same base metadata and
each rewrite it.
Backward compatibility: existing keychain items are read unchanged;
no schema migration for accounts.json.enc; the file lock adds a
.lock sidecar older builds ignore.
Tests: new SecureKeyStorageOrThrowTest (pure exit-code parser,
mac lookup Found/NotFound/Ambiguous, non-mac keyring hit and
throw-on-PasswordAccessException). DesktopAccountStorageTest gains
five cases: getOrCreateKey ambiguous-error preserves file and does
not rotate; getOrCreateKey definitive-not-found happy path;
readMetadataFromDisk transient-IO preserves file with no backup
sibling; GCM tag mismatch keeps .corrupt.<ts> backup; JSON malformed
uses new .jsonerror.<ts> suffix; eight concurrent saveAccount calls
serialize under the file lock with no lost updates. All existing
AccountManager* MockK setups extended to also stub
getPrivateKeyOrThrow.
Local verify: :desktopApp:test + :commons:jvmTest, 2490 tests, all
pass. Spotless clean.
Closes the last two items of documented debt from the commons/commonsUI split.
ParentNote (replyingDirectlyTo, isCommunityDefinition) and ReplyContext are
pure thread logic used by ViewModels, so they move from the misleading
`ui.note` package to `commons.model`, next to ThreadAssembler, together with
their tests and the StubCache fixture that shared the package. No `ui.*`
package is left in commons. The `ui.note` composables in commonsUI gain
explicit imports; consumer imports rewritten.
Whether commons still needs the Compose compiler plugin was an open question;
it is now measured. With compiler reports on the three GUI modules and full,
non-incremental recompiles in both configurations, removing the plugin flips
composable parameters typed with unannotated commons classes (TopFilter,
TorSettings, ProfileBroadcastStatus, ScheduledPost, EmojiPackState, ...) from
runtime-stable to unstable: 20→28 in commonsUI, 33→65 in desktopApp,
90→149 in amethyst. The plugin stays; the numbers are recorded in the build
file, ARCHITECTURE.md and the split plan so the question is not reopened.
Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli/desktopApp JVM
test suites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
Audit of the sync path the new geode-backed EventSyncTest exercises found
two bugs in EventSync itself, plus review nits on the harness changes.
- runSync closed its client (`use {}`) the moment the last page arrived,
while `publish` is fire-and-forget through the client's outbox. Events
forwarded from the final page of the last relay were still waiting for
a socket or an OK when the outbox was destroyed, so the sync reported
Done and silently never delivered them. Wait, bounded by the existing
per-relay timeout, until no forwarded event has a relay left pending.
- The "events sent" counters incremented on every onSent, including the
failed write to a destination still connecting and the outbox's
at-least-once resend of an unacknowledged event after the connection
syncs. Every cold destination therefore reported at least one extra
event sent. Count only successful writes, once per (event, relay).
The test now asserts the sent total equals the routed total.
- Harness: the 127.0.0.2 rationale claimed it survives Quartz's
isLocalHost() strip; that filter now covers all of 127.0.0.0/8, so
say so and note what it means for the strict-inbox DM cases. The
interactive Marmot harness gets an overridable RELAY_HOST/RELAY_BIND
and documents the loopback/RFC1918 stripping limit it inherits, and
its new --port guards a missing value instead of dying on set -u.
- EventSyncTest builds both scenarios through one helper.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
Two problems in the shared non-relay client, both of which make every HTTP
request cost more than it should.
The connection pool was being emptied constantly. buildHttpClient() evicted the
whole pool whenever the proxy differed from the last one it was handed, tracked
in a single field. But one factory mints BOTH long-lived variants:
DualHttpClientManager builds defaultHttpClient (always SOCKS, since
buildLocalSocksProxy falls back to 9050 rather than returning null) and
defaultHttpClientWithoutProxy (always null) from the same instance, and the two
share one rootClient.connectionPool. So the field alternated between the proxy
and null forever, and every rebuild read as a route change and wiped the pool
they share. Both are stateIn(WhileSubscribed(1000)) flows collected from a
composable, so that happened on every isMobileDataProvider change and every
foreground round trip — and the next image then paid a fresh DNS + TCP + TLS.
This is a plausible source of the "first image after a pause takes forever"
stall the pingInterval above it was added for.
ProxyRouteTracker narrows it to what the eviction was actually for: a direct
build never evicts (null is that variant's permanent route), and a proxied build
evicts only when the Tor port really moved. Nothing is lost by being this narrow
— OkHttp's Address, the pool key, already includes the proxy, so proxied and
direct connections to the same host are distinct entries that can never be
handed to each other's calls.
Second, maxRequests was 128. Dispatcher's executor is an unbounded cached pool,
so that is the thread ceiling: up to 128 threads running 128 concurrent TLS
handshakes on a handset. Nothing upstream bounds the arrival rate either —
Coil's enqueue is unbounded and PrefetchFeedMedia warms ±3 notes on both sides
of the viewport on every visible-range change — so a fast scroll really does
reach it. Past saturation, more concurrency slices the same bandwidth thinner
and pushes every image's completion out together, the on-screen one included.
32 total / 8 per host keeps the per-host lift that feeds need while letting
visible images finish and paint.
The pool fix is covered by tests. The dispatcher numbers are a reasoned choice,
not a measured one — worth a run against benchmark/ before release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
GifVideoView's Loading branch emitted DisplayBlurHash unconditionally, and
DisplayBlurHash renders nothing at all when both hashes are absent —
placeholderModel(null, null) returns null and the composable early-returns.
That is exactly the state a post with no imeta lands in. With no `dim` tag and
nothing in MediaAspectRatioCache, `ratio` is null, so mediaSizingModifier falls
to a bare fillMaxWidth() with no height constraint. The container then wraps an
empty loading state and the whole note collapses to zero height: no picture, no
URL, no spinner, just a gap in the feed for however long the fetch takes, and
then the image appearing from nowhere. Seen on a kind-1111 comment from Sidecar
whose content is a single blossom .gif URL.
UrlImageView already has the ladder this needs, so mirror it: blurhash/thumbhash
when there is one, a spinner in the reserved box when only a ratio is known, and
otherwise the URL plus a loading symbol via WaitAndDisplay. Every branch now
emits something with a height.
This is the missing feedback, not the latency — a slow fetch still takes as long
as it takes, it just stops being invisible while it does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdidqyhRni5L3ft5h8qti5
MainActivity.onPause() calls debugState() unconditionally, and every line it
emits is Log.d built through the *eager* overload — so the arguments are
evaluated before the level check. Those arguments are the expensive part:
nine materialising LargeCache.filter scans over notes/addressables/users and
the three channel maps, nested sums over every channel's and chatroom's
notes, two sorted passes, and three passes calling Event.countMemory(), which
itself walks every tag and tag element of every cached event.
Release builds sit at WARN, so all of that ran on every backgrounding and the
result was discarded. Gated on `Log.minLevel > LogLevel.DEBUG` rather than
`isDebug`, because that is exactly the condition under which the lines are
dropped: benchmark builds are `isDebug` but sit at INFO, so an isDebug gate
would have left the one variant whose numbers are meant to be trustworthy
still paying the cost. The function only reads and logs — no mutation — so
the early return cannot skip a side effect.
The same shape is already gated elsewhere: AppModules builds relayReqStats
and bootDiagnostics as `if (isDebug) ... else null`, and BootRelayDiagnostics
does comparable work. debugState was the one that was not.
fix: rethrow CancellationException in six catch blocks
All six sat in suspend functions whose try body contains a suspension point,
so a cancelled scope surfaced as CancellationException and was swallowed,
against the `if (e is CancellationException) throw e` convention this
codebase follows in 192 files. What cancellation used to mean:
- VanishRequestsState: published ComplianceStatus.ERROR, showing a relay as
having answered badly when it was never asked.
- NappletResourceFetcher: reported ERROR_NETWORK to the sandboxed page, so a
cancelled fetch was indistinguishable from an upstream failure.
- MarmotAgentStreamWatcher (two sites): the per-candidate handler does
`continue`, so a cancelled watcher kept dialling the remaining broker
candidates.
- CallSession: logged as a PeerConnection creation failure.
- NamecoinSharedPreferences: returned emptyList(), i.e. "no pinned certs".
Uses kotlin.coroutines.cancellation.CancellationException, the import that
also works from commons/commonMain.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
- Only retry a refused BOLT12 offer over BOLT11 when the wallet rejected it
before attempting a payment (EXPIRED, NOT_FOUND, BAD_REQUEST,
NOT_IMPLEMENTED, UNSUPPORTED_PAYMENT_INSTRUCTION, UNSUPPORTED_NETWORK).
NIP-47 defines PAYMENT_FAILED as possibly "due to a timeout", so an HTLC
can still settle after that reply; retrying on it, or on INTERNAL / OTHER
/ a missing code, could pay the recipient twice. The decision is now an
allowlist and the test locks the non-retry set.
- NwcInfoCache exposes an `updates` counter bumped on every stored entry.
The zap picker keys its rail recompute on it, so a BOLT12-only recipient's
bolt appears when the wallet's kind:13194 info lands after the popup
opened, instead of only after closing and reopening it.
- A BOLT12 refusal with neither message nor code no longer toasts the raw
"%1$s" placeholder; the code name (or OTHER) fills the detail.
- One abbreviateBolt12Offer() replaces three copies of the lno1 truncation
in the profile chip, the offers dialog and the settings screen.
- Reword the "these four" recompute-key comment so it covers the BOLT12
reads added alongside it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
The pre-insert cache look added in 6dc631e85d narrowed the single-flight
gap but didn't close it: a fast leader can insert, sign, cache and retire
its entry entirely between a straggler's cache read and its putIfAbsent,
so the straggler wins an empty map and signs a second time. That is the
intermittent `expected:<1> but was:<2>` in aFastSignerStillSharesOneSignature
failing main CI.
Check the cache again once this caller owns the slot. A leader always caches
before retiring its entry, so any token minted before the insert is visible
there; take it and give the slot back instead of re-signing.
Reproduced locally at round 2431 of 5000; two 5000-round runs pass with the fix.
`aFastSignerStillSharesOneSignature` failed on a loaded machine (rounds 11,
18 and 31 across three runs, on the branch head and on the already-pushed
commit alike): a straggler could miss the in-flight map, miss the cache,
get descheduled, and then win `putIfAbsent` only because the fast leader
had already cached *and* retired its entry — and sign a second time.
Take one more look at the cache after winning the in-flight slot. A prior
leader's cache write happens before its `remove`, and winning the slot
after that `remove` goes through the same ConcurrentHashMap bin, so the
just-minted token is visible there; hand it out and retire the slot instead
of launching a duplicate signature. The test class now passes four runs in
a row where it previously failed three in a row.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
The always-on notification read its "Connected to N relays" from
RelayPool.connectedRelays, a set that only moves on the onConnected /
onDisconnected callbacks. That set over-reports: a relay that sent a
WebSocket CLOSE frame never produces a callback (the app does not answer
onClosing, so OkHttp fires neither onClosed nor onFailure, and the later
cancel() is silent too), so the URL lingers until the 120s ping path
finally fails. After the feeds tore down in the background this left
hundreds of already-dropped relays in the count for minutes, with no
subscription in the "show details" breakdown to justify any of them.
Expose the pool's ground truth instead: RelayPool.connectedRelayUrls()
reads each member's isConnected(), surfaced as INostrClient.connectedRelays()
(defaulting to the flow's value for pool-less clients). The notification
keeps the flows only as a trigger, merging in availableRelaysFlow because
that is the flow that moves when the pool drops such a relay, and re-reads
the live count on each sample. The "show details" breakdown and the
Active Subscriptions screen read the same source so all three agree.
A pool test pins the drift: with a socket layer that never confirms the
close, removing a relay leaves it in the flow but out of the snapshot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cq6vrfQkASwxgqXXY4py8
The wallet resolves the offer itself, so a stale or unreachable kind:10058
offer surfaces as a NIP-47 error reply, which by the spec means nothing was
paid. When that recipient also publishes a lightning address, re-send the
same share as a regular zap through the BOLT11 lane instead of toasting the
BOLT12 error; the toast stays for a recipient with no other route.
Bolt12LightningFallback keeps the decision pure and tested: every refusal
retries except the ones about our own wallet (insufficient balance, quota,
rate limit, restricted, unauthorized, unsupported encryption), which would
fail the same way over BOLT11. A paid-but-no-receipt outcome is never
retried, and neither is a wallet that never answers: sendBolt12Zap now
passes a timeout handler, so a silent wallet reports a timeout and steps
the progress instead of leaving the zap hanging.
The BOLT11 lane moves into zapOverLightning so the main zap and the
fallback share one path, and Bolt12Recipient carries the lnAddress and
relay hint the retry needs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
Follow-ups to the commons/commonsUI split.
Feed data-access layer: FeedFilter, AdditiveFeedFilter, AdditiveComplexFeedFilter,
ChangesFlowFilter, FeedContentState, FeedState, InvalidatableContent,
DefaultFeedOrder, RepostRenderability and friends move from the misleading
`ui.feeds` package to the root of `commons.feeds`, next to `feeds/custom`.
The `ui.feeds` composables (NewPostsChip, RelayReachMarker, DM history cards)
stay in commonsUI and gain explicit imports. Consumer imports rewritten.
Chess: the eleven composable files in commonsUI move from the flat
`nip64Chess` package to `nip64Chess.ui`; the logic stays in
`commons/…/nip64Chess`. Consumer imports rewritten.
CLI size budget: measured after the split (1.15.2, Linux x64) the JVM
tarball is 55 MB and the jlink image tarball 80 MB, so the release gate
drops from 200 MB to 120 MB per asset. BUILDING.md, the architecture docs,
the feed-patterns skill and the split plan record the new state.
Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli/desktopApp JVM
test suites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
aFastSignerStillSharesOneSignature still failed about two runs in five: a
straggler that read the in-flight map and the cache as empty could then win
putIfAbsent because the leader had already signed, cached and retired its
entry in between, and would sign a second token.
The leader's cache put happens-before its removal of the same key, so once
a caller has claimed the slot a cached token, if any, is visible. Look once
more there: hand the cached token to this caller and to any follower that
already picked up the fresh deferred, retire the entry, and skip the
signature. Ten reruns of the class pass where two in five failed before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
Lint reports 874 warnings / 0 errors per amethyst variant. 512 of those are
in Crowdin-managed values-*/strings.xml (mostly MissingQuantity — a
translator's plural missing a quantity) and are not ours to hand-edit; this
takes the ones that are mechanical and behaviour-preserving:
- EmptySuperCall (24 in amethyst, plus 2 in commons the amethyst run cannot
see): ViewModel.onCleared is documented empty, so drop the super calls.
- UseKtx (2): Canvas.withTranslation for the LaTeX drawable, and
Bitmap.toDrawable for the map pin — the KTX form CLAUDE.md asks for, and
both compile to the same calls.
- ConstantLocale (1): CalendarEventListCard held its "MMM" formatter in a
file-level val, which captures Locale.getDefault() once — month
abbreviations stayed in whatever language was active at class init. The
formatter is now cached per locale, which keeps the property the original
comment was protecting (a formatter per recompose was 500 allocations while
scrolling), and the locale comes from LocalLocale.current.platformLocale so
the read is observable: Locale.getDefault() inside a composable is not, and
Compose's own NonObservableLocale check rates that an error.
- UnusedResources (6): the Android Studio new-project wizard's leftover
colors (purple_200, teal_200, teal_700, black, white, transparent), each
verified unreferenced from Kotlin and XML. purple_500/700 are in use and
stay.
- UseTomlInstead (3): the debug-only Compose/Perfetto tracing dependencies
move into the version catalog. Same coordinates and versions; the catalog
already carries BOM-managed versionless entries.
playDebug goes from 874 warnings to 838, still 0 errors.
Deliberately left, because each is a decision rather than a cleanup:
AppLinkWarning (autoVerify only works if the domains serve a matching
assetlinks.json), the 126 unused source strings and 10 PluralsCandidate
(both churn the translation surface), GradleDependency /
NewerVersionAvailable (dependency bumps need the license check), VectorRaster
/ VectorPath / IconDensities / IconXmlAndPng (redrawing assets), BatteryLife
(the battery-optimization helper working as designed), and InlinedApi /
ClickableViewAccessibility / DiscouragedApi / InsecureBaseConfiguration (each
needs its surrounding intent read first).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
The zap picker gated its Lightning bolt on the recipient's lud16/lud06, but
the send path has routed a recipient with a kind:10058 offer over BOLT12 for
a while (when our default NWC wallet advertises `pay`). A recipient who
published only an offer therefore had no bolt in the popup and no one-tap
zap, even though ZapPaymentHandler could pay them.
Teach RailCapabilityResolver about the BOLT12 route: hasLightning is now true
for a recipient with an offer when our wallet can pay offers. The rail keeps
its single bolt — which flavour is used stays a send-time decision. The
popup observes the recipient's offer list and the default wallet URI so the
bolt appears as those load, and the one-tap fast path uses the same check.
The sender-side test moves into AccountZapActions.canZapViaBolt12 so the
handler, the picker and the profile dialog share it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
The Crowdin workflow now passes import_eq_suggestions: true, so a value
equal to the English source is no longer skipped on upload — the repo's
locale files are the seed for Crowdin's database. The skill said the
opposite ("Don't add source-identical fallbacks"), which now leaves keys
untranslated in the UI forever.
- Background rewritten for the flag, with the confirming evidence: run
34706537802 -> PR #4107 round-tripped 330 identical values with zero
net key changes.
- Items 1-3: missing keys are actionable in the repo; copy English
verbatim, except <plurals> (trips MissingQuantity) and words a locale
would genuinely translate.
- Item 4 reconciled with item 3: seeding a key Crowdin holds nothing for
sticks; overwriting a value it holds differently still loses.
- Records the diff-reading trap: compare key sets per file, never -/+
lines separately, or a reorder reads as a mass strip.
Every build printed "Deprecated Gradle features were used in this build,
making it incompatible with Gradle 10". With --warning-mode all that was
five distinct Kotlin DSL delegated-property deprecations, all in our own
scripts:
- `val x by extra(...)` / `val x: T by extra` in the root script, for the
opt-in Sonar gate that buildscript {} publishes and the body reads. Now
extra.set("x", v) and extra["x"] as T.
- `val x by getting { }` for eight of quartz's KMP source sets. Now
getByName("x") { }, which is what commons already used. None of those
vals were referenced, so the local binding goes away with them.
- `val x by tasks.registering { }` and the typed
`by tasks.registering(T::class) { }`, thirteen tasks across quartz,
commons, cli, geode, nestsClient and desktopApp. Now
tasks.register("x") { } and tasks.register<T>("x") { }, which return the
same TaskProvider, so the dependsOn / finalizedBy references to them are
unchanged.
`./gradlew --warning-mode all help` is now silent, and all nineteen
converted tasks still register and run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
BlossomReadAuthTokenProviderTest.aFastSignerStillSharesOneSignature still
fails about one run in three (round N: expected 1 signature, was 2), which
the pre-push hook turns into a hard block on every push.
The second cache look added in 6dc631e8 closes the leader-finished-early
window for callers that reach it after the leader retired its entry, but
not for a caller parked between that look and its own putIfAbsent: a
leader that starts after the caller's miss can sign, cache and retire in
that gap (a local key does it in microseconds), so the parked caller's
putIfAbsent then succeeds against an empty map and mints a second token.
Once the caller holds the in-flight slot, any earlier leader has already
cached, because a leader caches before it retires. So a cache hit taken
at that point is definitive: hand the cached token to ourselves and to
every follower already parked on our deferred, and retire the slot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
BOLT12 offers saved in Settings were only reachable through a small bolt
button in the profile header action row, next to a second NIP-A3 wallet
button, while every other way to pay a profile (Lightning, CLINK, on-chain,
Cashu, NIP-A3 targets) rendered as a chip in the payment rail below the bio.
Render one chip per NIP-B1 offer in that rail: tap opens the existing
copy / pay-with-wallet / pay-via-intent dialog for that offer, long-press
copies the raw lno1 string. Remove both header buttons, since the rail
already lists every NIP-A3 target with the same tap-to-pay and long-press
copy behaviour the dialog rows have. The two dialogs stay (the reaction row
still opens the NIP-A3 one), so their files are renamed after what is left.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq
`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose
resources, markdown and desktop Compose as dependencies, dragging ~40 MB of
UI/Skiko jars into every `amy` distribution. This moves every
Compose-dependent file into a new KMP module, `:commonsUI`, that
`api`-depends on `:commons`; `:commons` keeps only the Compose runtime
(stability annotations + snapshot state) and lifecycle-viewmodel.
Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split
is a build-graph boundary and no consumer import changed. 236 files were
`git mv`'d (composables, icons, robohash, theme, Coil fetchers, the
`@Composable` relay-client entry points, `composeResources`, and the tests
that exercise them). Two headless files needed surgery instead of a move:
`GalleryParser` lost a vestigial foundation `@OptIn`, and the
`LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of
`PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds`
and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels
depend on them.
`amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell
from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`,
`geode` and `marmotBench` do not. commons' androidMain gains an explicit
androidx.core KTX dep it previously got transitively through Compose UI.
CI, crowdin, the icon-font tools and the escaping hook point at the new
composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new
commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills
document the boundary. A plan doc under commons/plans records the
classification method and follow-ups.
Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites;
both verifyKmpPurity gates; the cli runtime classpath no longer resolves
Compose UI, material3, Skiko or Coil.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
Every test that used to open a socket to something outside the repo now
talks to geode, the relay this project ships, either in-process or as the
embedded `amy serve`.
- amethyst: the Android instrumented EventSyncTest dialed vitor.nostr1.com,
pyramid.fiatjaf.com and the nos.lol / nostr.mom defaults and asserted
nothing. Replaced by a JVM unit test on geode's InProcessRelays that
preloads a source relay and asserts what lands on the outbox, inbox and
DM relays, plus a NIP-42 variant with the source behind FullAuthPolicy
to cover the RelayAuthenticator wiring. Wires :geode, its testFixtures
and the JVM SQLite driver into amethyst's unit-test classpath, the same
way quartz's jvmAndroidTest already does.
- cli/tests: the cache, dm and marmot headless harnesses cloned and
cargo-built nostr-rs-relay on first run. They now boot `amy serve`
(geode) from the amy binary they already build, via a shared
start_local_relay / stop_local_relay in headless/helpers.sh. Rust is no
longer needed for the cache and dm suites at all.
- cli/tests/marmot/marmot-interop.sh: the interactive harness defaulted
to relay.damus.io / nos.lol / primal / bitcoiner.social / nostr.mom,
with `--local-relays` pointing at MDK's docker stack. It now boots the
embedded relay on 0.0.0.0 by default (the phone reaches it over the
LAN) and keeps the public set behind an explicit `--public-relays`.
- docs: cli/tests/README.md, CONTRIBUTING.md, cli/DEVELOPMENT.md and
cli/ROADMAP.md no longer describe a loopback nostr-rs-relay.
The quartz prodbench probes (NegentropyStallRepro, CursorTerminationProbe,
NegentropyMultiRelayLiveTest, ProductionReceiverBenchmark, ...) are left
as they are: they are opt-in diagnostics of production relay behaviour,
gated behind PROD_RELAY_BENCH / NEG_MULTI / NEG_STALL_REPRO, and would
measure nothing against a local relay.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
Second half of the warning sweep, covering :amethyst (both flavors, all
build types, unit + instrumented tests), :desktopApp and :benchmark:
- GitReplyEvent (NIP-34 kind 1622) is deprecated in favour of NIP-22
comments, but events already on relays still arrive and still have to be
routed, rendered and surfaced. @Suppress("DEPRECATION") with that reason
at the five production sites and the coverage test that pins them.
- The four instrumented Compose tests move to
androidx.compose.ui.test.junit4.v2.createComposeRule. The v2 factory
returns the same ComposeContentTestRule, so mainClock, setContent and the
node assertions are unchanged; only the effect dispatcher differs.
- RelayAuthPromptBusTest / RelayAuthSessionGrantsTest: @OptIn for the
ExperimentalCoroutinesApi members (testScheduler.currentTime, runCurrent)
they already use, matching the annotation the file's other tests carry.
- MarmotFileUploader: drop a nullable alias of a non-null cipher, left
behind when the v2 reference stopped being conditional.
- LocalCacheSearchParityTest: hoist the Json format out of the loader.
- LivesSection: FlowRowOverflow and FlowRow's overflow parameter are
deprecated; the non-deprecated overload already clips beyond maxLines.
- HexBenchmark: drop a bare `null` expression statement from the measured
lambda.
Also fixes :commons:compileCommonMainKotlinMetadata, which did not compile
at all: shared code called BigDecimal.toLong(), which resolves in every
platform compilation (every actual is a Number) but not in the common
metadata one, where only the expect class's own members are visible.
`expect class BigDecimal : Number` cannot work — java.math.BigDecimal leaves
toByte()/toShort() abstract, so the JVM typealias fails the expect/actual
modality check — so the conversion is a top-level expect/actual extension
instead, with actuals next to each BigDecimal actual.
Verified warning- and error-free across the jvm, android (play/fdroid ×
debug/release/benchmark), linuxX64, and the common/jvmAndroid/native/apple/
ios metadata compilations. The apple actuals are checked by
compileAppleMainKotlinMetadata, which runs the frontend against the Apple
klibs without needing a macOS host.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
Fixes the Kotlin warnings the compiler reports for these modules across the
jvm, android, linuxX64 and metadata compilations:
- PartialTokensTest / marmotBench: drop redundant casts. kotlin.test's
assertTrue carries a `returns() implies` contract, so the `is` check
already smart-casts; the benchmark values were never nullable-typed.
- MarmotPublish*Test, LastResortKeyPackageReuseTest: name overridden
parameters as the supertype does (`retainedSecrets`, `snapshot`), so
named-argument calls through the interface stay correct.
- AuthOutcomeTest: PersistentMap.put is deprecated in favour of putting(),
which is what the rest of the codebase already uses.
- IndexableContentGoldenTest: drop an unnecessary !! on a non-null String.
- Nip46Test: the generic encode/decode round trip cannot be checked at
runtime, so suppress UNCHECKED_CAST with a note on why it is safe.
- InternTradeoffBenchmark: hoist the liveness anchor from a local to a
field. As a local its assignments were visible to data flow, which folded
the trailing `check(sink != null)` into a constant.
- Http3GetClient: an empty `else -> {}` branch instead of a bare `Unit`
expression statement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123kXtseu4X18hL3GMDcdER
BlossomReadAuthTokenProvider.header() reads the token cache, then signOnce()
reads the in-flight map — two separate reads. A leader caches its token before
retiring its in-flight entry, so a caller sitting between those two reads sees
an empty cache (its read came first) and an empty in-flight map (the leader
already finished), and signs a second token for the same host.
A 300ms test signer never opens that window, which is why the provider's own
concurrency test missed it. A local in-process key signs in microseconds, so
BlossomReadAuthFetcherTest.aBurstOf401sSharesOneSignature — 16 fetchers that
all 401 and all retry — hit it and intermittently saw two distinct tokens.
signOnce() now takes a second look at the cache once it finds no in-flight
entry: an absent entry proves the leader's cache write is already visible, so
the straggler reuses that token instead of starting another signature.
Covered by a new aFastSignerStillSharesOneSignature, which runs the 16-caller
burst against an instant signer over many rounds — the existing test's slow
signer cannot reach the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011APd48WJ5pZVK4Ltpj3YpL
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
Three defects found auditing the Health Connect workout path.
applyPrefill only assigned a field when the incoming route carried a value,
so picking a second suggestion merged into the first instead of replacing it.
Tapping a run (5 km, 380 kcal) and then a gym session left the run's distance
and calories in the form — the user publishes numbers from a workout that
never happened. Every metric is now assigned on both branches. Notes are
deliberately left alone: they are typed by the user, never carried by a route.
The `source` tag published the writing app's display label ("Samsung Health"),
or its raw package name when that app is not installed. SourceTag defines a
vocabulary — gps / manual / health_connect — and the feed badge uppercases
whatever is in the tag, so an imported workout rendered as "SAMSUNG HEALTH"
or "COM.HUAWEI.HEALTH" beside other clients' "GPS". It now publishes
SourceTag.HEALTH_CONNECT, which existed for this and had no callers.
DetectedWorkout.source keeps the friendly name for the UI.
readNewWorkouts ran entirely on Dispatchers.Main: the callers launch into a
composable's rememberCoroutineScope, and nothing in the feature switched
dispatcher. Health Connect's own calls suspend, but resolveSourceName's
PackageManager lookup is a blocking binder call made once per session, so a
week of sessions blocked the UI thread once each. The read now runs on
Dispatchers.IO and the label lookups are memoized per writer package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
NIP-34's PR Update example carries only `["E", <pull-request-event-id>]` —
there is no lowercase `e` tag on a 1619 at all, and
GitPullRequestUpdateEvent.build() writes only RootEventTag to match. Listing
1619 in RepliesAndReactionsKinds2 (the `#e` filter's kind list) therefore
never pulled a single PR revision, and the comment claiming it was "rooted at
the target patch/PR/issue via a `root`-marked `e` tag" was wrong for that kind
(it is correct for the 1630-1633 statuses beside it).
The `#E` filter added in the previous commit is what actually makes a PR's
revision chain reachable from an on-screen PR row, so 1619 moves there and
comes out of the `e` list.
Nip34NotificationCoverageTest asserted the false half, which is how this
survived: it now checks each kind against the filter that can actually match
it, and pins 1619 out of the `e` list so it cannot drift back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARAmQQWbT2gGVo9tysdAgM
Per CONTRIBUTING-WITH-AI.md, the branch got a review pass before the PR. All
six findings were real; none of them fire in the nostr.build case the feature
was built against, which is exactly why the tests missed them.
- Only a root-relative `og:audio`/`og:video` was resolved. A document-relative
`media/x.mp3` -- and any value at all when the page URL fails to parse --
reached the player verbatim as a relative string, which can only hang. Now
every reference resolves against the page, and an unresolvable one is refused
rather than passed on.
- Nothing checked the scheme. `og:audio = file:///...` with `audio/mpeg` passed
the gate, letting a remote page aim the local player at a local URI. Playable
media must now be http(s).
- The branch keyed on "is this playable", not "is this a player page", so an
article that merely embeds a clip lost its entire card -- headline, summary,
host, tap-through -- and got a bare player instead. `og:type` is now read and
required to be `music.*` or `video.*`; an article, or a page that declares no
type, keeps its card. The fixtures already carried the tag; nothing read it.
- Relaxing fetchComplete() broke the unstated "Loaded implies a non-empty
image" invariant, so a cover-art-less track page painted a blank 180dp box in
UrlPreviewCard (and through it UrlScreen and DisplayExternalId).
WebBookmarksScreen guarded with `!= null` on a non-null String, which is
vacuous. Both now test for blank.
- The new `audio/*` branch in UrlPreview produced Loaded states that the
composer's two preview handlers did not cover, so a direct audio URL was
handed to Coil to decode as an image and showed an empty square. Audio joins
video there, as it already had in LoadUrlPreview.
- The video/audio branch never forwarded the fetched Content-Type to the
player, contradicting its own new comment. An HLS playlist served as
`audio/x-mpegurl` from an extension-less URL was then unrecognisable to
MediaItemCache and played as progressive, i.e. not at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8uqrrzDpqUYxV9R7CJgxW
Google rejected the Health Connect declaration for "Insufficient Information
to Determine App Functionality": neither the store listing, the privacy
policy, nor the in-app experience explained what Amethyst does with the seven
read permissions it asks for.
All seven are used by HealthConnectManager, so none can be dropped. What was
missing was the explanation, on every surface a reviewer looks at:
- The rationale intents the manifest declares
(ACTION_SHOW_PERMISSIONS_RATIONALE, ACTION_VIEW_PERMISSION_USAGE +
CATEGORY_HEALTH_PERMISSIONS) pointed at MainActivity, which handles neither
— tapping "privacy policy" in Health Connect just opened the feed. They now
land on HealthConnectRationaleActivity, a static, account-free screen that
lists each data type, what it fills in, and what the app never does. It is
also reachable from a "What Amethyst reads" link on the Connect card, so
the rationale is available before the permission request, not only after.
- PRIVACY.md gains a "Health and fitness data (Health Connect)" section: a
per-type purpose table plus the limits (read-only, foreground-only, 7-day
window, no route/background/history permissions, no secondary use).
- The Play listing description now covers the app's features and the
Workouts flow, so the listing reflects what the permissions are for.
- docs/health-connect-play-declaration.md holds the paste-ready Play Console
text: app functionality, a reviewer walkthrough, and a per-permission
justification — including that CyclingPedalingCadence and StepsCadence come
bundled with READ_EXERCISE and READ_STEPS and are never read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019egdJyBHnrATZHjs86up8f
The per-note engagement subscription only asked relays for the lowercase
`e`/`a` tags. NIP-22 puts the conversation root in the **uppercase** `E`/`A`
tags and only the direct parent in lowercase, so a kind-1111 comment two or
more levels deep never carries `e`=<rootId> (nor `a`=<address> when the root
is an article). Those comments therefore never matched the feed's REQs and
only showed up once ThreadScreen opened its own `E`/`A` subscription — which
is exactly the "half the replies appear later" behaviour on note cards.
Kind-1 threads were unaffected: NIP-10 repeats the root `e` tag on every
descendant, so the existing filter already caught them.
LocalCache was never the problem: CommentEvent.tagsWithoutCitations()
already returns root + reply ids, so a nested comment is wired into the root
note's replies as soon as it arrives — it simply never arrived.
Adds an `E` filter (kinds 1111 + 1619, which also anchors with `E`) to
filterRepliesAndReactionsToNotes and an `A` filter (kind 1111) to
filterRepliesAndReactionsToAddresses. They are separate filters because tag
names inside one filter are ANDed — folding `E` into the `e` filter would
only match comments carrying both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARAmQQWbT2gGVo9tysdAgM
nostr.build mirrors its audio player page for video, confirmed against the live
host: `e.nostr.build/v_<id>_mp4` answers `text/html` and declares
og:video https://v.nostr.build/<id>.mp4
og:video:type video/mp4
— the same shape as the `og:audio` page, and the same `_mp4`-without-a-dot trap
the previous commit stopped misrouting into ExoPlayer. So og:video gets the same
treatment: parsed, type-checked, and played with the page's og:image as poster.
Reading og:video is not the same as trusting it, and video is where that
distinction earns its keep. YouTube declares
og:video:url https://www.youtube.com/embed/dQw4w9WgXcQ
og:video:type text/html
which is markup, not a video — playing it would reproduce the exact bug this
series exists to fix. UrlInfoItem therefore accepts a declaration only when its
type holds up (classifyMedia == VIDEO, so `video/*`, `audio/*` and the HLS
playlist MIMEs all pass, `text/html` does not) or, absent a type, when the URL
carries a real media extension. YouTube falls through to its link card, as it
should. Both cases are pinned as tests from the live tags.
`playableAudioUrl` collapses into `playableMediaUrl` + `playableMediaType`, so
the renderer has one branch and the preference rule — video wins a page that
declares both, since the audio path deliberately strips the picture — lives in
one testable place. A refused og:video still falls back to a playable og:audio.
Also reads the `:url`/`:secure_url` aliases, since hosts disagree on which to
emit (nostr.build the bare key, YouTube only `:url`), and drops the parser's
early exit outright: it stopped at title+description+image, a set every page
completes *before* it reaches the media tags, so it could only ever skip them.
The scan is bounded regardless — MetaTagsParser stops at `</head>`.
Verified: the nostr.build and YouTube tags above are from curl against the live
pages. commons jvmTest (2163) and amethyst fdroid unit tests (1471) pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8uqrrzDpqUYxV9R7CJgxW
A note linking nostr.build's audio player page rendered a dead player:
https://e.nostr.build/a_ETvKzX2OdOGmFEp1avRlm5_mp3?t=Aria&by=The+Fishcake
That URL is not an mp3. It answers `Content-Type: text/html` — it is the
human-facing player page, and the track it is about lives at
`a.nostr.build/ETvKzX2OdOGmFEp1avRlm5.mp3` (`audio/mpeg`), named in the page's
`og:audio`. We fed the HTML to ExoPlayer anyway, which can only buffer forever
and then show the browser-fallback overlay.
Two independent defects, fixed together:
1. Extension matching ignored the dot. `isAudioUrl`/`isVideoUrl`/`isImageUrl`/
`isPdfUrl`/`classifyMedia`/`parseImageOrVideo` trimmed the query and then
asked `endsWith("mp3")`, so a path ending in the *letters* of an extension
matched — `_mp3` here, and any prose slug such as `/my-thoughts-on-mp3`.
They now share one allocation-free matcher, `RichTextParser.hasExtensionIn`,
which requires a real dot-introduced extension in the last path segment and
compares case-insensitively (so `.Mp3`, which the doubled lower/UPPER lists
never covered, resolves too). SupportedContent carried a second copy of the
same dot-less scan — it admitted the player page into the video feed — and
now delegates to the shared matcher instead.
On its own this change turns the broken player into the correct OpenGraph
link card, because an unclassifiable URL falls through to LoadUrlPreview.
2. The preview pipeline ignored og:audio. OpenGraphParser now reads `og:audio`,
`og:audio:secure_url` and `og:audio:type` alongside title/description/image,
and UrlInfoItem exposes `playableAudioUrl` — the resolved URL, but only when
the page vouched for the type (an `audio/` MIME, or a genuine audio
extension when the MIME is absent), so a junk `og:audio` still falls through
to the card. LoadUrlPreview plays that file with the page's `og:image` as
cover art. Note the parser's early exit had to wait for the audio pair:
pages emit `og:audio` after `og:image`, so stopping at title+description+
image skipped the one field that makes the page playable.
So the note now plays, rather than merely stopping at a broken player.
Also: a URL that serves `audio/*` without an audio extension no longer throws
"unknown encoding" out of UrlPreview and lose the note's link, and fetchComplete()
counts playable audio as evidence so a track page with no cover art is not
discarded as Empty.
Verified: the Content-Type and og:audio claims above are from curl against the
live URLs. commons jvmTest (2154) and amethyst fdroid unit tests (1471) pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8uqrrzDpqUYxV9R7CJgxW
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
Fill the strings and plurals missing from the cs, de-rDE, sv-rSE and
pt-rBR locale files in both resource trees:
- amethyst: git status notifications, relay login dialogs, Marmot
disband/encrypted attachments, pay-to hand-off, napplet encryption,
library/ratings labels, library_directory_* plurals
- commons: Marmot avatar link, disband, disappearing messages and
system event lines, search empty states, publication/profile
labels, publication_* and birdex_species_more plurals
Source-identical entries (format strings, placeholders, brand names,
loanwords) are left for Crowdin to decide.
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
Bumps every catalog entry that has a newer release, verified with the
JVM/Android unit test suites and the desktop Compose UI tests.
Highlights:
- Kotlin 2.4.10 -> 2.4.20 (and kotlinTest, ksp 2.3.12)
- Compose Multiplatform 1.11.1 -> 1.12.0, androidx compose BOM 2026.09.00,
runtime-annotation 1.12.1
- coil 3.5.0 -> 3.6.2, media3 1.11.1, navigation-compose 2.10.1,
camera 1.6.2, sqlite 2.7.1, firebase BOM 34.19.0, vico 3.3.1
- benchmark 1.5.0-rc01 -> 1.5.0 (final), kotlinx-collections-immutable 0.5.2,
slf4j 2.0.19, spotless 8.10.2, sonarqube plugin 7.5.0.8588
- CI: actions/cache v4 -> v6 in build.yml, matching create-release.yml
Two entries needed more than a version change:
- material3 1.9.0 -> 1.12.0-alpha03. coil 3.6.2 requires Compose
Multiplatform 1.12.0, whose foundation changed the CustomStyle.applyStyle
signature. Every material3 release built against foundation 1.11.x (the
stable 1.9.0, and the 1.11.0-alpha07 kodein-emoji drags in) then throws
AbstractMethodError from OutlinedTextFieldDefaults at runtime; two desktop
UI tests caught it. 1.12.0-alpha03 is the first release built against
foundation 1.12.x. Android still takes androidx material3 from composeBom,
so this ref only drives desktop/iOS.
- cachemap stays at 0.2.4. 0.3.0 makes CacheMap.entries/keys/values
DeprecationLevel.ERROR and throws at runtime; quartz's appleMain
LargeCache/ConcurrentHashCache read those views directly and through the
stdlib Map operators. Porting them to the new forEach/forEachKey/
forEachValue API needs a build on an Apple target, so it is left for a
follow-up. Rationale recorded in the catalog.
commons/build.gradle.kts: DisableCacheInKotlinVersion.2_4_10 no longer
exists in KGP 2.4.20 (the enum keeps a sliding window on purpose, to force a
re-check each release), so the iOS ui-uikit prebuilt-cache workaround moves
to 2_4_20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXfvJi9QshDv9Umzp3ZYgQ
2026-09-11 15:12:58 +00:00
817 changed files with 11179 additions and 2962 deletions
@@ -47,7 +47,7 @@ Walk the imports. The usual offenders:
| `android.util.Log` | Replace with `quartz``PlatformLog` (already multiplatform). |
| `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. |
| `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. |
| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. |
| `androidx.compose.*` | Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` must stay out of `commons` entirely — they belong in `:commonsUI`, which Amy never depends on. Only the Compose *runtime* (`@Stable`, snapshot state) is allowed in `commons`. |
### Step 3 — Pick a migration strategy per dependency
@@ -66,7 +66,7 @@ Walk the imports. The usual offenders:
# Target location depends on what it is:
# - Protocol → quartz/src/commonMain/kotlin/…
# - Business logic → commons/src/commonMain/kotlin/…
@@ -24,7 +24,7 @@ Visual UI patterns for sharing composables across Android and Desktop.
## Philosophy: Share by Default
**Default to `commons/commonMain`** unless platform experts indicate otherwise.
**Default to `commonsUI/commonMain`** (shared composables live in `:commonsUI`, the Compose half of the shared layer; headless state/ViewModels stay in `:commons`) unless platform experts indicate otherwise.
### Always Share
@@ -416,7 +416,7 @@ fun DataScreen(uiState: UiState) {
This catalog documents shared UI components in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/`.
This catalog documents shared UI components in `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/` (the Compose half of the shared layer; headless state stays in `commons`).
## Directory Structure
```
commons/src/commonMain/kotlin/.../commons/ui/
commonsUI/src/commonMain/kotlin/.../commons/ui/
├── components/ # Reusable UI components
├── screens/ # Screen-level composables
├── theme/ # Theming and styling
@@ -232,7 +232,7 @@ private val pathData1 = PathData {
description:Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../ui/feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
description:Feed composition and data-access layer patterns in Amethyst. Use when adding or modifying a feed (home, profile, hashtag, bookmarks, notifications, DMs, communities), working with the shared `FeedFilter` / `AdditiveFeedFilter` / `ChangesFlowFilter` / `FeedContentState` in `commons/.../feeds/`, the Android-only `AdditiveComplexFeedFilter` / `FilterByListParams` in `amethyst/.../ui/dal/`, or extending the `FeedViewModel` family in `commons/.../viewmodels/`. Covers how feeds scan `LocalCache`, react to changes, apply ordering, and render through Compose.
---
# Feed Patterns
@@ -25,7 +25,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
│ ◄── MarmotGroupFeedViewModel │
│ │
│ │
│ commons/.../ui/feeds/ (shared, KMP) │
│ commons/.../feeds/ (shared, KMP) │
│ IFeedFilter / FeedFilter<T> (abstract base) │
│ IAdditiveFeedFilter / AdditiveFeedFilter<T> │
│ ChangesFlowFilter │
@@ -68,7 +68,7 @@ Amethyst's "feed" abstraction is: a `FeedFilter` that decides which notes belong
- **`FeedFilter.kt`** — `abstract class FeedFilter<T> : IFeedFilter<T>`. Has `feed(): List<T>` (the sync query against the cache), `feedKey(): String` (identity used to cache), `limit()`, and `loadTop()`.
- **`AdditiveFeedFilter.kt`** — `abstract class AdditiveFeedFilter<T> : FeedFilter<T>(), IAdditiveFeedFilter<T>`. Adds incremental updates (the "additive" part): `updateListWith(oldList, newItems)` runs `applyFilter(newItems)` and grafts accepted items onto the existing list (re-`sort` + `take(limit())`) without recomputing everything.
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../ui/feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**.
- The filter **base classes** (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`) and feed state (`FeedContentState`) are in `commons/.../feeds/` — **shared**. ViewModels are in `commons/.../viewmodels/` — **shared**.
- The **concrete** filters are platform-local: Android's in `amethyst/.../ui/screen/loggedIn/*/dal/`, Desktop's in `desktopApp/.../feeds/`. `amethyst/.../ui/dal/` keeps Android-only helpers (`AdditiveComplexFeedFilter`, `FilterByListParams`, `DefaultFeedOrder`) plus back-compat typealiases.
- When porting a feed, share the concrete filter only if both platforms need identical inclusion rules.
@@ -7,7 +7,7 @@ description: Use when comparing Android strings.xml locale files to find untrans
## Overview
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs the missing keys, then offers the two things that close them: **translate** the ones needing translation, and **copy the English value verbatim** for the ones a locale deliberately keeps in English (since 2026-09-12 that copy is what seeds Crowdin — see Background).
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
@@ -24,9 +24,9 @@ There are two separate `strings.xml` trees, each with its own default `values/`
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:**`commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
@@ -37,7 +37,7 @@ The `commons` tree appeared when shared event-renderer composables were extracte
Detect name-overlap **and flag value mismatches** in one pass:
<(grep '<string name='"$cdef"| sed 's/.*name="\([^"]*\)".*/\1/'| sort -u)\
@@ -54,7 +54,7 @@ Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
## Background: Crowdin strip-identical behavior
## Background: source-identical translations and the `import_eq_suggestions` flag
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin does not *store* a translation that exactly equals the source unless it is told to, so historically a key a translator deliberately kept as English (brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, version prefixes like `"v%1$s"`) never appeared in the locale's `strings.xml`, even though the Crowdin UI showed it as 100% translated.
**That changed on 2026-09-12.**`.github/workflows/crowdin.yml` now passes `import_eq_suggestions: true` to `crowdin/github-action`, so `upload_translations` no longer skips values equal to the source — whatever sits in the repo's locale files is seeded into Crowdin's database, identical values included. `auto_approve_imported` stays at its default `false`, so they arrive as **pending** translations for a translator to approve.
Confirmed end-to-end the same day: the first sync after the flag landed (workflow run `34706537802` → PR #4107) rewrote all five touched locale files in Crowdin's own key order with **zero net key changes** — 323 additions and 323 removals that pair up exactly. All 330 identical values pushed that morning came back down intact, unapproved included. Since Crowdin's download *replaces* file content with its export, a value it did not hold would have vanished; none did.
**Reading such a sync diff: compare key *sets* per file, never `-`/`+` lines separately.** A reorder looks identical to a mass strip under `grep '^-'`, and it will convince you the mechanism failed when nothing changed at all.
What this means for this skill:
1.**The raw on-disk diff is the candidate set.** A key missing from a locale file is either genuinely untranslated *or* a source-identical entry Crowdin stripped. Both are reported; the human decides which to skip. The Crowdin web UI ("N untranslated") is the ground truth for what genuinely needs work.
2.**Source-identical entries are a small, recognizable minority.** Brand terms (`Nowhere X`), single-word loanwords (`Apps` / `Feed` / `Issues`), and bare version/format strings (`v%1$s`) are the usual cases. Skip these by inspection rather than translating them to something identical.
3.**Don't add source-identical fallbacks.** Android falls back to `values/strings.xml` at runtime, so a key intentionally kept as English already renders correctly, and Crowdin's next sync would strip a local duplicate anyway.
1.**The raw on-disk diff is the candidate set.** A key missing from a locale file is genuinely untranslated,*or* a source-identical entry stripped before 2026-09-12 that no sync has re-seeded yet. Both are reported, and both are now actionable in the repo — translate the first, copy English into the second. The Crowdin web UI ("N untranslated") remains the ground truth for what needs human work.
2.**Source-identical entries are still recognizable, but no longer skipped.** Brand terms (`Nowhere X`), loanwords (`Apps` / `Feed` / `Issues`), symbol- or format-only values (`v%1$s`, `+%1$d`, `%1$d/%2$d`, `∞`, 👀) and example placeholders (`iPhone 13`, `https://example.com`) are the usual cases. Copy the English value into the locale file verbatim so the upload can seed it.
3.**DO add source-identical values — that is now the mechanism, not churn.** A key absent from a locale file is invisible to `upload_translations`; writing the English value in is what gets it into Crowdin, so a translator approves it once in bulk instead of typing it into the UI ~70 times per locale. (Runtime behaviour is unchanged either way: Android still falls back to `values/strings.xml`.) Two exclusions:
- **Never for `<plurals>`.** Copying English `one`/`other` into cs/pl trips `MissingQuantity`, which is a CI error (cs needs `one`/`few`/`many`/`other`). Plurals stay a Crowdin-UI job.
- **Not for words a locale would genuinely translate.** German `buzz_dm_workspace` ("Arbeitsbereich"), `workout` ("Training"), `relay_group_threads_title` ("Themen"), `calendar_rsvp_section` ("Zusagen") are *gaps*, not deliberate English keeps. Copying English there seeds a wrong pending suggestion — list those for the human to translate rather than approve.
4. **A repo-side edit to a translated value only sticks where Crowdin's database
doesn't contradict it.** Download replaces file content with Crowdin's current
@@ -96,6 +104,13 @@ What this means for this skill:
from `values/strings.xml` removes it project-wide, and attributes declared
there propagate into every export.
**This does not contradict item 3 — the two cases differ.** Seeding a key
Crowdin holds *nothing* for (the identical-value copy) sticks, because there is
no stored value to contradict it; that is exactly why the copy pass works.
*Overwriting* a value Crowdin already holds differently — including an empty
one — still loses on the next sync. Add missing entries in the repo; change
existing translations in the UI.
> **Historical note:** an earlier version of this skill tried to auto-filter the
> candidate list with a git "sync-timestamp" heuristic (skip any key added before
> the last `New Crowdin translations` commit). It was **dropped** because it
A convenient way to run the whole technique twice is to loop over the two base dirs:
```bash
for base in amethyst/src/main/res commons/src/commonMain/composeResources;do
for base in amethyst/src/main/res commonsUI/src/commonMain/composeResources;do
echo"########## tree: $base ##########"
# ... run the diff/count/value-extraction commands with $base/values[...] ...
done
@@ -172,7 +187,7 @@ comm -23 \
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
Locale files are asymmetric — legacy pre-2026-09-12 strips and uneven translator progress both leave different keys missing in different locales — so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
```bash
for locale in cs de-rDE sv-rSE pt-rBR;do
@@ -190,7 +205,7 @@ for locale in cs de-rDE sv-rSE pt-rBR; do
done
```
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set (minus any source-identical entries you skip by inspection — see Background).
The combined `strings + plurals` total should line up with the Crowdin web UI's untranslated count for that locale. If it does, the raw diff is your actionable set: translate what needs translating, and copy the English value verbatim for the entries a locale keeps in English (see Background).
### 3. Get English values for missing keys
@@ -258,8 +273,8 @@ Flag and offer to fix:
# hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales, in BOTH resource trees.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
Three things this scan taught us, all of which it now encodes:
@@ -458,7 +473,7 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because locale files are asymmetric (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
@@ -528,15 +543,15 @@ When adding translated strings to locale files:
## Common Mistakes
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commonsUI/src/commonMain/composeResources`). A key extracted into `commonsUI/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
- **Forgetting `translatable="false"`** — these should never appear in locale files
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Skipping source-identical entries instead of copying them in** — correct before 2026-09-12, wrong now. With `import_eq_suggestions: true` the repo file is the *seed* for Crowdin's database, so a key you leave out stays untranslated in the UI forever and reappears in every future scan. Copy the English value verbatim, except for `<plurals>` (trips `MissingQuantity`) and words the locale would really translate. (Confirmed by PR #4107: 330 identical values survived the next sync with zero net changes.)
- **Skipping per-locale diffs when only diffing cs** — different keys are missing in different locales (legacy strips plus uneven translator progress), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **Declaring the pass done without running `:amethyst:lintPlayBenchmark`** — the duplicate-key + XML + `convertXmlValueResourcesForCommonMain` gate is necessary but nowhere near sufficient. `MissingQuantity` and `ImpliedQuantity` are errors, there is no lint baseline, and `abortOnError` is on, so a change that compiles and passes every check in Step 6's first half can still take CI red. Compiling is not evidence. (Happened 2026-08-13: 3 lint errors after a clean duplicate/XML gate and a green `compileFdroidDebugKotlin`.)
- **Converting a `<string>` to `<plurals>` with `other` only** — "Crowdin fills the rest" is false; `MissingQuantity` errors immediately and CI fails before any sync. Supply every category the locale uses at conversion time, and re-check the declension rather than reusing the old text for `one`.
@@ -17,7 +17,7 @@ The layer between `LocalCache`/`Account` and the raw relay connection. Ensures c
## Layout
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`:
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` (the `@Composable` entry points — `observeUser*`, `*FilterAssemblerSubscription`, `KeyDataSourceSubscription` — sit in the same package but in `commonsUI/src/commonMain/…`, the Compose half of the shared layer):
| Artifact | Committed at | Regenerate when | Guide |
|---|---|---|---|
| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
> **Material Symbols is mandatory after icon changes.** The bundled font is a
@@ -105,8 +105,9 @@ and each has its own guide:
> change. Reusing an existing codepoint needs no regeneration.
Both tools have their own prerequisites (`fonttools`/`brotli` for the font; a
Rust toolchain + Android NDK 25+ for Arti) documented in their READMEs — they
are **not** required to build Amethyst from the committed sources.
Rust toolchain + the exact Android NDK revision pinned in
`tools/arti-build/ANDROID_NDK_VERSION` for Arti) documented in their READMEs —
they are **not** required to build Amethyst from the committed sources.
---
@@ -466,8 +467,8 @@ Homebrew removes the quarantine attribute on its own downloads.
> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and
> now prints the per-file notary log on a non-`Accepted` verdict. If it comes
> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the
> `:commons`core/ui split the size budget already flags). The **desktop** app
> zipping (the unused `skiko`/Compose jars left the CLI image with the
> `:commons`/ `:commonsUI` split). The **desktop** app
> bundles the same jars through Compose/jpackage notarization, so run a desktop
> dry-run too; its in-jar handling differs and is likewise unverified.
@@ -533,14 +534,17 @@ reads an optional per-release changelog from
## Bootstrap runbook (one-time)
> **Status as of v1.14.0:**Winget has been submitted — [microsoft/winget-pkgs#422752](https://github.com/microsoft/winget-pkgs/pull/422752), pending CLA + review. Neither Homebrew package (`amethyst-nostr` cask, `amy` formula) has been submitted yet.
> `https://formulae.brew.sh/api/cask/amethyst-nostr.json` and
> `microsoft/winget-pkgs/manifests/v/VitorPamplona/Amethyst` both 404, so
> **Amethyst does not currently ship through either channel.** The bump
> workflows detect this and skip with a `::warning::` instead of failing, so a
> green release run does *not* mean Homebrew/Winget shipped. The two subsections
> below are the work that activates them; until then treat the desktop app as
> GitHub-Releases-only on macOS and Windows.
> **Status as of v1.15.2:**both Homebrew packages are now live upstream — the
> `amethyst-nostr` cask (`Homebrew/homebrew-cask`, at 1.14.0) and the `amy`
> formula (`Homebrew/homebrew-core`) both answer 200 on `formulae.brew.sh`, so
> `bump-homebrew.yml` finally has something to bump. **Winget is still not
Amethyst is free, open-source software (MIT License — see `LICENSE`). It is not a service. There is no Amethyst server, no Amethyst account, and the developer has no access to data stored on your device.
@@ -20,6 +20,7 @@ Using the app causes the following data to leave your phone:
- **Nostr events** you publish, sent to the relays you have configured.
- **Subscriptions** (filters describing what you want to read), sent to those relays.
- **Media uploads** (images, audio, video), sent to the media server you select.
- **Workout summaries**, when you choose to publish one — see [Health and fitness data](#health-and-fitness-data-health-connect) below.
- *(Google Play build, push notifications enabled)* a per-device push token, your public key, and a preferred relay, registered with Google Firebase Cloud Messaging so a notification proxy can wake the app.
- *(F-Droid build, push notifications enabled)* a per-device token registered with whichever UnifiedPush distributor you install (e.g. ntfy).
@@ -29,6 +30,36 @@ The developer does not run any server that aggregates or stores this data.
Configuration, cached events, keys, drafts, and other operational data live in the app's local storage. Other apps cannot read it on a standard, non-rooted Android device. You can wipe it by clearing the app's storage or uninstalling.
### Health and fitness data (Health Connect)
Amethyst's **Workouts** section lets you publish a summary of a finished workout to the Nostr relays you choose (a NIP-101e kind 1301 event), so the people who follow you can see it. To save you typing the numbers in by hand, Amethyst can read the workout your watch or fitness app already saved to **Android Health Connect** and pre-fill the post.
The feature is optional and off until you grant the permissions. Amethyst asks for them only when you open the New Workout composer — never on first launch.
**What Amethyst reads, and what each type is for:**
| Health Connect data type | Permission | What it is used for |
| --- | --- | --- |
| ExerciseSession | `READ_EXERCISE` | The workout itself: activity type, start time and duration — the title, date and duration of the post. |
| Distance | `READ_DISTANCE` | The distance of the run, ride, walk or swim. |
| ActiveCaloriesBurned | `READ_ACTIVE_CALORIES_BURNED` | The energy the workout burned. |
| TotalCaloriesBurned | `READ_TOTAL_CALORIES_BURNED` | Fallback energy figure for sources that only record total energy. |
| HeartRate | `READ_HEART_RATE` | Average and maximum heart rate over the workout — how hard the effort was. |
| Steps | `READ_STEPS` | The step count of a run, walk or hike. |
| ElevationGained | `READ_ELEVATION_GAINED` | How much you climbed. |
Health Connect groups a few data types under one permission: `READ_EXERCISE` also covers CyclingPedalingCadence and `READ_STEPS` also covers StepsCadence. Amethyst does not read, store, or publish cadence — those types come attached to the permissions above and are never requested separately.
**Limits on this access:**
- **Read-only.** Amethyst never writes to Health Connect.
- **Foreground only.** Reads happen only while the New Workout composer is on screen. Amethyst does not request `READ_HEALTH_DATA_IN_BACKGROUND` and has no background health worker.
- **Last 7 days only.** Only sessions that finished in the previous 7 days are offered. Amethyst does not request `READ_HEALTH_DATA_HISTORY`.
- **No location.** Amethyst does not request `READ_EXERCISE_ROUTE`, so it never receives the GPS track of a workout.
- **Nothing is uploaded automatically.** Health data stays on your device until you pick a suggestion, review the pre-filled post, and publish it yourself. The developer runs no server; a published post goes to the Nostr relays you configured, and those numbers then become public like any other post you make.
- **No other use.** Health data is never used for advertising, analytics, profiling, or sale, and is never shared with third parties. It is not used to determine your eligibility for insurance, credit, or employment, and is not transferred to any such party.
- **Revocable.** Turn the feature off under Settings → Compose → "Suggest workouts to share", or revoke the permissions in Health Connect at any time. Amethyst keeps the workout suggestions it has already shown only in memory; revoking access stops all reads immediately.
| **Homebrew formula** | `Homebrew/homebrew-core` → `geode-relay` | Not submitted — renamed from `geode`, which is permanently reserved for Apache Geode |
| **Winget** | `microsoft/winget-pkgs` → `VitorPamplona.Amethyst` | **Submitted at v1.14.0** — [PR #422752](https://github.com/microsoft/winget-pkgs/pull/422752), still open pending CLA + review |
Until each lands, that channel delivers nothing and macOS/Windows users get the
desktop app from GitHub Releases only. Re-check before assuming — the state
above is a snapshot, and `gh api repos/microsoft/winget-pkgs/contents/manifests/v/VitorPamplona`
(404 = still absent) answers it in one call.
Until each lands, that channel delivers nothing and its users get the desktop
app or CLI from GitHub Releases only. Re-check before assuming — the state above
is a snapshot, and two calls answer it:
`gh api repos/microsoft/winget-pkgs/contents/manifests/v/VitorPamplona` and
@@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() {
overridefunonCleared(){
liveScanJob?.cancel()
super.onCleared()
}
funtoggle(address:Address){
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.