The profile_thumbnails_v2 dir was only created in ThumbnailDiskCache's
constructor, but Android can delete cache subdirectories while the app
runs (system cache trim under storage pressure, or the user tapping
Clear cache in Settings). After that, every generateFromFile call
failed with ENOENT on the temp-file write until process restart,
silently disabling thumbnail caching.
Recreate the dir right before the write, and add instrumented
regression tests covering the cleared-at-runtime path.
https://claude.ai/code/session_01RQinCw5QKpaYXqtyb4gf3h
- Rename Nip05Test backticked test name to drop parentheses, which are
illegal identifier characters on Kotlin/Native (iosSimulatorArm64).
- Resolve CLINK budget toast strings at composition time via stringRes
instead of context.getString inside the async callback, fixing the
LocalContextGetResourceValueCall lint errors in WalletScreen.
https://claude.ai/code/session_01UgP8ErzBbQYkTDtkJx5nrt
- Clamp the seeded kind:445 subscription since at wall-clock now: the
inner createdAt is sender-controlled, so a single future-dated message
could push since past the present and silently skip genuinely new
events on every restart. Covered by a new regression test.
- Drop the remember() around the group-list unread count: the chatroom's
message set can shrink without newestMessage or lastReadTime changing
(pruning, kind:5 deletion of an older message), which left the cached
count stale. The set is pruned to ~100 entries, so counting per
recomposition is cheap.
- Extract marmotGroupLastReadRoute(): the "MarmotGroup/<id>" last-read
key was inlined at three call sites; a prefix drift between the
mark-as-read side and the unread checks would silently reintroduce
the bug this branch fixes.
- Derive GROUP_EVENT_REFETCH_OVERLAP_SEC from TimeUtils.ONE_DAY instead
of re-deriving 24*60*60.
Two bugs kept CLINK offer/debit round-trips from completing over the shared
account relay client:
- The offer relay was treated as a generic "new" relay, so with Tor on it
was dialed through the proxy and failed on services that block Tor exits.
Register the offer/debit relays as money-operation relays for the duration
of the round-trip; the subscribe()-triggered reconnect plus the
BasicRelayClient wrong-transport rebuild then move the socket to clearnet.
- The subscription id "clink-offer-<event id>" was 76 chars; relays cap REQ
subscription ids at 64 (NIP-01) and reject the over-long REQ outright, so
the reply never arrived. Use newSubId(); the reply is matched by request
id in the listener, not by subscription id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relay-socket Tor routing only had localhost/onion/DM/trusted/new buckets,
so a wallet or payment-service relay fell through to newRelaysViaTor and
got forced over Tor regardless of the "Money operations via Tor" toggle
(which previously governed only HTTP clients). On services that block Tor
exits this silently broke NIP-47 and CLINK payments.
Add a moneyOperationsViaTor field to TorRelaySettings and a moneyOpRelay
bucket to TorRelayEvaluation (taking precedence over DM/trusted/new, after
the onion reachability check). TorRelayState gains a persistent money-op
relay set — fed across all accounts from NIP-47 wallet relays and saved
CLINK debit relays via AccountsTorStateConnector — plus a reference-counted
ad-hoc registry for one-off payment relays (e.g. an noffer pointer). The
websocket builder resolves the per-relay decision from live source values
so ad-hoc registration takes effect on the next connect with no race.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
connectAndSyncFiltersIfDisconnected() bailed whenever a socket already
existed, so a still-connecting socket built for the wrong transport (e.g.
a relay whose Tor classification changed since the dial started) could
never be preempted — it blocked until the hung dial timed out. The
connected-relay path in RelayPool.reconnectIfNeedsTo already rebuilds
ready sockets via needsToReconnect(); this covers the connecting state it
cannot see (isConnectionStarted() true but isConnected() false).
Now: if a socket exists but reports needsReconnect() (transport/proxy
mismatch against the current builder decision), drop it and redial on the
correct transport; otherwise leave it. Disconnected relays still honor
their reconnect backoff.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness fixes in GlobalMediaPlayer.kt
- snapshotFlow { hasMedia } collector for initial seek used `return@collect`
which only exits the lambda; the collector kept running and each
subsequent playVideo() call accumulated a live collector that would
re-fire a stale seekTo() on the wrong media. Replaced with `Flow.first`
which terminates the collection cleanly.
- playVideo()/playAudio() reset the public MediaPlaybackState to
volume=100/isMuted=false on a new URL, but the kdroidFilter player
retains its `volume` across openUri(); muting one track and starting a
new one left the engine silent while the UI showed unmuted. Reset
`player.volume = 1f` to match the public state.
- ensureVideoPlayer()/ensureAudioPlayer() called createVideoPlayerState()
synchronously from the Compose getter; if native init throws (missing
GStreamer on Linux, broken NativeLibraryLoader extraction) the whole
window would crash. Wrapped in runCatching and changed
activeVideoPlayerState to nullable. Consumers in DesktopVideoPlayer
and GlobalFullscreenOverlay handle the null path by rendering the
thumbnail / blank backdrop respectively; playVideo()/playAudio()
surface "Video playback unavailable" through the existing
errorReason -> PlaybackErrorMessage path.
Crash mitigation (kdroidFilter 0.10.0 UAF in MacVideoPlayerSurface)
- NowPlayingBar previously mounted a SECOND VideoPlayerSurface against
the same VideoPlayerState while the feed card was already mounting
one, doubling the draw rate against the shared frame bitmap and
widening the UAF window in MacVideoPlayerSurface's RasterFromBitmap
path. Mini-preview now renders the cached thumbnail (or the music
icon fallback). 0.10.1 contains an upstream fix
("recover video playback after composition removal") but is not yet
on Maven Central — single-surface mounting is the only mitigation
we can ship today.
VideoThumbnailCache.kt
- Truncated-download cache poisoning: when an origin ignored the
Range: header and returned HTTP 200 with the full body, we capped
the copy at MAX_THUMB_BYTES and persisted the truncated file
forever. Subsequent thumbnail attempts hit the broken cache file
and re-failed JCodec/ffmpeg every time. Tag download results with
whether the server actually returned 206; on 200, extract from the
temp file and delete it (no persistent cache hit).
- Tor bypass: replaced the bare OkHttpClient with
DesktopHttpClient.currentClient() so thumbnail fetches respect the
user's Tor preference (fail-closed when Tor is expected but
bootstrapping).
- ffmpeg version probe leaked the process on hang: now drains stdout
to DISCARD and calls destroyForcibly() on timeout.
- Frame-extract ffmpeg subprocess could deadlock on a chatty stderr
pipe: redirectError(DISCARD) so we never wait on stderr; a finally
block destroys the process if anything leaked through the timeout.
CI workflow cleanup
- Removed vlc-setup download cache + pre-fetch steps from
build.yml and smoke-test-desktop.yml. They were targeting an
ir.mahozad.vlc-setup plugin we no longer apply, so they wasted
~minutes of CI time per leg and tied the build to videolan.org
reachability for no reason.
- Trimmed create-release.yml's stale VLC-plugins justification on
the linuxdeploy-vs-appimagetool comment.
.gitignore + missing per-OS ffmpeg READMEs
- The pre-PR rules blanket-ignored desktopApp/src/jvmMain/appResources/{linux,macos,windows}/
so the LGPL FFmpeg drop-in slot READMEs created in 704f4f44e never
reached the commit. Refined the ignore rules to keep stale vlc/ workspace
trees out of git (still ignored) while explicitly tracking the
ffmpeg/README.md drop-in slot under each OS. The READMEs document the
recommended LGPL build source per OS for the bundled-FFmpeg packaging
path.
Verified on macOS arm64:
./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL
./gradlew :desktopApp:test BUILD SUCCESSFUL
./gradlew :desktopApp:spotlessApply clean
Refs PR #3175 review by @davotoula.
The test lived in commons androidHostTest, but no CI workflow or
pre-push task runs :commons:testAndroidHostTest — and running it
manually fails before reaching any assertion: quartz's android
PlatformLog actual hits unmocked android.util.Log stubs
(NoSuchMethodError), since the source set is not configured with
returnDefaultValues. The end-to-end leave/rejoin coverage was
therefore never executed anywhere.
:commons:jvmTest runs in CI and in the pre-push hook, already has the
secp256k1 JVM bindings the test needs, and uses quartz's JVM logger.
Verified green there alongside MarmotManagerRestoreTest.
Second audit pass over the remaining skills (amy-expert, auth-signers,
find-*, nostr-expert, quartz-integration, vendored technique skills),
verifying every concrete claim against the code:
- auth-signers: bunker login goes through NostrSignerRemote.fromBunkerUri
+ connect(), not the nonexistent RemoteSignerManager.connect(url)
- nostr-expert: NIP count 57 -> 80+; replace invented Nip44v2/Nip19
static APIs with the real Nip44 facade, ByteArray bech32 extensions,
entity create() helpers, and Nip19Parser.uriToRoute()?.entity
- nip-catalog: heading counts corrected to 87 standard + 23 experimental
packages with a ground-truth pointer
- quartz-integration: NIP-19 example rewritten for ParseReturn.entity;
Event Store is commonMain (all platforms), not Android-only, with the
real store.sqlite.EventStore import and suspend query<T> API
amy-expert, find-missing-translations, find-non-lambda-logs, the rest of
auth-signers, and the vendored technique skills audited clean.
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
The Marmot subscription since, the processed-event dedup set, and the
application ratchet position (group state persists only at commits) are
all in-memory only. On restart, relays therefore redeliver the group's
entire kind:445 history and the rewound ratchet re-decrypts old
application messages as if they had just arrived — wasted decryption
work and, when a replay beats the disk restore, duplicate entries
appended to the persisted plaintext message log.
Two defenses:
- MarmotManager.restoreAll() now seeds each restored group's
subscription since from the newest persisted decrypted message, minus
a one-day overlap window for late/out-of-order publishes. Seeding
happens before syncWithGroupManager registers default entries, so
even the first filter set sent to relays carries it. The CLI is
unaffected: it builds group filters from its own persisted since.
- MarmotMessageStore appends are now explicitly idempotent (contract
was previously ambiguous and both real stores appended blindly):
the Android and CLI file stores skip an entry that is already in the
group's log, so replays inside the overlap window cannot grow it.
Covered by MarmotManagerRestoreTest in commons jvmTest — placed there
rather than androidHostTest because CI only runs :commons:jvmTest (the
androidHostTest task currently fails on android.util.Log stubs even
for the pre-existing Marmot test).
The unread dot for Marmot/MLS group rooms was driven by an in-memory
unreadCount on MarmotGroupChatroom. On restart the MLS group state is
restored from the last persisted commit, the kind:445 subscription
restarts with since=null, and the in-memory processed-event dedup set is
empty — so relays redeliver old group events, they re-decrypt as fresh
application messages, and the counter was re-bumped, resurrecting the
dot for chats already read.
Marking-as-read was already persisted: opening a group chat writes the
newest rendered message's createdAt to the MarmotGroup/<groupId> route
in lastReadPerRoute (saved to disk with account settings). Compute the
unread indicators from that timestamp instead — exactly how DM rooms
and public channels do it — in both the Messages screen row and the
Marmot group list row.
With no consumer left, drop the volatile counter and collapse the
addMessageSync/restoreMessageSync split (they only differed in the
counter bump).
Audit pass that verified every concrete claim in .claude/ against the
repository:
- account-state: Account.kt no longer exposes followListFlow-style
StateFlows; document the state-object pattern (kind3FollowList,
muteList, bookmarkState, ... each exposing .flow) and rewrite the
catalog reference from the real Account.kt
- feed-patterns: filter bases (FeedFilter, AdditiveFeedFilter,
ChangesFlowFilter, FeedContentState) moved to commons/ui/feeds;
ui/dal keeps AdditiveComplexFeedFilter/FilterByListParams plus
back-compat typealiases; fix recipe example signatures
- relay-client: add nip17Dm/, eoseManagers and subscriptions entries
to the layout tree
- gradle-expert: 4-module claim -> 10 modules; refresh compose/kotlin/
BOM versions; rewrite dependency graph with verified edges for cli,
geode, quic, nestsClient, quic-interop, benchmark
- desktop-expert: drop drifted Main.kt line numbers; sidebar is the
custom MainSidebar in DeckSidebar.kt, not a NavigationRail in
SinglePaneLayout.kt
- android-expert: compileSdk/targetSdk 36 -> 37, versionName via
generateVersionName()
- kotlin-expert: remove reference to nonexistent commit 258c4e011
- CLAUDE.md: add missing geode/benchmark/quic-interop modules
- desktop-run: packageRpm + correct binaries output path; extract.md:
drop duplicated find clause
- session-start.sh: /home/user/Amber fallback was a copy-paste from
another repo; fall back to CLAUDE_PROJECT_DIR
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
StrictMode flagged the offer round-trip (ephemeral keygen, JSON serialization,
NIP-44 encrypt/decrypt, signing) running on the UI thread, because
ClinkOfferPreview launches it from a Compose (Main) scope. Wrap the heavy work
in withContext(Dispatchers.IO) in both ClinkOfferPayer.requestInvoice and
ClinkDebitPayer.payInvoice/requestBudget so the payers are main-safe regardless
of caller dispatcher.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Adds a ContentCopy IconButton at the right of the CLINK Offer card title that
copies the noffer string (the active pointer, after any moved-offer redirect) to
the clipboard with a confirmation toast.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Mirrors the app's NIP-05 .well-known clink_offer discovery fallback (kind-0
offers are already readable via 'amy profile show'). Reuses the Context's
nip05Client.loadClinkOffer and decodes the resolved noffer into its fields.
Adds a bad-nip05 validation case to the headless harness; 17/17 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
amy zap printed the invoice but never paid it. With --with <ndebit> it now
settles the fetched BOLT-11 in-place through a CLINK debit pointer (kind-21002,
reusing DebitCommands.settle), mirroring how the app routes a zap through its
default payment source. Works for both single-recipient (zap user) and
split zaps (zap event) — each recipient reports paid + preimage (or pay_error).
Adds a --with validation case to the headless harness; 16/16 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Brings amy's CLINK surface closer to the app's:
- profile edit --clink-offer <noffer|"">: set/clear the kind-0 clink_offer
(validated as a real noffer; "" clears). MetadataEvent already carried the field.
- offer request --follow: chase an 'Expired or Moved' (code 3) reply to its
'latest' pointer (bounded hops), mirroring the app; the error output now also
carries code/latest/range so a script can follow or correct manually.
- offer pay <noffer> --with <ndebit> [--amount]: end-to-end — fetch the invoice
(21001) and settle it through a debit pointer (21002), reusing DebitCommands.settle.
- Structured GFY detail (code, range, retry_after, delta) in debit/offer errors,
via a new Output.error(extra=) overload.
Adds local-validation cases to the headless harness (offer pay --with, profile
edit --clink-offer); 15/15 pass.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
The profile CLINK offer showed the full ClinkOfferPreview payment card up front.
Render it instead as a compact payment-target-style chip (Bolt icon + 'Lightning
Offer' label, matching the PaymentTargetChip look); tapping it expands the
payable card, collapsed by default — same expand-on-click idiom as the lightning
address row.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
- CLAUDE.md: drop the 5-step skill-approval workflow (skills auto-trigger
and the approval loop blocked autonomous sessions), condense Verify-Don't-
Guess to the repo-specific tooling pointers, remove references to the
uncommitted /bugfix and /investigate skills, and replace the mandated
emoji survey matrix with one-line guidance
- android-expert / desktop-expert: add missing YAML frontmatter so the
skills carry trigger descriptions and can actually auto-invoke
- extract.md: fix stale shared-ui/ module name -> commons/
- delete skills/quartz-kmp.md breadcrumb (migration long complete)
- gate the Stop spotlessApply hook on modified Kotlin files via
hooks/stop-spotless.sh so Q&A-only turns skip the Gradle run
- condense core-skills-plan.md to a historical changelog
https://claude.ai/code/session_01EC7LdXjatFTh1CJSP4qKRn
progressAllPayments was a non-atomic Float var incremented from the concurrent
mapNotNullAsync bodies AND the async response callbacks (NWC onResponse / the
CLINK launched coroutine), so parallel zap splits raced and could leave the
progress bar below 100%. Replace it with a shared PaymentProgress(AtomicInteger
over 2*N half-steps) used by both payViaNWC and payViaClinkDebit, which also
removes the duplicated half-step arithmetic.
Note: NWC's response half-step still won't fire if a wallet never replies within
its 60s window (sendZapPaymentRequestFor doesn't signal onResponse on timeout);
that progress-stall is pre-existing and separate from this race fix.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
From the audit of this session's changes:
- Error surfacing: the budget (WalletScreen) and offer/invoice card
(InvoicePaymentDispatcher) paths now use DebitResponse.failureDetail() like the
zap path, so a GFY code-5/code-4 surfaces its range/retry_after instead of just
the bare error string.
- NOffer.priceType is now non-null: decode already defaults an absent TLV 3 to
SPONTANEOUS, so the nullable type was misleading and the '?: SPONTANEOUS'
fallbacks in ClinkOfferPreview were dead. Drops them and the now-redundant
always-emit-TLV3 test (covered by the spontaneous round-trip).
- WalletViewModel.requestDebitBudget catches the budget-validation
IllegalArgumentException so a malformed frequency dismisses the dialog instead
of hanging the spinner.
- Document why ClinkDebitPayer signs with the persistent account key (stable
identity for budgets) while ClinkOfferPayer uses an ephemeral key.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Sweeps every typed kind: addressable kinds (30000..39999) must read
their d tag, plain replaceables (10000..19999, 0, 3) must ignore stray
ones — the invariant the kind-34235/34236 fix restores.
Advance half the per-payable progress on dispatch and the other half when the
async debit response arrives, exactly like payViaNWC, instead of jumping the
full share on dispatch.
https://claude.ai/code/session_01NM2TyJtosLdY5ycjyabSRS
Extracts the priority sort into rankPriorityFirst() and covers: priority
users move to the top, stable order within both groups, no injection of
non-matching priority keys, and untouched list when priority is empty.