Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
(ensureCurrentAccountInStorage called in onLoginSuccess)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of multi-account support. Move account types to commons/commonMain
so both Android and Desktop share the same data model:
- SignerType sealed class (Internal, Remote, ViewOnly)
- AccountInfo data class (npub, signerType, isTransient)
- AccountStorage interface (loadAccounts, saveAccount, etc.)
Desktop AccountManager now imports SignerType from commons instead of
defining its own. All existing tests updated with new import path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs
(detected by .pdf extension, NIP-92 imeta m tag, or application/pdf
Content-Type) render a card showing the first page, filename, and page
count. Tapping opens a full-screen HorizontalPager over every page
rendered on demand with PdfRenderer. Long-press surfaces the existing
share menu. Uses only the built-in Android PdfRenderer; no new
dependencies. Desktop continues to fall back to a clickable link.
Adds a dismissible top-of-screen banner to the Pinned Notes, Bookmarks,
Old Bookmarks, and Bookmark Set (detail) screens that appears when one
or more listed items have been deleted by their author (kind-5 deletion,
pubkey-matched via DeletionIndex). Tapping "Remove from list" rewrites
the underlying list event (NIP-51 kind 10001/10003/30001/30003) with
the deleted entries stripped out and broadcasts it once.
The scan is scoped to each screen's loaded list (typically <20 items)
and runs only while composed, so it avoids the performance hit of
hooking Account.deletedEventBundles, which fires for every kind-5
deletion in the firehose. Public and private (NIP-44 encrypted)
bookmarks are both cleaned in a single resign() call per list.
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
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
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
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
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.
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.
When user A invites user B to a Marmot/MLS group, B previously had no
visible signal that they had been added: no notification fired, the
group screen was a single flat list with no notion of "this group was
not started by me", and the in-memory chatroom was created but not
broadcast to the list UI. Three related changes here.
1. **Invitee Welcome notification.** `EventNotificationConsumer` now
handles `WelcomeEvent` (kind:444) inside the unwrapped gift wrap.
The push-notification background path does NOT go through
`Account.eventProcessor`, so we explicitly call
`manager.processWelcome` here on a best-effort basis and then
`syncMetadataTo` so the notification body can include the actual
group name. A new `marmot:<groupHex>?account=<npub>` deep link
scheme is parsed by `uriToRoute` so tapping the notification opens
the group chat directly.
2. **Known vs New Requests split for Marmot groups.**
`MarmotGroupChatroom` gains an `ownerSentMessage` flag mirroring
the private-DM `Chatroom` field. `MarmotGroupList` now takes the
account's `ownerPubKey` and flips that flag whenever it sees a
message authored by the local user, both in the live `addMessage`
path and in `restoreMessage` on startup. Two helpers,
`markAsKnown` and `notifyGroupChanged`, let the Account layer
move groups out of "New Requests" eagerly:
- `Account.createMarmotGroup` marks the new group known (the
creator obviously knows their own group)
- `Account.sendMarmotGroupMessage` marks known immediately on
send, before the relay round-trip
- `DecryptAndIndexProcessor.processMarmotWelcome` now fires
`notifyGroupChanged` after a successful join so an open
`MarmotGroupListScreen` re-renders to show the new invite
`MarmotGroupListScreen` now has a `PrimaryTabRow` with "Known" and
"New Requests" tabs and partitions the loaded groups by the new
flag.
3. **Group metadata hydration on the invitee.** Verified that the
existing `processMarmotWelcome` already calls `syncMetadataTo` on
the chatroom after `MarmotManager.processWelcome` succeeds, so
group name / description / admins / members / relays land in the
chatroom right after a Welcome is processed. Combined with (2)'s
`notifyGroupChanged` emit, the list UI now refreshes immediately.
Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:commons:compileAndroidMain`, `:amethyst:compilePlayDebugKotlin` —
all BUILD SUCCESSFUL.
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.
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
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
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
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.
New "Enable voice and video calls" switch at the top of the Call
Settings screen. Default is ON (no behavior change for existing users).
When a user turns it OFF:
- Call buttons in the chat room top bar are hidden. ChatroomScreen
observes account.settings.callsEnabled as a StateFlow and flips
onCallClick / onVideoCallClick to null, which RenderRoomTopBar
already treats as "no buttons".
- Incoming CallOfferEvents are silently dropped in CallManager.
A new isCallsEnabled: () -> Boolean hook on the CallManager
constructor gates onIncomingCallEvent *before* the followed-user
check, so the device never rings and no state transition occurs.
Existing in-flight call signaling (answers/hangups/ICE) still flows
through so a call that was active when the user flipped the toggle
continues to clean up normally.
- The rest of the call-related settings (video quality, max bitrate,
TURN servers) are hidden on the settings screen since they have no
effect while calls are disabled — the screen becomes just the single
meaningful toggle.
The setting is persisted per account via SharedPreferences
(PrefKeys.CALLS_ENABLED = "calls_enabled"), loaded in
LocalPreferences.loadFromEncryptedStorageSync, and exposed as a
MutableStateFlow<Boolean> on AccountSettings so both the chat top bar
and the settings screen react to changes without needing to navigate
away and back.
AccountViewModel wires
isCallsEnabled = { account.settings.callsEnabled.value }
into the CallManager constructor so the flag is read lazily on every
incoming event.
Tests in CallManagerTest:
- incomingOfferIgnoredWhenCallsDisabledInSettings — disabled flag
drops the offer, no state change, no published events.
- disablingCallsAfterStartDoesNotTearDownInProgressCall — an active
call keeps working after the toggle flips; only new offers are
ignored.
- incomingOfferProcessedWhenCallsEnabled — regression guard for the
default-enabled path.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
Two changes that tighten how the caller-side UX handles slow or
unresponsive callees in a group call:
1. Per-peer 30-second invite timeout
Previously the caller used a single 60-second call-wide timeout: if
*any* peer answered within the window the timer was cancelled and
slow peers could remain "ringing" indefinitely. For a mid-call
invite there was no timeout at all — the invitee stayed in
pendingPeerPubKeys forever, burning a PeerConnection on the caller
side and keeping the invitee's device ringing.
CallManager now schedules an independent 30-second timer for every
peer in pendingPeerPubKeys (or Offering.peerPubKeys in the initial
offer phase). The timer is started when the peer is added to
pending — in beginOffering, initiateCall and invitePeer — and is
cancelled when the peer answers (onCallAnswered), rejects
(onCallRejected) or hangs up (onPeerHangup). On expiry the peer is
dropped from the group, a CallHangup is published to them so their
device stops ringing, and onPeerLeft fires so CallController can
dispose the per-peer PeerConnection. If the drop leaves the caller
with zero connected and zero pending peers, the call ends with
EndReason.TIMEOUT.
Callee ringing still uses the 60-second CALL_TIMEOUT_MS in
onIncomingCallEvent; the two timers now serve distinct roles.
New constant CallManager.PEER_INVITE_TIMEOUT_MS = 30_000L.
NIP-AC.md "Event Lifecycle" documents both timers.
2. Per-peer "Calling..." status in the video grid
The shared "Waiting for others to join…" banner across the top of
ConnectedCallUI is removed. PeerVideoGrid now takes a
pendingPeerPubKeys set and, for each peer still pending, routes
rendering through PeerAvatarCell with a "Calling…" status line
under the username — so it's obvious *which* participants the call
is waiting on, not just *that* it's waiting. A peer in pending
never shows as a video cell even if a stale track is still in the
map, because they haven't actually answered yet.
The voice-only path in ConnectedCallUI now also uses PeerVideoGrid
(with empty tracks/active-video) instead of the single
GroupCallPictures + GroupCallNames stack, so the per-peer status
behavior is consistent for audio calls.
Tests added in CallManagerTest:
- perPeerTimeoutEndsP2PCallWhenBobNeverAnswers
- perPeerTimeoutDropsSlowCalleeFromGroupCall
- perPeerTimeoutIsCancelledOnAnswer
- perPeerTimeoutDropsMidCallInviteeWhenNoAnswer
- perPeerTimeoutIsCancelledOnReject
- perPeerTimeoutEndsCallWhenAllCalleesIgnore
All existing CallManagerTest cases still pass unchanged.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
When an existing group call participant invites a new peer via
CallController.invitePeer(), the caller connects to the invitee via the
normal offer/answer flow, but the other existing callees were never
establishing their own PeerConnections to the new peer — breaking the
full-mesh invariant.
Root cause: the NIP-AC "callee-to-callee mesh setup" mechanism relies on
each callee observing the others' CallAnswers during its own ringing
window and applying a symmetric "lower pubkey initiates" tiebreaker.
A mid-call invitee's ringing window happens after all existing callees
have already answered, so the invitee's discoveredCalleePeers set is
empty. Worse, if the invitee's pubkey is higher than an existing
callee's, that callee also stays passive (waiting for the invitee to
initiate), and no mesh connection is ever attempted between them.
Fix — break the symmetry on mid-call invites:
- CallManager.onCallAnswered now expands peerPubKeys when an answer
arrives in Connected (or Connecting) state from a peer that is not
yet in the tracked group membership. This keeps the UI and state
consistent with the expanded group and gives CallController a clear
hook via onAnswerReceived.
- CallController.onCallAnswerReceived splits the NO_SESSION case:
* Connected state → mid-call invite. Unconditionally initiate a
mesh CallOffer to the new peer. The invitee stays passive, so
exactly one side initiates per pair and glare is structurally
impossible.
* Connecting state → initial-call mesh observation. Keep the
existing lower-pubkey tiebreaker via onNewPeerInGroupCall to
avoid glare with the symmetric peer.
This fix uses the existing event kinds (CallOffer 25050 and CallAnswer
25051) — no new kinds or tags are required. NIP-AC.md is updated with
a new "Mid-Call Mesh Expansion" subsection under "Inviting New Peers"
documenting the asymmetric rule and a full-flow diagram.
Tests in CallManagerTest cover:
- Mid-call invitee's broadcast answer expands existing callee's
peerPubKeys in Connected state.
- Same expansion works in Connecting state (existing callee still
handshaking with caller when the invitee joins).
- Initial-call answers from already-tracked peers do NOT trigger the
expansion branch (regression guard).
- Full end-to-end 3-way flow exercising Alice, Bob, Carol managers.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
Mirror TorSettingsTest and TorRelayEvaluationTest in commons/commonTest
using kotlin.test for KMP compatibility. Tests verify the shared types
directly without going through Android typealiases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New shared types in commons/commonMain/tor/:
- TorServiceStatus: clean sealed class with Off, Connecting, Active(port),
Error(message). Uses data object for stateless variants. No TorControlConnection
dependency (Android keeps its own version for now).
- ITorManager: reactive interface with status flow + dormant/active/newIdentity
- ITorSettingsPersistence: load/save interface for platform persistence
Android TorServiceStatus unchanged — will migrate to commons version when
RelayProxyClientConnector is refactored to use ITorManager signals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move relay routing logic to shared commons module:
- TorRelaySettings data class
- TorRelayEvaluation with useTor() routing logic
Android files replaced with typealiases for backward compatibility.
All tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move core Tor data models and preset logic to shared commons module:
- TorSettings data class, TorType enum, TorPresetType enum
- parseTorType, parseTorPresetType, isPreset, whichPreset functions
- All preset constants (torOnlyWhenNeededPreset, etc.)
R.string resource IDs removed from enums in commons. Android retains
resourceId/explainerId as extension properties in ui.tor package.
15 Android files updated to import from commons.tor. All 69 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Send hangup/reject to peer when WebRTC init or PeerConnection creation
fails, so remote phone stops ringing instead of timing out after 60s
- Throw on null PeerConnection from factory to fail fast instead of
silently no-oping all subsequent WebRTC operations
- Start foreground service during IncomingCall to protect ringtone
playback from being killed on Android 14+
- Make cleanup() idempotent with AtomicBoolean guard to prevent double
disposal when Ended state and ViewModel.onCleared race
- Replace mutableMapOf with ConcurrentHashMap for videoSenders accessed
from UI and WebRTC callback threads
- Add @Volatile to peerConnection, videoPausedByProximity, and
foregroundServiceStarted for cross-thread visibility
- Capture peerConnection into local variable in dispose() to prevent
TOCTOU race between close() and null assignment
- Replace leaked MainScope() in CallNotificationReceiver with structured
CoroutineScope that is cancelled after work completes
- Remove self-wraps in group answer/reject to avoid wasting bandwidth
sending encrypted messages to ourselves
- Move startTimeout inside stateMutex in initiateCall for consistency
https://claude.ai/code/session_017HrFJNxD6zrGwiZ3s69xTh
Bug fixes:
- Fix RemoteVideoMonitor killing group monitor job when primary track switches
- Add mutex protection to CallManager.initiateCall() to prevent state races
- Fix ICE restart offer never being sent to remote peer (was immediately
replaced by a second offer from onRenegotiationNeeded)
- Fix duplicate duration timer in PiP connected call UI
- Fix error snackbar dismiss button not clearing the error
- Make PeerSessionManager thread-safe with synchronized blocks (accessed
from WebRTC native threads and coroutine dispatchers concurrently)
- Make CallManager event handlers private (only called from onSignalingEvent)
Improvements:
- Replace fragile ICE candidate regex parsing with kotlinx.serialization JSON
- Respect DND/silent mode: only ring in NORMAL mode, only vibrate in VIBRATE
- Signal camera-off to remote peer by removing video track sender (instead
of sending frozen/black frame)
- Clear CallSessionBridge on AccountViewModel.onCleared() to prevent stale
references on account switch
- Custom TURN servers now replace defaults (instead of appending) so
credentials can be rotated without an app update
New features:
- Front/back camera switch button (visible when video is enabled)
- Network transition handling: ConnectivityManager.NetworkCallback triggers
ICE restart on all peers when network changes (WiFi/cellular handoff)
https://claude.ai/code/session_01JHn7skAibTrkVqsoWutgYe
Bug fixes:
- Fix invitePeer() bypassing CallManager state tracking, causing
invited peers to not appear in pendingPeerPubKeys
- Remove 10-minute proximity wake lock timeout so it lasts the
full call duration (released on cleanup)
- Send hangup to peers on caller timeout so callees stop ringing
immediately instead of waiting for their own 60s timeout
- Remove duplicate cleanup() call on Ended→Idle transition
New feature:
- Add Call Settings screen (TURN servers + video quality)
- Users can configure custom TURN servers for restrictive networks
- Default STUN/TURN servers are always active and displayed
- Video resolution options: 480p, 720p (default), 1080p
- Configurable max video bitrate: 750kbps, 1.5Mbps, 3Mbps
- Settings wired into IceServerConfig and CallMediaManager
https://claude.ai/code/session_01F5RF2yzngiMr1v2gr7f1GP
In chess note cards (NoteCompose), player hex keys were displayed as
raw strings. Now uses LoadUser + ClickableUserPicture + UsernameDisplay
to show proper profile pictures and display names for:
- Challenge cards (incoming/outgoing)
- Game end cards (both players)
- PGN metadata in game viewers (white/black players)
Added playerContent composable slot to PGNMetadata and ChessGameViewer
so callers can inject platform-specific user rendering.
https://claude.ai/code/session_0171mKrVEfQnNRabmT7Kv4gf
- Clear discoveredCalleePeers on transitionToEnded to prevent stale
peers from triggering mesh setup in subsequent calls
- Cap processedEventIds and completedCallIds with LRU eviction to
prevent unbounded memory growth over long app sessions
- Store and release SurfaceTextureHelper in startCamera/stopCamera
to prevent GL thread and texture leaks
- Wrap stopCamera's stopCapture in try-catch for InterruptedException
to ensure camera resources are always released
- Add Mutex to CallManager to serialize state mutations, preventing
races when signaling events arrive concurrently from multiple relays
- Add @Synchronized to CallController's toggleAudioMute, toggleVideo,
and cleanup to prevent races with WebRTC callbacks
https://claude.ai/code/session_01TTPrYjcz1eYEzdSV5N6rHi
When the app restarts and reconnects to relays, old call offer events
from completed calls could be replayed, causing the phone to ring for
calls that already ended. This was especially noticeable when the user
killed and restarted the app shortly after a call.
Three protections added to CallManager:
1. Track completed call IDs: hangup/reject events mark their call-id as
completed. Subsequent offer events for the same call-id are ignored.
2. Init timestamp guard: events created before the CallManager was
initialized (minus a grace period) are rejected, preventing stale
events from a previous app session from triggering ringing.
3. Completed call IDs survive reset(): the set is intentionally not
cleared when the call state machine resets to Idle, ensuring that
stale offers remain blocked for the lifetime of the CallManager.
https://claude.ai/code/session_0145VKiG8yZMqcMsaBEjNPxv
Move transitionToEnded() before the signing + relay publish in hangup()
so the UI stops ringing/ringback immediately, matching the pattern
already used by rejectCall(). Add onDestroy safety net in CallActivity
to hang up if the Activity is destroyed while a call is active. Wrap
audio stop methods in try-catch to prevent one failure from blocking
the others.
https://claude.ai/code/session_01Rip2HPCbF48PPFDiB2X5ik
The publish KeyPackage button was always active because the app didn't
track whether a key package had already been published. This adds:
- hasActiveKeyPackages() to KeyPackageRotationManager and MarmotManager
- hasPublishedKeyPackage() to Account, checking both in-memory bundles
and the local cache for existing kind:30443 events
- Own key package filter in MarmotSubscriptionManager and the EOSE
manager so previously published key packages are downloaded from
relays on app restart
- UI feedback: primary-colored key icon when published, contextual
empty-state message, and a spinner during publishing
https://claude.ai/code/session_01BVe7aSEWd2KLi5Ks6RZkcc