Two related Marmot/MLS group bugs were causing data loss across app
restarts:
1. **Group name disappears, edit screen shows "Group Metadata Not Found".**
`CreateGroupScreen` was setting `chatroom.displayName.value` directly
in memory and never writing the name into the MLS GroupContext
extensions. After restart `MarmotManager.syncMetadataTo` had nothing
to read from, and `EditGroupInfoScreen` blew up because
`AccountViewModel.updateMarmotGroupMetadata` required existing
metadata to copy from.
Now `CreateGroupScreen` issues a real GCE commit through
`updateMarmotGroupMetadata` so the name is persisted in the MLS
extensions on disk. `AccountViewModel.updateMarmotGroupMetadata`
tolerates missing prior metadata by constructing a fresh
`MarmotGroupData` (creator as admin), so older groups can also be
recovered by editing them. `Account.updateMarmotGroupMetadata` now
calls `syncMetadataTo` after the local commit so the chatroom UI
reflects the new name without waiting for the relay round-trip.
2. **Messages disappear after restart.** `MarmotGroupChatroom.messages`
was purely in-memory. Marmot/MLS application messages cannot be
re-decrypted once the ratchet has advanced, so relay redelivery is
not enough to restore history — the plaintext must be captured at
first decryption and persisted.
This change introduces `MarmotMessageStore` (quartz interface) and
`AndroidMarmotMessageStore` (encrypted, file-based, sharing the same
KeyStoreEncryption used for MLS state). `MarmotManager` now exposes
`persistDecryptedMessage` / `loadStoredMessages` and clears the log
on `leaveGroup`. `GroupEventHandler` persists each new application
message after it has been added to the chatroom. On startup,
`Account.init` loads any stored messages for restored groups and
re-hydrates the chatroom via a new `restoreMessageSync` helper that
does not bump the unread counter.
Two related bugs in the Marmot "new group → add member" path were
preventing the invited user from ever discovering the group, even
though the commit applied cleanly on the creator's side.
1. fetchKeyPackageAndAddMember() searched for the invitee's KeyPackage
on the *creator's* own outbox relays. KeyPackages are published by
the invitee to their own outbox (see publishMarmotKeyPackage), so
this only worked when the two happened to overlap. Union the
invitee's NIP-65 outbox with ours before issuing fetchFirst().
2. addMarmotGroupMember() routed the Welcome gift wrap through
computeRelayListToBroadcast(). Its GiftWrap branch first tries the
recipient's NIP-17 kind:10050 and falls back to
computeRelayListForLinkedUser(), which reaches for
inboxRelays()/relay hints and can legitimately return an empty set
when the recipient has no cached NIP-17/NIP-65 data. client.publish
then silently dropped the welcome, which is why the invitee saw
nothing while the creator's local state looked healthy.
Mirror sendNip04PrivateMessage's delivery strategy instead: publish
to our own outbox (so we retain a copy and the invitee may find it
via a shared relay) unioned with User.dmInboxRelays(), which already
ladders NIP-17 kind:10050 → NIP-65 read relays — the exact set
AccountGiftWrapsEoseManager listens on. A warning log covers the
pathological case where both sides are empty.
Note: spotlessApply/compile could not be run in the sandbox (Android
Gradle plugin unreachable from this environment). Please run
./gradlew spotlessApply locally before merging.
The incoming call push notification was falling back to the raw caller
hex (or short npub) whenever the caller's User object hadn't been
created or their metadata hadn't been loaded yet — which is the common
case when the app is woken up by a push notification with a fresh,
empty in-memory cache.
Switch notifyIncomingCall to use LocalCache.getOrCreateUser, and if the
caller's metadata hasn't been loaded, briefly subscribe via
UserFinderQueryState (same pattern used by wakeUpFor) and wait up to
5 seconds for metadata to arrive before building the notification.
This lets toBestDisplayName() resolve to the user's real name in the
notification popup.
Also tighten NotificationUtils.showIncomingCallNotification to use
getOrCreateUser so that in-app calls fall back to the user's npub
display rather than a raw hex prefix when metadata is unavailable.
Maps the full call flow — NIP-AC event kinds, module layout across
quartz/commons/amethyst, the CallManager state machine, incoming and
outgoing signaling pipelines, group-call invited-vs-watchdog timer
semantics, multi-device "answered elsewhere" handling, and the
load-bearing invariants (stateMutex discipline, self-pubkey guards,
publish-outside-the-lock pattern).
Lives next to CallManager.kt so the next contributor finds it without
an hour of grepping.
https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
Implements draft NIP-82 in the experimental package, following the
NIP-88 polls file structure. Adds three new events:
- SoftwareApplicationEvent (kind 32267): parameterized replaceable event
describing a software application, keyed by reverse-domain `d` tag
- SoftwareReleaseEvent (kind 30063): parameterized replaceable event
grouping assets under a versioned release channel
- SoftwareAssetEvent (kind 3063): regular event binding an installable
artifact to its sha256, mime type, platform and version metadata
SoftwareApplicationEvent (32267) and SoftwareAssetEvent (3063) are
registered in EventFactory. SoftwareReleaseEvent (30063) is intentionally
left unregistered because that kind already maps to NIP-51
ReleaseArtifactSetEvent; callers can instantiate it directly.
When the user is logged in on two devices and receives a call, both
ring. When one picks up, the other should stop ringing immediately —
but crucially, the device that answered must not lose its WebRTC
connection when the silenced device reacts.
The "answered elsewhere" state machine (onCallAnswered at the self-
pubkey branch) was already in place and covered by a unit test, but the
signal it relies on was never actually published: acceptCall wrapped the
CallAnswerEvent only for `groupMembers - signer.pubKey` so in a 1:1 call
it only reached the caller. Sibling devices subscribed to gift wraps
for their own pubkey never saw it and kept ringing until the 60 s local
timeout.
Fixes:
- acceptCall / rejectCall publish an extra gift wrap of the *same*
signed answer/reject event addressed to `signer.pubKey`, so sibling
devices in IncomingCall observe the echo and transition to
Ended(ANSWERED_ELSEWHERE / REJECTED) locally. Neither path publishes
any further signaling, so the device that picked up is never
disturbed.
- onCallRejected: add a self-pubkey early-return mirroring
onCallAnswered. Without it, a self-reject echoing back to a device
already in Connecting/Connected hit the peer-rejection branches,
firing onPeerLeft(signer.pubKey) — which would tell CallController to
dispose its own PeerSession and tear down the live audio.
- onSignalingEvent: record self-answer call-ids in completedCallIds
alongside hangups and rejects, so an out-of-order relay replay
delivering the self-answer before the original offer does not make a
sibling device start ringing for a call it already knows was answered.
Adds multi-device tests covering: the new self-addressed wraps in
acceptCall/rejectCall, the end-to-end "two phones, one answers"
scenario, the Connected / Connecting self-reject echo guards, and the
out-of-order offer-after-self-answer replay case.
https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
Logs from the real device showed that with the previous fixes save and
listGroups now work correctly (878 → 1162 bytes written, file found
after restart), but decrypt was throwing:
InvalidAlgorithmParameterException: Only GCMParameterSpec supported
at AndroidKeyStoreAuthenticatedAESCipherSpi$GCM.initAlgorithmSpecificParameters
AndroidKeyStore's authenticated AES/GCM cipher rejects plain
IvParameterSpec and requires an explicit GCMParameterSpec with the
auth tag length. Switched decrypt() to use GCMParameterSpec(128, iv).
While chasing this I also noticed that MlsGroupManager.restoreAll
was **deleting** the stored group on any exception from load(), on
the assumption that it must be corrupted. This turned a transient
decrypt bug into permanent data loss — the user's group file was
wiped during the first broken restart. Change the catch block to
log and skip instead of delete, so after a fix the next restart
can still recover the data.
https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
Logs from the real device showed that the AndroidMlsGroupStateStore
constructor was throwing NoSuchAlgorithmException for
"AES/GCM/PKCS7Padding" at Cipher.getInstance, which caused
AccountCacheState to silently fall back to InMemoryMlsGroupStateStore
for every account. That's why Marmot groups vanished on every restart.
GCM is an authenticated encryption mode and must not use a block
padding scheme — the correct transformation is AES/GCM/NoPadding.
PKCS7Padding was always wrong; no provider on this device accepts it
for GCM. Switch PADDING to ENCRYPTION_PADDING_NONE.
This class is currently only wired to AndroidMlsGroupStateStore
(AccountSecretsEncryptedStores is defined but not referenced anywhere
in the runtime code path — only in docs/secure-key-storage-migration.md),
so there is no on-disk data that was previously encrypted with the
broken transformation to worry about.
https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
User-reported issue: creating a Marmot group works in a single session,
but the group is gone after app restart. The store code path survives
the persistGroup save on creation, but something between save and
restore silently loses the state.
Changes:
- KeyStoreEncryption: pass "AndroidKeyStore" explicitly to
KeyGenerator.getInstance so generator.init(KeyGenParameterSpec) is
guaranteed to land on the AndroidKeyStore provider (the default
provider selection is not guaranteed to choose it). Also wrap
encrypt/decrypt in try/catch so the real exception is logged
instead of bubbling up as a generic failure.
- AndroidMlsGroupStateStore: log rootDir at construction, and log
every save/load/listGroups/delete with file paths, byte counts,
and whether the target file actually exists after writing. Any
thrown exception is now logged with full stack trace before
rethrowing.
- MlsGroupManager: log create / persist / restoreAll with group ids,
byte counts, and in-memory group count so we can see exactly which
step drops state. The existing "corrupted state, delete" branch now
logs at ERROR with a clearer marker so it's easy to spot in the logs.
- MarmotManager + AccountCacheState: log which MLS store implementation
is chosen per account (Android vs InMemory fallback) and the root
filesDir path, plus restoreAll() begin/end with restored group ids.
These logs should make it obvious whether:
1. persistence is silently falling back to InMemoryMlsGroupStateStore,
2. save is actually writing the file to disk,
3. listGroups is finding the directory on restart,
4. decrypt is throwing and the restoreAll catch block is wiping
the "corrupted" state.
https://claude.ai/code/session_014EKS8JBwSpap34aM6FnYLm
The cleanedUp AtomicBoolean guard in CallController was only re-armed in
initiateGroupCall() and acceptIncomingCall(). For incoming calls the user
rejected (or that the remote party hung up before being accepted), the
flag stayed true after the first cleanup, so on the next incoming call
cleanup() became a no-op — leaving the ringtone playing and the
notification stuck on screen until the app was force-killed.
Three layers of defense:
1. Re-arm cleanedUp in the state collector when entering IncomingCall or
Offering, so cleanup() always runs for new call sessions regardless of
how the previous one ended.
2. Move the (idempotent) ringtone/ringback/notification stops in
cleanup() out of the guarded block so they run unconditionally, even
if the heavy teardown is already complete.
3. In CallActivity.onDestroy(), explicitly stop the ringtone, ringback
tone, and call notification after controller.cleanup() so their
lifecycle is hard-tied to the Activity — when the call UI goes away
for any reason, the audio and notification go away too.
Scenario: Alice calls {bob, carol} as a group. Bob answers, Bob and
Alice are talking, Carol never joins. Before Carol's 30 s watchdog
fires, Alice hangs up. Bob's state had peerPubKeys={alice} and
pendingPeerPubKeys={carol}. The old onPeerHangup handler only ended
the call when BOTH sets became empty, so Bob stayed in Connected
alone staring at a black screen until Carol's watchdog finally
dropped her — and even then the state machine didn't terminate
because "no peers, no pending" wasn't true at the exact same step.
Change the termination rule in onPeerHangup (both Connected and
Connecting states) to end the call as soon as connectedRemaining
becomes empty, regardless of how many pending peers are still
tracked. Without a single connected peer the call has no one to
talk to and can't meaningfully continue.
Also publish a CallHangup to any peers in the pending set that WE
personally invited (peersInvitedByUs ∩ pendingPeerPubKeys) so their
devices stop ringing. Peers we did not invite (e.g. group members
the caller originally included) are the caller's responsibility —
the caller's own group hangup already reaches them — so we leave
them alone.
Adds three regression tests covering:
- Connected state: caller hangs up while one pending member remains
- Connecting state: same, before onPeerConnected fires
- Callee had invited someone mid-call: call ends AND Bob notifies
his invitee to stop ringing
When a caller invites a group and one of the members never answers,
the caller already drops the unresponsive peer after 30 s (via the
per-peer watchdog in CallManager). The hangup the caller publishes is
addressed only to the unresponsive peer, so the OTHER participants
never learn that the peer is out and keep waiting for them forever.
Mirror the caller's 30 s budget on the callee side. In acceptCall,
split the group members into:
- peerPubKeys: the caller (they sent us the offer) plus any peers
whose answer we observed while still ringing. These are confirmed
to be in the call.
- pendingPeerPubKeys: everyone else we haven't heard from yet. Each
gets a local watchdog timer; if we never observe them (via a mesh
answer or a mid-call offer) within 30 s they are silently dropped
from our local state.
Because the callee is not the peer's inviter, handlePeerTimeout must
NOT publish a CallHangup in this path — terminating the invitee's
ringing is the caller's responsibility. Track "who we invited" in a
peersInvitedByUs set so the caller-side path (beginOffering,
initiateCall, invitePeer) still publishes the hangup, while the new
callee-side watchdog path drops the peer silently.
Also move a pending peer into peerPubKeys and cancel their watchdog
when they send us a mid-call mesh offer — the offer is proof they're
in the call, so we should not wait further.
Adds three new tests (callee watchdog drops pending peer, mid-call
offer transitions pending peer, watchdog is cancelled on answer from
pending peer) and updates two existing tests for the new Connecting
state shape when a group call is accepted.
On Android, KeyPairGenerator.getInstance("Ed25519") could resolve to
AndroidKeyStoreKeyPairGeneratorSpi, which rejects NamedParameterSpec and
requires KeyGenParameterSpec, crashing group creation with
IllegalArgumentException. Explicitly select a non-AndroidKeyStore
provider (Conscrypt on Android, SunEC on JVM) for KeyPairGenerator,
KeyFactory, and Signature, and drop the unnecessary initialize() call
since Ed25519 is fully specified by its algorithm name.
The previous two-LaunchedEffect handshake (one to flip boundsReady,
another to animate on the new key) was unreliable — the enter
animation frequently never ran and the dialog snapped straight to
fullscreen.
Hoist imageBounds up to ZoomableImageDialog alongside progress and
use a single LaunchedEffect(Unit) that awaits the first valid bounds
via snapshotFlow before calling progress.animateTo(1f). Bounds are
always accepted on first capture so the flow can emit; subsequent
updates are still blocked while progress.isRunning so a mid-flight
layout change can't cause a hiccup. After the animation settles,
updates flow through again so the exit animation targets the current
on-screen image.
Moves off the 2.1.1-hls-SNAPSHOT mavenLocal iteration onto the
released JitPack artifact. API surface is identical to the final
SNAPSHOT Amethyst was already running against after the library
code review.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two related UX issues on the HLS publish progress screen:
1. The Uploading PhaseRow's label used a fallback of
`stringResource(hls_state_uploading_format, 0, 0)` whenever state
wasn't Uploading, so the row flickered back to "Uploading 0 of 0"
during the Transcoding phase between rendition uploads. The counter
appeared to reset mid-flow even though every upload was succeeding.
2. With the counter now persisted, it still read "Uploading 2 of 5"
while we were actually transcoding the next rendition — the count
was right but the verb tense implied an upload was in flight when
none was.
Persist lastDone + lastTotal in the composable via remember so the
counter reads monotonically across state transitions, and split the
label into two semantic variants:
- "Uploading %s (%d of %d)" — present tense, only when an upload is
actually in flight (state is HlsPublishState.Uploading). The %d is
the pre-incremented in-flight index.
- "Uploaded %d of %d" — past tense, used both in the gap between
uploads (transcoding next rendition) and in the done/checkmark
state. The %d is the last observed in-flight index, which after the
lambda returns corresponds to the count that has actually finished.
- "Upload" — only the pre-first-upload idle state.
Also add a progressFraction that falls back to lastDone/lastTotal so
the progress bar under the row sticks too, matching the label.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The library's HlsUploadHelper.run is now generic: the lambda returns
HlsUploaded<T>(url, metadata) and the result surfaces every upload as
HlsUploadResult<T>.uploads keyed by the same suggestedFilename the
library handed the lambda. Amethyst threads MediaUploadResult through
as T, so the per-rendition sha256 / size needed for NIP-71 imeta tags
comes out of the helper's own return value instead of a
hand-maintained side-channel MutableMap the orchestrator kept in
parallel.
- HlsPublishOrchestrator.runUpload closure signature:
suspend (HlsConfig, HlsListener, suspend (File, String) -> String)
-> HlsUploadResult
becomes
suspend (HlsConfig, HlsListener, suspend (File, String) -> HlsUploaded<MediaUploadResult>)
-> HlsUploadResult<MediaUploadResult>
- delete `segmentUploads = mutableMapOf<String, MediaUploadResult>()`
inside publish(); read directly from uploadResult.uploads instead
- HlsVideoPublishInput.segmentUploads: Map<String, MediaUploadResult>
becomes uploads: Map<String, HlsUploaded<MediaUploadResult>>; event
builder reads playlistUpload.url and combinedMetadata?.sha256/size
via .metadata
- HlsPublishOrchestratorFactory's HlsUploadHelper.run call uses the
new generic form with T = MediaUploadResult
- tests: fakeRunUpload now synthesizes the uploads map in a
LinkedHashMap (matching the library's iteration-order contract) and
returns HlsUploadResult<MediaUploadResult>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the lambda set HlsPublishState.Uploading(done=uploadsDone)
before running the upload and incremented after. So while upload #1 was
actually in flight the state said "0 / 5 done", and StateFlow conflation
ate the post-upload increment before the UI could paint it — the counter
appeared stalled until the next rendition's onProgress flipped state
back to Transcoding.
Pre-incrementing means `done` now represents "working on item N of M"
rather than "N already finished, N+1 silently in flight", so the first
upload displays as "1 of 5" and each subsequent upload ticks to 2, 3,
4, 5 visibly. The earlier "done" wording existed to disambiguate 0/N
from a stalled bar; with pre-increment we never show 0/N, so the string
drops "done" and reads as the cleaner "Uploading X of Y".
Confirmed via logcat on device: R1 sinkFinish=5669ms, R2 sinkFinish=6343ms
— the "phantom upload" between R1 encoding and R2 encoding really was
the R1 combined upload; the counter just wasn't reflecting it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously the "Draft note" button on the HLS success screen pushed the
composer on top of the publish screen. When the composer popped itself
after posting, the user landed back on the now-Idle HLS publish screen
with the form still populated — confusing "I'm back where I started"
UX. Use popUpTo so the publish screen is removed from the back stack as
the composer opens; posting or backing out now drops the user on the
screen they came from.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Collapses Amethyst's hand-rolled HLS orchestration onto the library's
HlsUploadHelper.run. The library now ships everything the prior session
had to reimplement: per-rendition width/height/codec metadata on the
onRenditionComplete callback, a public PlaylistRewriter, canonical
HlsContentTypes constants, and the transcode -> upload -> rewrite loop
itself.
- bump libs.versions.toml to 2.1.1-hls-SNAPSHOT
- delete HlsUploadPipeline, HlsBundle, HlsTranscoder, HlsTranscodingSession,
HlsPlaylistRewriter and their tests; HlsUploadHelper.run + the library
rewriter cover everything they did
- rewrite HlsVideoEventBuilder to consume HlsRenditionSummary width/height
directly; drops the master-playlist streamInfRegex entirely
- HlsPublishOrchestrator now wraps HlsUploadHelper.run: a SimpleHlsListener
drives Transcoding progress while the uploader lambda captures each
MediaUploadResult in a side-channel map keyed by the library's
suggestedFilename, so per-rendition sha256/size still flow into the
NIP-71 imeta tags
- extract HlsBlobUploader into its own file (was inline in the deleted
HlsUploadPipeline.kt)
Net delta on HLS code: 3194 -> 2182 lines (-1012, ~32%).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Five full-width rows eating half a screen was too much real estate for
a five-item toggle. Replace the fillMaxWidth stack with a FlowRow where
each item is a compact checkbox + short label ("360p", "540p", …),
wrapping to the next line on narrow screens.
The bitrate subline per rendition was cut — the values are public
library defaults anyway (360/540/720/1080/4K ladder) and the secondary
text was the biggest contributor to the vertical bloat. Above-source
rungs remain disabled; the grey-out on the checkbox + label is still
visible in the new layout.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The bare "0 / 5" read like the pipeline was stuck before the first
upload completed. Appending "done" makes the count read as a
completion tally — "0 / 5 done" clearly says "nothing finished yet"
rather than "0 steps remaining" — and matches the label's intent.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>