P1 fixes (must-fix):
- AppRun: VLC path corrected to usr/lib/app/linux/vlc (jpackage actual
path), add VLC_PLUGIN_PATH env var for codec discovery
- linuxdeploy: use APPIMAGE_EXTRACT_AND_RUN=1 env var to bypass FUSE
on CI runners instead of fragile pre-extract + symlink approach
- Prerelease classifier: inverted to positive-match stable format
(^v[0-9]+\.[0-9]+\.[0-9]+$) across desktop, Android, and composite
action — eliminates regex drift between allowlists
- assert-stable-release: now accepts inputs (tag, is_prerelease,
is_draft) so workflow_dispatch on bump workflows also validates
P2 fixes (should-fix):
- .gitignore: add linuxdeploy-*.AppImage and extracted dirs
- RPM version: use replace("-", "~") instead of substringBefore("-")
for correct RPM prerelease ordering (1.08.0~rc1 < 1.08.0)
- linux-portable → linux alias moved into collect_assets() in
scripts/asset-name.sh (single source of truth)
- Android cache key: add gradle/libs.versions.toml to hashFiles
- r0adkll/sign-android-release: SHA-pinned to 349ebdef (v1)
P3 fixes (nice-to-have):
- LINUXDEPLOY_OUTPUT_VERSION env var replaced with
APPIMAGE_EXTRACT_AND_RUN (misleading comment + redundant var)
- nick-fields/retry timeout: 40m → 15m (surface real hangs)
- generate_release_notes: true only on Android job (avoid
last-writer-wins race across 6 concurrent uploaders)
- apt-get install rpm: tightened guard to matrix.family == 'linux'
(linux-portable leg doesn't need rpm tooling)
- 16:9 cover image with rounded top corners
- Topic/hashtag chips row above title (up to 3)
- Bolder titleLarge headline, max 2 lines
- Gray summary with max 3 lines
- Author row with display name, time ago, and reading-time badge
WebRTC's Camera2Session accesses WindowManager for device orientation.
WindowManager is a visual service that requires an Activity context —
applicationContext throws IllegalAccessException on Android 12+.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Root cause of "ringing survives reject on old Android":
1. startRinging() was NOT idempotent. When CallManager re-emitted
IncomingCall state (e.g., when another group member rejected while
still ringing), the state collector called startRinging() again.
startRingtone() OVERWROTE the ringtone field reference without
calling stop() on the old one. Old Ringtone instances kept
playing with no reachable reference — stopRingtone() on reject
only stopped the latest one.
2. Ringtone.isLooping was only set on API 28+. On older Android the
Ringtone class had documented reliability problems with stop().
MediaPlayer has reliable stop/release on all API levels and
supports looping universally.
Fixes:
- Switch from android.media.Ringtone to android.media.MediaPlayer.
- Make startRinging() idempotent — always call stopRinging() first
so any existing player/vibrator is torn down before a new one
starts.
- Add aggressive tracing logs throughout the reject path:
* CallAudioManager: instance id + thread on every start/stop,
MediaPlayer error listener, before-and-after player hashes.
* CallSession: log every state collector tick, log close() entry.
* CallManager: log rejectCall() entry/exit + transitionToEnded.
* CallNotificationReceiver: log every action with state transitions.
With these logs, if ringing still survives reject we can trace
exactly which primitive is failing.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Three fixes from audit:
1. PiP swipe-away regression (Bug 5): Restored hangup signaling in
CallActivity.onDestroy via detached CoroutineScope. This is the
PRIMARY signaling path for PiP dismiss, back press, and finish().
CallForegroundService.onTaskRemoved is the BACKUP for task swipe
from Recents. Both paths are idempotent — double-publishing is safe.
2. SharedFlow event drops (Bug 1): Increased sessionEvents buffer from
16 to 256 to handle worst-case ICE candidate bursts. Added
emitSessionEvent() helper that logs drops as errors. Increased
renegotiationEvents buffer from 8 to 32. tryEmit is kept (not
emit) to avoid suspending while holding stateMutex, which would
block all signaling.
3. runBlocking deadlock risk (Bug 2): CallSessionBridge.clear() now
uses CallManager.reset() (non-blocking, no mutex) instead of
runBlocking { hangup() }. The bridge teardown is for local state
cleanup during account switch — hangup signaling is the Activity's
and Service's responsibility.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
The previous approach used a detached CoroutineScope in
CallActivity.onDestroy to publish hangup/reject events. This was
unreliable: the process could be killed before the coroutine completed,
leaving the remote peer's phone ringing.
Fix: CallForegroundService now owns hangup signaling via two paths:
1. onTaskRemoved() — fires when user swipes app from Recents. Uses
runBlocking with 3-second timeout to synchronously publish hangup
before the service is killed.
2. onDestroy() — fires when the service is stopped for any other
reason. Same runBlocking pattern as a safety net.
Changes:
- AndroidManifest: stopWithTask="false" so onTaskRemoved fires instead
of the service being silently killed.
- CallForegroundService: added onTaskRemoved(), onDestroy(), and
publishHangupBlocking() helper.
- CallActivity.onDestroy: removed fire-and-forget CoroutineScope.
Session resource cleanup (close()) still happens here; signaling
hangup is delegated to the foreground service.
- CallSessionBridge.clear(): converted fire-and-forget hangup to
runBlocking with 3-second timeout for account-switch safety.
The three-layer hangup guarantee is now:
1. CallForegroundService.onTaskRemoved/onDestroy — synchronous,
reliable (runBlocking).
2. CallManager ringing watchdog (65s) — if nothing else fires.
3. Remote peer's own timeout — last resort.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Convert all 5 mutable `var` callback fields on CallManager
(onAnswerReceived, onIceCandidateReceived, onNewPeerInGroupCall,
onMidCallOfferReceived, onPeerLeft) into a single
`sessionEvents: SharedFlow<CallSessionEvent>`.
Benefits:
- Eliminates the stale-callback window between Activity destroy and
recreate. No more nulling callbacks in onDestroy — the `closed`
flag on CallSession is sufficient.
- No mutable state shared between Activity and background code.
- CallActivity no longer wires or tears down callbacks — CallSession
subscribes to the flow in its init block.
- Type-safe sealed interface (CallSessionEvent) replaces 5 separate
lambda types.
The renegotiationEvents SharedFlow remains separate because
renegotiation has its own glare-resolution logic in CallSession.
Updated all 11 test sites in CallManagerTest to collect from
sessionEvents instead of setting callback vars.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Three additional hardening fixes:
1. WebRTC callback guards: Every callback passed to WebRtcCallSession
(onIceCandidate, onPeerConnected, onRemoteVideoTrack, onDisconnected,
onError, onRenegotiationNeeded, onIceRestartOffer) now checks `closed`
before touching any session resource. Prevents native crashes from
libwebrtc callbacks firing after close() has disposed PeerConnections.
2. Connecting-state watchdog: New 30-second timer armed when entering
Connecting state, disarmed on Connected/Ended/reset. If ICE
negotiation hangs (broken TURN, restrictive NAT), the call ends
with TIMEOUT instead of leaving the user on a "Connecting..." screen
forever.
3. Account-switch safety: CallSessionBridge.clear() now calls
callManager.hangup() before nulling references. Prevents a stale
CallSession from invoking signing/publishing lambdas on a disposed
Account after logout or account switch.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Fixes found by audit:
1. Set `closed = true` at the top of close() so the init collectors
(state, renegotiation, proximity) short-circuit when they see
the flag. Prevents them from calling methods on disposed resources
during the window between close() and lifecycleScope cancellation.
2. Null out all CallManager callbacks in onDestroy after close() so
signaling events arriving between Activity destroy and recreate
don't route to a dead CallSession.
3. Synchronize peerSessionMgr.disposeAll() + reassignment in close()
to prevent concurrent collector reads on a half-disposed manager.
4. Reorder onDestroy: close() runs first (synchronous resource
release), then callback nulling, then best-effort hangup signaling.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Root cause: WebRTC call ringing continued forever after cancellation
because ringing, WebRTC, and notifications were owned by CallController
which lived in viewModelScope (account lifetime), not by CallActivity.
Cleanup depended on a state collector observing Ended in time, which
raced, dropped, and went stale.
Architecture after fix:
- CallManager (background, AccountViewModel scope): state machine only.
No audio, no WebRTC, no notifications. Exposes state as StateFlow,
renegotiation events as SharedFlow, and a 65-second ringing watchdog
as a fail-safe.
- CallSession (Activity-scoped, replaces CallController): owns all call
resources. Created in onCreate, destroyed via close() in onDestroy.
Implements AutoCloseable. No cleanedUp guard flag needed.
- CallSessionBridge: slimmed down — holds only callManager +
accountViewModel for cross-Activity reference sharing. No
callController field.
Key changes:
- CallController renamed to CallSession, moved to ui/call/session/.
- CallSession.close() replaces cleanup() — single-shot, no guard flags.
- CallActivity creates and owns CallSession directly.
- CallManager.onRenegotiationOfferReceived callback replaced with
renegotiationEvents SharedFlow (buffered, survives Activity restarts).
- CallManager gains ringing watchdog (65s) that forces Ended(TIMEOUT)
if state stays in IncomingCall/Offering past the deadline.
- CallNotifier extracted from NotificationUtils into dedicated class.
- AccountViewModel.initCallController deleted; callManager wired to
newNotesPreProcessor eagerly in init block.
- ChatroomScreen uses CallActivity.launchForOutgoingCall() with intent
extras instead of directly calling callController.initiateGroupCall().
- AndroidManifest adds navigation|fontScale|density to configChanges
so no config change recreates CallActivity mid-call.
Invariants:
1. CallActivity.onDestroy → session.close() → all resources released.
2. Ringing cannot outlive any code path: Activity close, state
collector, and 65s watchdog provide three independent stop paths.
3. Config changes cannot kill the call (manifest prevents recreation).
4. No global mutable singleton for CallController.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
Extracts the Marketplace/Products functionality from the Discovery tab
into a dedicated standalone screen following the Pictures/Polls pattern.
The screen displays NIP-99 classifieds in a 2-column grid with its own
filter system, defaulting to Around Me (geolocation-based) filtering.
https://claude.ai/code/session_01PyrUZzSj7A3ZXucs7DTYbB
Add a new dedicated Articles screen accessible from the left drawer that
displays long-form content (NIP-23, kind 30023) with its own filters,
data sources, and FAB. The screen follows the same pattern as Pictures,
Polls, and Longs screens. Default filter is set to "All Follows".
- New route: Route.Articles with navigation and drawer entry
- ArticlesFeedFilter using LongTextNoteEvent with own follow list
- ArticlesFilterAssembler/SubAssembler reusing makeLongFormFilter
- ArticlesScreen with DisappearingScaffold, top bar filter, and FAB
- ArticlesFeedLoaded rendering via ChannelCardCompose/LongFormHeader
- Account settings for defaultArticlesFollowList with persistence
https://claude.ai/code/session_01QnP527Zq3xrtcLDmdx3LNf
Adds full MIP-04 (Encrypted Media) support for Marmot group chats:
Protocol layer (quartz):
- Mip04MediaEncryption: ChaCha20-Poly1305 AEAD with MLS exporter key
derivation (HKDF-Expand), random nonces, and AAD binding
- Mip04Cipher/Mip04NostrCipher: NostrCipher adapters for the existing
EncryptedBlobInterceptor download-and-decrypt pipeline
- Mip04IMetaTag: NIP-92 imeta tag builder/parser with MIP-04 fields
(url, m, filename, x, n, v=mip04-v2)
- MlsGroupManager.mediaExporterSecret(): MLS-Exporter("marmot",
"encrypted-media", 32)
Upload flow (amethyst):
- MarmotFileUploader: per-file Mip04NostrCipher → existing
UploadOrchestrator.uploadEncrypted pipeline (compression, stripping,
Blossom upload)
- MarmotFileSender: builds imeta tags and sends kind:9 inner events
through the MLS group message pipeline
- MarmotGroupMessageComposer: adds SelectFromGallery leading icon and
ChatFileUploadDialog (mirrors NIP-17 DM pattern)
Display flow:
- RenderMarmotEncryptedMedia: parses imeta v=mip04-v2 tags, derives
Mip04Cipher, registers in EncryptionKeyCache, renders via
ZoomableContentView (same path as NIP-17 encrypted files)
- MarmotGroupList.noteToGroupIndex: reverse index for mapping notes
back to their group ID (needed for exporter secret lookup)
- ChatMessageCompose NoteRow: routes MIP-04 events to the new renderer
https://claude.ai/code/session_01TckzZLpJdmE6p198DHBQ5i
Replace the "MLS Groups" FAB button on MessagesScreen with a "Create
Group" action that navigates directly to the group creation screen
(Route.CreateMarmotGroup) instead of the group list page. This aligns
with the plan to merge all marmot groups into the Messages screen's
Known and New Requests tabs.
Add marmot group messages to the notification screen so they appear
alongside regular DMs. This includes:
- Including marmot group messages in NotificationFeedFilter.feed()
- Accepting marmot group notes in acceptableEvent() via inGatherers check
- Converting marmot group notes to MessageSetCard in CardFeedContentState
- Routing marmot group notification clicks to the group chat screen
https://claude.ai/code/session_012wbykgUYHNVdHVNbDDnMUt
Fills out Quartz's NIP-34 implementation so clients can build and parse
every spec-defined event kind, not just repository announcements and issues.
Kind 30617 (Repository Announcement):
- Multi-value web/clone accessors and builders
- relays, maintainers, hashtags, earliestUniqueCommit, isPersonalFork
- New tag classes: RelaysTag, MaintainersTag, EucTag
- New build() overload covering every optional tag
Kind 30618 (Repository State): new GitRepositoryStateEvent with
refs/heads, refs/tags, HEAD, and a RefTag/HeadTag pair.
Kind 1617 (Patch): rewrite builder to use the eventTemplate DSL with
required a/r/p tags, optional commit/parent-commit/commit-pgp-sig/
committer/root/root-revision markers, and a reply() helper that adds
NIP-10 marked e-tags pointing at a prior patch.
Kind 1618/1619 (Pull Request + Update): new GitPullRequestEvent and
GitPullRequestUpdateEvent with c/clone/branch-name/merge-base tags and
NIP-22 E/P parent references for updates.
Kinds 1630/1631/1632/1633 (Status Open/Applied/Closed/Draft): new
GitStatusEvent base + four subclasses sharing a GitStatusBuilders DSL.
Applied status carries merge-commit, applied-as-commits, and q tags for
the specific patch events that were merged.
Kind 10317 (User Grasp List): new UserGraspListEvent replaceable list of
g tags (websocket URLs in preference order) for discovering grasp
hosting servers.
NIP-51 Kind 10017 (Git Authors List): now uses a dedicated GitAuthorTag
class with optional petname (NIP-02 follow-list 4th element) instead of
the mute-list UserTag, so round-tripping follow lists no longer drops
petnames.
All new event kinds are registered in EventFactory. Spotless-clean and
compiles via :quartz:compileKotlinJvm.
https://claude.ai/code/session_018Fz3AeEdGkeFoeHBKuMwbH
The diagnostic trace from the invitee shows the welcome flow working
end-to-end (gift wrap → seal → processMarmotWelcomeFlow) but finally
failing at
MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle
for eventId=41177359… — inviter referenced a KeyPackage we don't
have private keys for
The eventId the welcome carries IS on relays — the inviter just
fetched it successfully in `fetchKeyPackageAndAddMember` — but on the
invitee's device the `eventIdToSlot` map is empty, so the lookup
misses. Root cause: the invitee's persisted KeyPackage snapshot is
in v1 format (from before the eventId→slot index existed).
`restoreFromStore` loaded v1 just fine, leaving bundles in place but
the eventId map empty. Then `Account.ensureMarmotKeyPackagePublished`
saw `hasActiveKeyPackages()` return true and *skipped republishing*
— so the stale kind:30443 that the inviter just fetched has no
matching mapping on the invitee.
Fix: `restoreFromStore` now refuses to load any snapshot whose
version is not the current `SNAPSHOT_VERSION` (2). On a v1 file it:
1. Logs a warning.
2. Deletes the on-disk snapshot via `store.delete()` so the next
save writes fresh v2.
3. Returns without touching `activeBundles` / `pendingRotations` /
`eventIdToSlot`.
This means `hasActiveKeyPackages()` will return false right after
restoreAll finishes, `ensureMarmotKeyPackagePublished` will generate
+ publish a fresh bundle (which `recordPublishedEventId` indexes by
its real Nostr event id), the new kind:30443 replaces the old one on
relays via d-tag addressability, and the next inviter's
`fetchKeyPackageAndAddMember` will find the new event + the invitee
will have the matching private keys to process its Welcome.
The same defensive wipe also fires for a v2 snapshot that somehow
carries bundles with an empty eventId map (corner case, crash during
upgrade, etc.).
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
User reported the only logs Charlie's device produced when Bob added
him to a group were:
GiftWrapEventHandler.processNewGiftWrap: id=40e2073c… recipient=9a960f0b…
GiftWrapEventHandler.processNewGiftWrap: unwrapped innerKind=13 innerId=ffda2949…
That kind=13 is the smoking gun. The previous code in
`processNewGiftWrap` checked `isWelcomeEvent(innerGift)` directly on
the gift-wrap-unwrapped result, expecting kind:444. But Marmot
`WelcomeGiftWrap.wrapForRecipient` actually produces a three-layer
envelope:
GiftWrap (1059) → SealedRumor (13) → WelcomeEvent (444)
So `event.unwrapOrNull(signer)` returns the kind:13 Seal, not the
WelcomeEvent. The previous check fell through to `eventProcessor.
consumeEvent`, which dispatched to `SealedRumorEventHandler`, which
unsealed correctly to a kind:444 — and then handed it back to
`eventProcessor.consumeEvent` again. There's no LocalCache event
handler registered for kind:444, so the WelcomeEvent was silently
dropped on every invitee.
Fix: extract the welcome-handling code from `GiftWrapEventHandler.
processMarmotWelcome` into a top-level `processMarmotWelcomeFlow`
helper, and call it from BOTH:
- `GiftWrapEventHandler.processNewGiftWrap` (kept for the unlikely
case where Marmot ever publishes a Welcome with no Seal layer)
- `SealedRumorEventHandler.processNewSealedRumor` (the actual
production path) — after unsealing, if the inner rumor is a
WelcomeEvent it now goes straight to the shared flow instead of
being passed to the unrouted event processor.
Also added matching `MarmotDbg` logs in `processNewSealedRumor` so
the seal-side trace is visible: id, unsealed inner kind/id, and the
"detected Marmot WelcomeEvent inside seal" routing line.
Compile-checked: `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
While adding diagnostic logs to trace why invitees were receiving
nothing on Marmot group adds, I found the actual root cause: a
fundamental hash mismatch between the value the Welcome event carries
and the value the receiver looks up by.
### The bug
A Marmot WelcomeEvent (kind:444) carries a tag `["e", <eventId>]`
referencing the kind:30443 KeyPackage event that was consumed — see
`KeyPackageEventTag` and `MarmotWelcomeSender.wrapWelcome`. That
`<eventId>` is the *Nostr event id* (a hash of the signed event JSON).
`MarmotInboundProcessor.processWelcome` then called
`keyPackageRotationManager.findBundleByRef(hexToBytes(eventId))`,
which compares each stored bundle's `keyPackage.reference()` —
**the MLS-spec KeyPackageRef, an entirely different hash computed by
`MlsCryptoProvider.refHash("MLS 1.0 KeyPackage Reference", encoded)`
over the TLS-encoded KeyPackage**.
Those two values are never equal, so the lookup ALWAYS missed and
every invitee path returned `WelcomeResult.Error("No matching
KeyPackageBundle found")`. Combined with the previous in-memory-only
storage, this is why nothing ever appeared on the invitee's screen.
### The fix
`KeyPackageRotationManager` now also indexes bundles by the Nostr
event id of the corresponding kind:30443 event:
- `eventIdToSlot: Map<HexKey, String>` — populated by
`recordPublishedEventId(slot, eventId)`, called from
`MarmotManager.generateKeyPackageEvent` immediately after signing
the event template (which is when the event id is first known).
- `findBundleByEventId(eventId)` — looks up the slot via the new
index, then returns the bundle.
- `markConsumedByEventId(eventId)` — symmetric consume-by-event-id
for the welcome receive path.
- The persisted snapshot format is bumped from v1 → v2 to include
the eventId map. v1 snapshots are still readable (loaded as if
the eventId map were empty); republishing a KeyPackage will refill
it. Also cleans the index when slots are consumed.
`MarmotInboundProcessor.processWelcome` now uses
`findBundleByEventId(keyPackageEventId)` instead of
`findBundleByRef(hexToBytes(...))`, and `markConsumedByEventId` for
the consume call. The dead `hexToBytes` helper + import are removed.
### Diagnostic logging
Added `MarmotDbg`-tagged logs across the entire add-member / send /
receive chain so the user can `adb logcat -s MarmotDbg` to see
exactly what's being sent and what's being received:
- `Account.fetchKeyPackageAndAddMember` — querying relays, KP found
/ not found, kind, author
- `Account.addMarmotGroupMember` — commit publish target, welcome
delivery presence, full relay union with sources
- `Account.sendMarmotGroupMessage` — group, inner kind, target relays
- `Account.publishMarmotKeyPackage` + `ensureMarmotKeyPackagePublished`
— publish target relays, signed event id
- `DecryptAndIndexProcessor.processNewGiftWrap` — gift wrap unwrap
result, inner kind, route to Marmot welcome handler
- `DecryptAndIndexProcessor.processMarmotWelcome` — manager null,
WelcomeResult, synced metadata, group id
- `DecryptAndIndexProcessor.GroupEventHandler.add` — kind:445 arrival,
membership check, processGroupEvent result, decrypt path
- `MarmotInboundProcessor.processWelcome` — eventId lookup,
bundle-not-found details, MLS join success
- `MarmotGroupEventsEoseManager.updateFilter` — active groups,
per-group relay set, fallback usage, total emitted filters
- `AccountGiftWrapsEoseManager.updateFilter` — pubkey + dmRelay set
used for kind:1059 subscription
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
The user reported that NIP-17 DMs from Bob to Charlie arrive instantly,
but Marmot Welcomes and group messages produce nothing on Charlie's
device. Three independent bugs combined to make Marmot invisible on
the receiving side.
### 1. kind:445 EOSE filter never invalidated when joining a group
`MarmotGroupEventsEoseManager` only re-polled its filter set when
`account.homeRelays.flow` emitted. So an invitee that just processed
a Welcome got the new group added to `MarmotSubscriptionManager` and
to `marmotGroupList`, but the per-`h`-tag kind:445 subscription was
never actually sent to the relays — the EOSE manager kept using the
filter snapshot from app start. The same was true for restored groups
on every restart.
Fix: add a second job in `MarmotGroupEventsEoseManager.newSub` that
collects `account.marmotGroupList.groupListChanges` and calls
`invalidateFilters()`. `Account.init`'s restore loop now also fires
`marmotGroupList.notifyGroupChanged(groupId)` for every restored
group so the EOSE manager picks them up at startup.
### 2. Group metadata had no relays — both sides used different sets
`MarmotGroupData.relays` was always empty. `marmotGroupRelays` and
`MarmotGroupEventsEoseManager` both fall back to "the local user's
own relays" when the metadata has none — so Bob published kind:445 to
Bob's outbox while Charlie subscribed on Charlie's home relays. The
two only overlap by accident.
Fix: `AccountViewModel.updateMarmotGroupMetadata` now stamps the
inviter's outbox relays into the GCE proposal (merged with any
existing relays). The Welcome carries the GroupContext extensions, so
the invitee learns the same canonical relay set at join time.
`CreateGroupScreen` now always issues the metadata commit (even when
the user left the name blank) so this stamping happens for every new
group.
### 3. Welcome gift wrap delivery used a different relay path than NIP-17
`addMarmotGroupMember` had its own ad-hoc relay fallback chain
(`outboxRelays + cache.getOrCreateUser(...).dmInboxRelays()`) and
explicitly avoided `computeRelayListToBroadcast`. The comment claimed
that path returned an empty set in some cases, but the actual code in
`computeRelayListToBroadcast(GiftWrapEvent)` falls back through
kind:10050 → NIP-65 read → relay hints — exactly the same chain that
NIP-17 DMs use, which empirically reach the recipient.
Fix: union `computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)`
with the previous outbox + dmInboxRelays set so the welcome takes the
same route NIP-17 DMs do, plus belt-and-braces fallbacks. Added a log
line so we can tell from logcat exactly how many relays the welcome
went out on.
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
When user A wants to invite user B to a Marmot group, A must fetch B's
published KeyPackage (kind:30443) from relays. If B has never had one
published, A's `fetchKeyPackageAndAddMember` returns "No KeyPackage
found" and the invite silently fails — which matches the user's
report that "creating a new group with another user, nothing happens
for that user". Two fixes:
### 1. Persist KeyPackageBundles to disk
`KeyPackageRotationManager` was 100% in-memory: every restart wiped
both the active bundles (with their private init/encryption/signature
keys) AND the pending-rotation set. The relay echo of the kind:30443
event is meaningless without the matching private keys, so once a
session ended the user could no longer process Welcome events
referencing the published KeyPackage.
This change adds a quartz `KeyPackageBundleStore` interface and an
Android `AndroidKeyPackageBundleStore` implementation backed by the
same `KeyStoreEncryption` (AES/GCM via Android KeyStore) used by
`AndroidMlsGroupStateStore`. Storage layout:
<rootDir>/marmot_keypackages/state — encrypted snapshot
`KeyPackageRotationManager` accepts the store via constructor, encodes
the `(activeBundles, pendingRotations)` pair into a versioned TLS
snapshot, and persists after every state-mutating call:
`generateKeyPackage`, `markConsumed`, `markConsumedByRef`,
`clearPendingRotation`, `rotateSlot`. A new `restoreFromStore()`
method is invoked from `MarmotManager.restoreAll()` at startup.
The store is wired through:
MarmotManager(... keyPackageStore)
→ Account(... marmotKeyPackageStore)
→ AccountCacheState (constructs AndroidKeyPackageBundleStore)
### 2. Ensure a published KeyPackage at app startup
`Account.init` now calls a new private `ensureMarmotKeyPackagePublished`
right after `marmotManager.restoreAll()`. If no active bundle exists
in memory after the restore (i.e., the user never published one), it
generates a fresh bundle (which the rotation manager auto-persists)
and publishes the corresponding `KeyPackageEvent` to the user's
outbox relays. Best-effort — failures are logged and swallowed so a
flaky relay or missing outbox config at startup doesn't break account
init.
The redundant `LaunchedEffect` in `MarmotGroupListScreen` that did the
same thing on first screen visit is now removed: KeyPackage publishing
is no longer tied to the user navigating to the group list.
This means freshly installed accounts (and any account that had never
opened the Marmot Groups screen) will now have a published KeyPackage
on relays as soon as the account loads, so other users can actually
invite them.
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
- `MarmotGroupList.addMessage` now routes self-authored messages
through `restoreMessageSync` (the no-unread-bump path) so the
relay round-trip of a message the user just sent doesn't show up
as an unread bold entry in the group list and doesn't bump the
unread badge counter.
- `MarmotGroupListScreen` empty state now has 32dp horizontal
padding and centered text alignment so the "No invitations" /
"No groups yet" copy doesn't run into the screen edges.