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
Implement end-to-end reading of MarmotGroupData (extension 0xF2EE)
from the MLS GroupContext.extensions and sync to the UI layer.
Quartz (protocol):
- MarmotGroupData.decodeTls(): deserialize from TLS wire format
(version, nostrGroupId, name, description, admin pubkeys, relays,
image fields)
- MarmotGroupData.fromExtensions(): find and decode from extension list
- MlsGroup.extensions: expose groupContext.extensions publicly
Commons (shared logic):
- MarmotGroupChatroom: add description, adminPubkeys, relays fields
as MutableStateFlow for reactive UI
- MarmotManager.groupMetadata(): extract MIP-01 data from group
- MarmotManager.syncMetadataTo(): sync metadata + member count to
a MarmotGroupChatroom
Sync points (amethyst):
- Account init: sync metadata after restoreAll() for all restored groups
- GiftWrapEventHandler: sync after Welcome processing (group join)
- GroupEventHandler: sync after CommitProcessed (epoch advances may
update extensions via GroupContextExtensions proposals)
UI (GroupInfoScreen):
- Display group description from MIP-01 metadata
- Display relay URLs from MIP-01 metadata
- Mark admin members with "- admin" suffix (from adminPubkeys)
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Based on MIP spec review, add missing UI screens:
MarmotManager additions (commons):
- memberPubkeys(): extract Nostr pubkeys from MLS ratchet tree
leaf nodes via Credential.Basic identity
- memberCount(): get group member count
- groupEpoch(): expose current MLS epoch
- GroupMemberInfo data class for leaf index + pubkey pairs
Account & AccountViewModel:
- leaveMarmotGroup(): publish SelfRemove proposal and clean up
- marmotGroupMembers(): expose member list to UI
MarmotGroupInfoScreen (new):
- Group metadata header (name, ID, epoch, member count)
- Member list with leaf indices, clickable to view profiles
- "Leave Group" with confirmation dialog
- "Add Member" access from group info
Navigation:
- Route.MarmotGroupInfo added and wired into nav graph
- Chat screen title now clickable to navigate to group info
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Add complete UI layer for Marmot Protocol MLS group messaging:
State models (commons/commonMain/):
- MarmotGroupChatroom: tracks decrypted inner messages per group
- MarmotGroupList: manages all group chatrooms with change notifications
- MarmotGroupFeedFilter: feed filter for group message feeds
- MarmotGroupFeedViewModel: ViewModel exposing group messages
Account integration:
- Add marmotGroupList to IAccount interface and implementations
- GroupEventHandler indexes inner events into MarmotGroupList
- AccountViewModel gains sendMarmotGroupMessage, createMarmotGroup,
publishMarmotKeyPackage helper methods
Android screens (amethyst/):
- MarmotGroupListScreen: lists all groups with last message preview
- MarmotGroupChatScreen: group conversation with message composer
- MarmotGroupChatView: message feed + simple text composer
- CreateGroupDialog: create new MLS group with display name
- AddMemberDialog: add member by npub/hex (KeyPackage fetch TBD)
Navigation & integration:
- Route.MarmotGroupList and Route.MarmotGroupChat routes
- Wired into AppNavigation nav graph
- "MLS Groups" button added to ChannelFabColumn on Messages screen
- Marmot group newest messages appear in ChatroomListKnownFeedFilter
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Wire Marmot MLS group messaging into the app's Account lifecycle,
event processing pipeline, and relay subscription system.
New files:
- AndroidMlsGroupStateStore: encrypted file-based MLS state storage
using KeyStoreEncryption (AES/GCM backed by Android KeyStore)
- MarmotManager: central coordinator holding all Marmot components
(MlsGroupManager, KeyPackageRotationManager, subscription/inbound/
outbound processors, WelcomeSender)
- MarmotGroupEventsEoseManager: relay subscription manager for
kind:445 GroupEvent filters via AccountFilterAssembler
Account integration:
- MarmotManager initialized during Account startup with restoreAll()
- Outbound methods: sendMarmotGroupMessage, addMarmotGroupMember,
publishMarmotKeyPackage(s), createMarmotGroup
- GroupEvent (kind:445) and KeyPackageEvent (kind:30443) added to
LocalCache.justConsumeInnerInner() dispatch
Event routing:
- GroupEventHandler: processes inbound kind:445 events through
MarmotInboundProcessor, indexes decrypted inner events in LocalCache
- GiftWrapEventHandler extended: detects kind:444 WelcomeEvent after
NIP-59 unwrap, routes to MarmotManager.processWelcome(), triggers
KeyPackage rotation when needed
- WelcomeEvent enhanced with optional "h" tag for group ID routing
https://claude.ai/code/session_01W2LHazEt4E3W4hn8f7gWVW
- Split PeerSession.kt out of PeerSessionManager.kt (types, interface,
manager are now in separate files)
- Remove webRtcSessions duplication in CallController — PeerSessionManager
is now the single source of truth for session tracking; WebRtcCallSession
is retrieved via the adapter cast when WebRTC-specific APIs are needed
- Initialize PeerSessionManager eagerly with localPubKey (passed to
CallController constructor) instead of lazy suspend init — fixes early
ICE candidates being silently dropped before first suspend call
- Extract FakePeerSession into its own file for reuse across test files
- Remove assertion-only glare tiebreaker tests from NipACStateMachineTest
(now properly tested with real logic in PeerSessionManagerTest)
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
- ThumbnailDiskCache: combine get()+decodeThumbnail() into single load()
method. Move bitmap resize logic into generateFromFile() so callers
just pass a source file and the cache handles decode+resize+save.
- ProfilePictureFetcher: remove unused `options` field, remove @Stable
annotation, delegate all resize logic to ThumbnailDiskCache.
- UserAvatar: fix stale doc comment.
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
Replace profilepic:// string scheme with a ProfilePictureUrl data class.
Coil routes to the fetcher by type via Fetcher.Factory<ProfilePictureUrl>
instead of parsing a URI scheme string on every load. This avoids string
concatenation at the call site and string parsing in the fetcher/keyer.
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
Extract the ICE candidate buffering, answer routing, and renegotiation
glare logic from CallController into a platform-independent
PeerSessionManager in commons/. This makes the three most critical
NIP-AC spec requirements testable as JVM unit tests with FakePeerSession:
1. Two-layer ICE candidate buffering (global + per-session)
- Global buffer: candidates arriving before any PeerConnection exists
- Per-session buffer: candidates arriving before setRemoteDescription
- Candidates buffered while ringing are preserved when accepting
2. Renegotiation glare handling (pubkey comparison tiebreaker)
- Higher pubkey wins, lower pubkey rolls back local offer
- Full glare scenario test with two PeerSessionManagers
3. Callee-to-callee mesh initiation (lower pubkey initiates)
CallController now delegates to PeerSessionManager via a PeerSession
interface, with WebRtcPeerSessionAdapter bridging to WebRtcCallSession.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Replace manual tag assembly in test helpers (makeOffer, makeAnswer, etc.)
with the existing build() methods from quartz event classes. This keeps
test tag structures in sync with production code automatically.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Profile pictures are displayed at small fixed sizes (18-100dp) but Coil's
disk cache stores full-size originals, causing expensive re-decode on every
memory cache miss. With 60 avatars on screen, this creates significant I/O.
Adds a ProfilePictureFetcher with a dedicated ThumbnailDiskCache that stores
pre-resized 256px JPEG thumbnails (~5-10KB each). On cache hit, reads a tiny
file instead of re-decoding the full original. The zoomable full-screen dialog
bypasses this cache and loads the original via the normal Coil pipeline.
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
Add 81 tests across two test suites covering the full NIP-AC spec:
- quartz/NipACStateMachineTest (31 tests): Protocol compliance test vectors
for event structure, tags, P2P/group flows, ICE serialization, staleness,
renegotiation glare rules, and multi-device support
- commons/CallManagerTest (50 tests): State machine integration tests using
real NostrSignerInternal with actual crypto, covering:
* Full call lifecycle (Idle → Offering/IncomingCall → Connecting → Connected → Ended → Idle)
* Call rejection, busy auto-reject, hangup from any state
* Self-event filtering (ICE, hangup, answer-elsewhere)
* Mid-call renegotiation (voice ↔ video)
* Group calls (mesh discovery, partial disconnect, invite peer)
* Interface-level tests with real signing + gift wrapping pipeline
* Full end-to-end P2P flow with two CallManager instances
Also adds test vector tables to NIP-AC.md spec for other implementers.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Use EphemeralGiftWrapEvent (kind 21059) instead of GiftWrapEvent (kind
1059) for WebRTC call signaling. The ephemeral kind signals to relays
that these events are transient and should not be persisted, eliminating
the need for expiration tags on both inner signaling events and outer
wraps.
Changes:
- Remove expiration tags from all 6 call event types (25050-25055)
- Switch WebRtcCallFactory to produce EphemeralGiftWrapEvent wraps
- Update CallManager and CallController publishEvent types
- Update Account.publishCallSignaling signature
- Use CallManager.MAX_EVENT_AGE_SECONDS for staleness checks
- Update NIP-AC spec to document EphemeralGiftWrap usage
- Remove expiration-related tests
https://claude.ai/code/session_014kyBgZx7cNyeUXYWV25M4j
When a peer rejects a group call, CallManager updated its state but never
notified CallController to dispose the peer's PeerSession. This caused two
issues:
1. The rejected peer's PeerConnection lingered in HAVE_LOCAL_OFFER state
until the entire call ended (resource leak visible as "disposing 2 peer
sessions" when only 1 should exist).
2. In onPeerDisconnected, the allDisconnected check required ALL sessions
to be CLOSED. The stale session blocked this check, preventing automatic
hangup when ICE failed — meaning the remote peer never received a hangup
event and kept ringing.
Fix: invoke onPeerLeft when a peer rejects in Offering/Connecting/Connected
states, and make onPeerDisconnected treat sessions without a remote
description as inactive.
https://claude.ai/code/session_01UXaFKmHVPDyTzfHL8fkSw1
Four bugs fixed in group call signaling:
1. Self-event filtering: Own answer/ICE/hangup events echoed from relays
were processed as if from another peer, causing callees to attempt
callee-to-callee connections with themselves.
2. Lost peer discovery: When a callee received another callee's answer
while still ringing (IncomingCall state), the peer was silently
discarded. Now stored in discoveredCalleePeers and used for mesh
setup after accepting.
3. Duplicate onNewPeerInGroupCall: Both CallManager and CallController
triggered mesh setup for the same peer, causing duplicate log entries
and wasted work. Removed CallManager's redundant calls since
CallController handles this internally via onCallAnswerReceived.
4. No per-peer session cleanup: When a peer hung up but the call
continued, their WebRTC PeerSession was never disposed — it lingered
until ICE timeout. Added onPeerLeft callback and disposePeerSession
to cleanly close individual sessions and update video tracks.
Also added strategic debug logging for session lifecycle, peer
discovery, and disconnect handling.
https://claude.ai/code/session_01SWDsQJibYi1MMgvu55puSY
When a group member (e.g. Bob) rejects a call, the rejection event is
sent to all group members. The caller (Alice) correctly removes Bob,
but other callees (Charlie) were not removing Bob because the
IncomingCall handler in onCallRejected only handled self-rejection
for multi-device sync. Now it also removes the rejecting peer from
groupMembers, matching the behavior already present in onPeerHangup.
https://claude.ai/code/session_01PMwKCEU1fRDGNQ8WdX8CFW
Three issues addressed:
1. Make cleanup() exception-safe: wrap each WebRTC dispose call in
its own try-catch so that a failure in one (e.g. native crash
disposing a PeerConnection) does not skip releasing the camera,
audio mode, foreground service, or EGL context.
2. Upgrade the Idle safety net to call cleanup() instead of only
stopping ringing/notifications. If the Ended state is missed due
to StateFlow conflation, the Idle handler now performs full
resource cleanup (camera, WebRTC, audio mode, foreground service,
proximity wake lock). cleanup() is idempotent since all resources
are null-checked and nulled out.
3. Call cleanup() in AccountViewModel.onCleared() so that WebRTC
resources, camera, audio mode, and foreground service are released
when the ViewModel is destroyed (e.g. logout, activity recreation).
4. Clear processedEventIds when transitioning to Idle to prevent
unbounded memory growth across calls.
https://claude.ai/code/session_01GWRdrVAa29BsDkv8R7Z3Y9
Replaces the single-PeerConnection architecture with full mesh topology
where each participant maintains one PeerConnection per peer, as
specified by NIP-AC.
WebRtcCallSession: Refactored to a pure PeerConnection wrapper that
accepts a shared PeerConnectionFactory. No longer manages media sources,
tracks, or camera — those are now shared across all peer sessions.
CallController: Manages per-peer sessions via ConcurrentHashMap. Shared
resources (PeerConnectionFactory, EglBase, audio/video sources, camera)
are initialized once and reused. Each peer gets its own WebRtcCallSession
with per-peer ICE candidate routing. Supports callee-to-callee mesh
connections with pubkey-based tie-breaking to avoid ofer glare.
CallManager: New methods for per-peer offer/answer publishing
(publishOfferToPeer, publishAnswerToPeer, beginOffering). Forwards ALL
answers to CallController (not just the first). New callbacks
onNewPeerInGroupCall and onMidCallOfferReceived for mesh setup. Handles
mid-call offers (same call-id) when already in Connecting/Connected state.
AccountViewModel: Updated callback wiring to pass peer pubkey with
answer events and register new group call callbacks.
https://claude.ai/code/session_01J5fJx9YbSBx1BsBMctiAm8
Two bugs caused WebRTC group calls to get stuck in "Connecting":
1. CallController.onLocalIceCandidate() used currentPeerPubKey() which
returns only the first peer via firstOrNull(). ICE candidates were
gift-wrapped to only one peer; the others never received them.
Fixed by iterating over all currentPeerPubKeys().
2. CallManager.acceptCall() set Connecting.peerPubKeys to groupMembers
which includes the local user's own pubkey. This caused
currentPeerPubKey() to potentially return self, sending ICE
candidates to oneself instead of the caller.
Fixed by filtering out signer.pubKey from the peer set.
https://claude.ai/code/session_01J5fJx9YbSBx1BsBMctiAm8
In group calls (e.g. Alice calls Bob and Charlie), when Bob accepts the
call, a CallAnswerEvent is gift-wrapped to all group members including
Charlie. Charlie's CallManager, still in IncomingCall state, was treating
ANY CallAnswerEvent as "answered elsewhere" (intended for multi-device
scenarios) and ending the call prematurely.
The fix checks whether the answering/rejecting peer is actually the
current user (signer.pubKey) before treating it as an "answered/rejected
elsewhere" event. If it's a different group member, we simply ignore it
and keep ringing.
https://claude.ai/code/session_01EYzWB93PZRqw15QuQadCf4
Adds debug logging across CallManager, CallController, and WebRtcCallSession
to trace the full call lifecycle: SDP offer/answer creation, remote description
set success/failure, ICE candidate exchange, ICE connection state transitions,
and signaling state changes. The noOpSdpObserver is replaced with a logging
observer so setRemoteDescription success/failure is visible in logcat.
Filter logcat with: CallManager|CallController|WebRtcCallSession
https://claude.ai/code/session_01M3yyyRyu8KWJZ5PjNt4V2H
The group factory methods work correctly with any number of members,
including single-peer calls. Remove the if/else branches that
duplicated P2P vs group logic in acceptCall, rejectCall, hangup,
sendRenegotiation, and sendRenegotiationAnswer.
https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp