- Correct the KDoc on the Shorts and Longs composers. They claimed
everything posted from them lands in that feed; VideoPostKind only
governs videos, and the gallery picker takes images with no mime
filter, so a picked JPEG still posts as a kind-20 picture that
neither feed reads. Say so instead of asserting a false invariant.
- Forward the shared text as the composer's caption. The media targets
dropped EXTRA_TEXT entirely, so sharing a photo with a caption lost
it — while the DM target kept it. The routes now carry the message
and NewMediaModel.load() seeds the caption field from it.
- Accept SEND_MULTIPLE on the three media targets. Sharing several
files at once previously did not offer Amethyst at all, even though
the picture composer publishes N images as one kind-20 event. Routes
carry a URI list; other targets take the first.
- Replace, rather than stack, a feed entry when a second share arrives
while the first is still open. Only entries that carry attachments
are replaced, so a feed reached from the bottom bar keeps its
tab-root marker underneath.
- Rename NewImageButton to NewVideoFeedButton: it is the Video feed's
composer and handles pictures and video alike.
Adds ShareTargetManifestTest, which pins the activity-alias names in
AndroidManifest.xml to the constants ShareIntentRouting matches them
by — in both directions, plus the SEND_MULTIPLE filters. That link is
invisible to the compiler and fails silently at runtime by routing a
share to the wrong composer. Verified the guard bites by renaming an
alias in the manifest alone: three of its four tests go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
Adds three share targets next to "New Post", "Send as DM" and
"New Highlight":
- New Picture (image/*) -> the picture feed's composer, publishing a
NIP-68 kind 20 picture post
- New Short (video/*) -> the Shorts feed's composer, publishing a
NIP-71 kind 22 short
- New Video (video/*) -> the Video feed's composer, publishing a
NIP-71 video event
Until now every SEND intent landed in the kind-1 composer, so a shared
picture became a text note with a link instead of a post in the feed
the user was aiming for.
Each alias resolves to MainActivity like the existing ones, so
ShareIntentRouting now maps the launching component class to a
ShareTarget enum instead of a growing chain of isShareAsX() booleans.
The media targets navigate to the destination feed carrying the shared
content URI, and the feed's existing composer button opens on it.
Video kind is no longer guessed from orientation alone in feeds that
only read one of the two kinds: the Shorts composer always publishes
kind 22 and the Longs composer always publishes kind 21, so a post
lands in the feed it was composed from whatever the footage's shape.
The mixed Video feed and the picture feed keep the automatic choice.
Also fixes the New Post launch path passing the literal string "null"
as the attachment when a share carried no media.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
Four kind-0 events collected from relays exposed two content-parsing
problems. Both `MetadataEvent.contactMetaData()` and `contactMetadataJson()`
are shared code, so Jackson (JVM/Android) and kotlinx (native) were equally
affected — verified against both mappers.
Empty content is a valid, empty profile — someone wiping their metadata —
not a parse failure. It logged "Content Parse Error" and returned null,
which made `LocalCache.consume` drop the event, so the stale profile stayed
in place forever even though a newer replaceable event had arrived. Blank
content now decodes to a blank `UserMetadata` (and an empty `JsonObject`).
A string field wrapped in a one-element array (`"nip05":["a@b.com"]`) has
only one possible reading, so `TolerantStringSerializer` now unwraps it
rather than silently dropping the user's NIP-05 verification. Empty,
multi-element, and non-primitive arrays stay ignored.
The other two events already behaved correctly and are pinned as
regressions: `"nip05":{}` with foreign client keys, and Ditto's ambiguous
`"birthday":"10-24"` — both drop just the offending field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGYcqfBKsP9SCvRdPzdkSx
`loadMetadataBatched follows the same retry semantics` failed on the macOS
build-desktop runner (1431 tests in the module, 1 failed). The coordinator is
fine; the test was wall-clock coupled.
It fired call 1 with `timeoutMs = 200`, waited a flat `delay(350)`, then
asserted that call 2 re-subscribed. That leaves a 150ms budget for
`scope.launch` to be scheduled, subscribe, `awaitAll(200)`, unsubscribe and roll
the pubkeys out of `inFlightBatchedMetadata`. On a loaded runner the launch
itself can be queued past the margin, so the roll-back lands late, call 2
short-circuits by design, and the assertion fails on healthy code. Every test in
the file had the same shape.
"Has call 1 finished?" is a question about the job tree, not the clock, and the
test owns the scope — so it can just ask. `awaitCoordinatorIdle()` waits until
no child of the test scope is active; `waitUntil {}` polls the few preconditions
that aren't expressible that way (listener registered before EOSEs are fired).
Both carry a generous deadline and fail with a message rather than hanging. Safe
here because the coordinator launches nothing at construction and
`BatchEoseGate` closes and joins its consumer inside `awaitAll`, so the scope
does reach idle.
Also made the test fake thread-safe. `subscriptions` and `subscribeCalls` were a
plain map and list, written from the coordinator's `Dispatchers.Default`
coroutines (and `Dispatchers.IO` in the concurrent-EOSE test) and read from the
test thread: an unsynchronized `size` read can be stale, and `fireEose`
iterating `subscriptions.values` while a coordinator coroutine calls
`unsubscribe` can throw ConcurrentModificationException. Concurrency is the
thing under test, so the fake shouldn't be the weak link. Now
ConcurrentHashMap + synchronizedList + AtomicInteger, with a snapshot in
`fireEose`.
Verified deterministic rather than just green:
- 3/3 idle, 4/4 under 24 busy loops on 12 cores (the old shape needed the
margin; the new one is load-independent).
- Still catches its regression: reintroducing the original mark-on-send bug
(`eosedRelays > 0` -> `>= 0`) fails exactly this test. A deflaked test that no
longer detects the fault would be worse than the flake.
This is a pre-existing flake (test dates from 5217035f94, 2026-07-07) unrelated
to the rest of this branch, which touches no commons code — fixed here because
it blocks the branch's CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from auditing the previous commit.
observeChannel registered a ChannelFinder query per invite row that could
never produce a filter: every assembler under ChannelFinderFilterAssemblyGroup
is gated on `is PublicChatChannel` / `is LiveActivitiesChannel`, so a
RelayGroupChannel contributes nothing and the registration only churned the
app-wide key set (an allKeys() Set copy per bundled invalidation) on every
mount and unmount. Collect the channel's metadata stateFlow directly instead —
same live name updates when the group's kind-39000 lands, none of the churn.
TimeAgo used the default Dotted style inside a row that already spaces its
children, so the variant's own leading " • " doubled the gap. DottedTight is
what the note header uses in exactly this position.
Key each row by channel id. The list is sorted newest-first, so an arriving
invite shifts every row below it; without a key Compose matches children by
call-site position and each shifted row recomposed against a different invite,
re-resolving the actor and reloading their avatar. Also remember the row
modifier rather than rebuilding the chain on every recomposition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
The INFO default landed a readable log but an incomplete one: only the boot
census came from our own code, so the default read as "warnings + census" with
nothing between process start and the 20s mark. The first four seconds — the
expensive part — were silent.
Adds the missing spine, ten INFO lines a boot:
- `AmethystApp`: version, process and effective log level at start. The
napplet-sandbox skip is promoted too — it is the one line that explains why
`instance` is unset and `LocalCache` empty in that process, which otherwise
reads as a broken app rather than a deliberately secret-free sandbox.
- `LocalPreferences`: how many accounts are saved (nearly every per-account
subsystem multiplies by this), then each account's load with its elapsed ms.
Decrypting one account's settings is among the most expensive things a cold
start does and "which account was slow" is the first question when a boot
drags. The six intermediate steps stay at DEBUG.
- `TorService`: every status transition, with time since bootstrap started. All
status writes now go through one `setStatus` helper instead of nine scattered
assignments, so a transition is logged exactly once; self-transitions are
skipped because the reset paths reassign `Off` defensively. A stuck
`Connecting`, an `Active` that took 40s and a silent drop to `Off` are three
different bugs that used to look identical.
- `BootRelayDiagnostics`: adds a census at 5s. The pool is assembled and
dialling well before 20s, and the early datapoint is what separates "slow to
connect" from "connected fine, slow to serve" — on device it reads pool=21
at 5s against pool=359 at 20s. Paid for by folding the `=====` banners down
to DEBUG, so a census is 3 INFO lines instead of 5.
Reads on device as:
I AmethystApp: Amethyst 1.13.1-DEBUG starting in main process (log level INFO)
I TorService: Off -> Connecting
I LocalPreferences: Found 4 saved account(s)
I LocalPreferences: Loaded account npub1gcx… in 394ms
I LocalPreferences: Loaded account npub142g… in 760ms
I BootRelayDiag: census @5s pool=21 opened=17 served_events=14 …
I TorService: Connecting -> Active after 9910ms
I BootRelayDiag: census @20s pool=359 opened=158 served_events=78 …
Verified by tag on a cold start: +10 INFO lines from our code, no other change
to volume. (Raw totals moved more, but that run happened to autoplay video, so
15 extra INFO lines came from Android's media/codec stack — not from this.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit follow-ups on the previous commit's gating.
1. The `hasLoadedContent` gate over-suppressed. Cached channels in
LocalCache are proof the socket answered *once*, not that it answers
now — so a relay whose circuits died mid-session, leaving a full but
frozen screen, would never get the offer. Replace it (and the
one-shot grace timer) with a debounce on the connection itself: the
countdown restarts on every drop and clears the moment the socket
returns. That covers the cold-start case, the reconnect blip and the
mid-session death with one signal, and no longer depends on what
happens to be cached.
2. Don't offer what can't help. The action adds the relay to the
kind-10089 Trusted list, which only moves it to clearnet while
trusted relays are off Tor. Under the Small-Payloads and
Full-Privacy presets (trustedRelaysViaTor = true) it changed no
routing at all — the old `relay !in trustedRelays` condition merely
hid the banner afterwards, so the dead button looked like it worked.
Withhold the offer under those presets instead of silently flipping
a global privacy setting from a per-relay banner.
Tests cover both gates plus the preset routing they read.
The invite prompt was a floating Material card with a surfaceVariant fill,
sitting above the Notifications feed and Messages > New Requests as its own
visual language — nothing else on either surface looks like that.
Render it through NoteComposeLayout instead, the same layout every note and
notification card uses: the actor becomes the row's author (picture, name and
time in the usual header), "Added to <channel>" plus the host relay become the
row's content, and the three choices take the reactions slot. So the prompt now
reads like the reply and mention notifications it sits next to, and a divider
closes each row the way the feed does.
The channel name is now read through observeChannel, the metadata-only observer
the channel rows use, so a stranger's add resolves to a name instead of a raw
group id. It does not open the channel's message subscription — holding that
back until the viewer answers is the whole point of the prompt.
Also relabel "Show" to "Add to Messages". acceptChannelInvite is defined as
`= addRelayGroupToMessages(channel)`, literally the call behind the channel top
bar's "Add to Messages" item, so one action had two words for it. Reusing the
existing string drops channel_invite_accept and channel_invite_body (the actor
and the question both live in the row now).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGB1z4VcNfDw5cMt9V5NCG
A Buzz/NIP-29 group id is only unique within its host relay, so a message
notification titled just "#general" doesn't say which #general it came from.
The Notifications card already draws a RelayGroupChannelHeader naming the
channel; add the relay next to it, mirroring what a Concord message gets on
the same cards (ConcordCommunityPill naming its parent community) and what the
Messages row already does for these groups.
Extracts the relay chip out of ChatroomHeaderCompose into a shared
RelayNameChip so the Messages row and the Notifications feed render the same
chip, the way ConcordCommunityPill is already shared between them. The chip
taps through to the relay's channel list; the name/avatar still open the room.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DsoLSb42C3CdjMceJW2Pq
Closes#3817.
collectWarmTargets() harvested link URLs from note content but not from
NIP-73 `I`-tag scopes, so the external-content preview cards popped in
mid-scroll while every other link preview was warmed ahead of view.
Gated on `is CommentEvent` so only kind 1111 walks the tags, keeping the
other kinds off that path. Reuses scope() -- the same accessor the
renderer uses -- so a URL is only warmed when it will actually be shown.
Inherits the existing showUrlPreview() data-saver gate already applied to
targets.links.
An entity edited after a CORD-06 Refounding was frozen at its pre-Refounding
version and its newer state silently discarded on every refold. Observed on
device: entity e83ee182 of a real community held at v1 across 21 consecutive
refolds while the current epoch offered v2 citing v1's own hash.
`EditionFold.foldEntity` anchored the walk by requiring the offered set to
*contain* the floor edition. But a Refounding re-wraps ONE edition per entity
(CORD-06 §3, "the last Control Plane state is simply rewrapped"), so an entity
edited afterwards has only its successor on the new epoch while the floor
edition stays behind on the old one. `prev == floor.hash` is a stronger proof
the chain connects than mere presence, and it was being ignored. Worse, the
gap made `admissible` — the pre-filter every derived fold shares — keep only
`version < floor.version`, so the newer edition was dropped every refold: a
role edit, channel rename or banlist entry reverting itself for that user.
Checked against Armada (semantics only, AGPLv3) and the canonical spec before
changing consensus behaviour. Armada selects an arm per entity, and Amethyst
was missing both halves:
1. Its chain-walk arm has three anchor branches to our one; the missing
`versions[0] === floor + 1n -> bytesEq(lo.prevHash, floorHash)` is now
implemented.
2. Once an entity has been re-wrapped into the epoch being folded it does not
chain-walk at all — it anchors on version alone (`bootstrapHead`), because
behind a compaction dangling `prev`s are normal and, since seal signatures
survive re-wrap, any group-key holder can re-serve a genuine old edition
under the current group. A re-wrap cannot raise the version inside the
signed seal, so version is what bounds that. This is the half that fixes
the observed pin; branch 1 alone would still refuse a floor-v1 entity whose
only served edition is v3.
Implemented as an optional `snapshot` (the rumor ids of the epoch being
folded), defaulting to null = pure chain walk for every other caller.
`ConcordCommunityState.fold`/`authorizedHeads` capture it from their own
editions argument before `admissible` re-seats older-epoch heads — Amethyst
folds exactly one epoch per call, so the argument is the snapshot. `admissible`
and `candidates` now ask `foldEntity` whether it gapped rather than
re-deriving the test, so the three sites cannot drift apart again.
`LOG_GAP` also dedups on (entity, floor, offered): the same refusal was
re-reported on every refold, 22 byte-identical warnings a boot, which read as
22 attacks rather than one unchanged state. Deduping is what made the two
genuinely distinct refusals visible and led here. Fails open above 4096
distinct refusals — a flood is when the warnings matter most.
Anti-rollback is unchanged: all 12 pre-existing floor tests still pass, and
three new negative tests cover the fork, the below-floor re-serve, and an
entity absent from the snapshot. Device gaps for the pinned entity: 21 -> 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cold start emitted 6,198 app lines in 80s (~77/sec) on a real device.
Six tags produced 83% of it, and the lines that are actually actionable —
relay protocol refusals like `auth-required`, `rate-limited`,
`unsupported: too many filters` — were buried among them.
`Log.minLevel` was `DEBUG` in debug and `ERROR` in release: two settings,
both wrong. Release dropped all 460 `Log.w` call sites, so the field never
saw a single relay refusal. Debug kept everything.
Now debug defaults to INFO and release to WARN, with `Amethyst.VERBOSE_LOGS`
to restore the firehose. 333 files already route through `quartz.utils.Log`
(only 13 use raw `android.util.Log`), so `minLevel` is a real global gate —
which is why three of the four loudest buckets needed no code change at all:
relay socket lifecycle (1,659 lines), `RelaySpeedLogger` (1,308) and Arti's
per-stream SOCKS errors (753) were already `Log.d`.
The rest:
- `RelayLogger.onCannotConnect` E -> D. Under the outbox model a boot dials
~400 relays and most fail identically; one line per dead socket is not
actionable, and its multi-line TLS certificate dumps were 3-4 logcat lines
each. This one mattered most: at `Log.e` it survived every level.
- `BootRelayDiagnostics` rollup -> INFO, per-relay WASTE/SERVE tables -> DEBUG.
The census line carries the aggregate the 655 per-socket errors spelled out.
- `Duplicated/SPAM` -> DEBUG: a detection is the filter working, and it is
already reported via `relayStats.newSpam()` and `flowSpam` to the UI.
- `MarmotDbg` "not a member of group" -> DEBUG: relays serve kind:445 for
every group they carry, so this is the steady state. It was burying the
real warning next to it ("Generation N already consumed").
Measured on emulator (4 accounts, Tor on): 6,198 -> 433 lines. Flipping
VERBOSE_LOGS gives 5,574 back, so nothing was deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Can't reach this relay over Tor" card on a community's screen was
driven by a bare 6s timer: `connectTimedOut` flipped true after the delay
and never flipped back, and the condition never consulted the relay at
all. On a Buzz relay the banner is an unconditional item in the sectioned
list (the empty-list guard only covers the vanilla NIP-29 branch), so it
appeared six seconds after opening any community and stayed up while the
relay was connected and delivering messages.
The Tor half was wrong too: `torType != OFF` says Tor is enabled somewhere,
not that this relay is routed through it — onion, localhost and the
per-role presets (trusted / DM / new) each decide independently.
Gate the offer on live signals instead, extracted into a pure predicate:
- routed over Tor — TorRelayEvaluation.useTor, the same predicate the
relay pool dials with
- Tor is up — TorManager.status is Active, so a bootstrapping Tor
isn't misreported as the relay blocking exits
- not connected — client.connectedRelaysFlow(); a relay that answers is
reachable by definition
- nothing loaded — no channels/DMs on screen, so a reconnect blip can't
flash the banner over a live list
- grace elapsed — unchanged, so a slow first connect isn't nagged
Adds unit tests for each gate, including the reported regression
(connected and delivering -> banner stays hidden).
Flipping either local-cache setting called probe.invalidate(), which only
clears the TTL timestamp — it never re-runs the probe. Nothing else does
either: isAvailable() is reached only from the one-shot startup warm and
from inside findServers().
That leaves the feature dormant when enabled mid-session. The feed image
path gates on `available.value` (AccountViewModel.useLocalBlossomBridge
and shouldBridgeBlossomCache), so while the probe reads false no request
ever routes through the resolver, and the resolver is the only thing that
would have refreshed the probe. The settings "detected" chip reads the
same flow, so it also stays stale.
Re-probe right after invalidating so enabling the toggle activates the
feature — and reports its true state — in the same session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
BlossomServerResolver.findServers and LocalBlossomCacheProbe.probe are
suspend functions that don't confine to Dispatchers.IO. They're reached
from Compose produceState/LaunchedEffect (RichTextViewer,
MarmotGroupIconDisplay, ZoomableContentView), which run on the main
dispatcher, so the pre-suspension work — BlossomUri regex parsing,
LruCache lookups, the probe's OkHttpClient build on TTL expiry, and the
server-list flow setup — executed on the UI thread on every uncached
resolution during feed scroll.
Wrap findServersInner and the probe's client-build + HEAD in
withContext(Dispatchers.IO) so that work stays off the main thread.
Behaviour is unchanged; callers already on IO (Coil BlossomFetcher,
PlaybackService) are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
Review feedback from PR #3810:
- Gate both new preview call sites behind settings.showUrlPreview(), so a
user on WIFI_ONLY over cellular or NEVER no longer triggers an outbound
OpenGraph request and image download per external-scoped comment. Falls
back to the plain link chip / URL header.
- Wrap the open-in-browser icon in an IconButton so it gets a real touch
target instead of a 14dp one nested inside the card's own clickable,
where a near-miss silently navigated instead.
- Only show that icon when onCardClick is set; without it the icon and the
card tap did the same thing, so existing note-body link cards stay as-is.
- staticCompositionLocalOf for LocalCurrentExternalScope (constant per
screen, avoids read tracking per feed row).
- Inset the loading/error URL header to match the card, and use the
hoisted Size14Modifier.
The leftover inner "a" ring sat in front of the ostrich, making the logo
look like it was behind the circle. Drop the inner-a segments from the @
outline, leaving just the outer ring + tail, and size the ostrich so it
reads clearly as the "a".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXfHYcViokBQRtqCW9vLQi