Commit Graph

408 Commits

Author SHA1 Message Date
Claude 1290e9151c feat(settings): add toggle to disable voice and video calls
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
2026-04-15 01:21:37 +00:00
Claude b9d6cefbeb feat(call): per-peer 30s invite timeout and per-peer status in video grid
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
2026-04-14 23:23:42 +00:00
Claude d087e1ac13 feat(call): full-mesh setup for mid-call invites
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
2026-04-14 22:54:07 +00:00
Vitor Pamplona 887e0bf302 Improves stability and permission checks for voice calls when the app is in the background. 2026-04-14 10:19:47 -04:00
nrobi144 c564d4532b test: add commons/commonTest for shared Tor logic
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>
2026-04-13 07:24:31 +03:00
nrobi144 77f9905b69 feat(tor): add TorServiceStatus, ITorManager, ITorSettingsPersistence to commons
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>
2026-04-13 07:24:31 +03:00
nrobi144 84c3189cea refactor(tor): extract TorRelaySettings, TorRelayEvaluation to commons/commonMain
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>
2026-04-13 07:24:31 +03:00
nrobi144 1d9c1eb9b6 refactor(tor): extract TorSettings, TorType, TorPresetType to commons/commonMain
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>
2026-04-13 07:24:31 +03:00
Claude d851168a46 fix: WebRTC call resource leaks, thread safety, and error handling
- 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
2026-04-10 23:55:16 +00:00
Claude 865c71e0e2 fix: WebRTC call bugs, hardening, camera switch, and network resilience
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
2026-04-10 22:37:43 +00:00
Claude cd573a583c fix: WebRTC call bugs and add Call Settings screen
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
2026-04-10 17:18:25 +00:00
Claude ed5515a7c5 fix: critical MLS security fixes for Marmot protocol (RFC 9420 compliance)
Phase 1 - Critical/High cryptographic fixes:
- Fix sender data nonce reuse: derive key/nonce from ciphertext sample per §6.3.1
- Add PrivateContentAAD binding (group_id, epoch, content_type) per §6.3.2
- Add SenderDataAAD binding per §6.3.1
- Fix KDFLabel encoding: use TLS fixed-width (putOpaque1/putOpaque4) not QUIC VarInt
- Add reuse_guard (4-byte random XOR into nonce) per §6.3.1
- Fix parent hash verification: capture sibling hashes before UpdatePath applied
- Fix Welcome confirmation tag: use HMAC instead of ExpandWithLabel
- Make confirmation tag mandatory in processCommit (was nullable)
- Fix off-by-one in path secret derivation during processCommit
- Fix TokenEncryption: extract 32-byte x-only pubkey from 33-byte compressed key
- Fix SELF_REMOVE proposal type: move from 0x0008 to 0xF001 (private-use range)

Phase 2 - Protocol compliance and thread safety:
- Validate KeyPackage ciphersuite is supported (0x0001 only)
- Synchronize KeyPackageRotationManager read operations with mutex
- Synchronize EpochCommitTracker with lock object
- Synchronize processedEventIds with lock in MarmotInboundProcessor
- Synchronize MlsGroupManager encrypt/decrypt with mutex
- Synchronize MarmotSubscriptionManager read methods
- Require unresolved proposal references to error per §12.4.2

Phase 3 - Hardening:
- Add path traversal validation in AndroidMlsGroupStateStore (hex-only groupId)
- Bind nostrGroupId as AAD in outer ChaCha20-Poly1305 encryption
- Track failed events in dedup set to prevent CPU exhaustion
- Validate nostrGroupId in processWelcome against Welcome event's own h tag
- Validate sender leaf index bounds during decryption

Phase 4 - Low priority fixes:
- Fix externalJoin: compute confirmedTranscriptHash and interimTranscriptHash
- Add snapshot methods for non-suspend filter access

https://claude.ai/code/session_017SjKXS4Vpu4xRg9zHTgpmC
2026-04-10 03:23:15 +00:00
Claude b861a71c2c feat: replace hex pubkeys with user picture + name in chess cards
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
2026-04-08 20:53:56 +00:00
Vitor Pamplona 086178471b Merge pull request #2168 from vitorpamplona/claude/review-call-screens-6zzLt
Add thread-safe state management to CallManager
2026-04-08 08:43:11 -04:00
Claude 894042f056 style: spotlessApply formatting fix for MarmotManager
https://claude.ai/code/session_018gVkmmYgMFtBH7G31pCk9N
2026-04-07 23:06:16 +00:00
Claude 11412b8873 feat: add remove member, edit group metadata, and formatting fixes
- Add removeMember() to MarmotManager and AccountViewModel
- Add updateGroupMetadata() to MarmotManager for MIP-01 name/description
- Add MarmotGroupData.toExtension() for encoding metadata as MLS extension
- Add proposeGroupContextExtensions() to MlsGroup
- Apply spotlessApply formatting fixes
- SecretTree: prune consumed generations below current minimum

https://claude.ai/code/session_018gVkmmYgMFtBH7G31pCk9N
2026-04-07 23:05:44 +00:00
Claude cd86660cae fix: call resource leaks and race conditions from out-of-order signaling
- 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
2026-04-07 23:05:06 +00:00
Claude 4526beb4be fix: MEDIUM/LOW bugs - validation, unread tracking, TLS bounds, KeyPackage checks
- H17: Add unread count tracking to MarmotGroupChatroom
- M8: Add MAX_OPAQUE_SIZE bounds check to TLS deserialization
- M13: Add version/ciphersuite validation on KeyPackage deserialization
- M24: Add logging before deleting corrupted group state in restoreAll
- L1: Add size limit to sentKeys map in MlsGroup
- Additional UI fixes: leave group cleanup, error handling improvements
- Fix MarmotSubscriptionManagerTest for updated API

https://claude.ai/code/session_018gVkmmYgMFtBH7G31pCk9N
2026-04-07 23:04:09 +00:00
Claude 8c8ab4bb2c fix: HIGH/MEDIUM Marmot bugs - thread safety, dedup, UI error handling
- H8: Add Mutex to MarmotSubscriptionManager for thread safety
- H10: Add retained exporter secret to MlsGroupState/RetainedEpochSecrets
  for outer decryption of out-of-order messages
- H14: Add error handling to CreateGroupScreen and MarmotGroupChatView
- H15: Add removeGroup() to MarmotGroupList, clean up after leave
- M3: Use SecureRandom for nostrGroupId generation
- M5: Add event deduplication to MarmotInboundProcessor
- M6: Add KeyPackage credential validation in MarmotManager.addMember()
- M7: Validate nostrGroupId matches WelcomeEvent h-tag

https://claude.ai/code/session_018gVkmmYgMFtBH7G31pCk9N
2026-04-07 23:03:27 +00:00
Claude 29d1610d1a fix: critical Marmot/MLS bugs - crashes, crypto, protocol compliance
- C1: Fix leaveGroup() crash (used group state after deletion)
- C2: Fix X25519.bigIntegerToBytes ArrayIndexOutOfBoundsException
- C3: Add all-zeros DH check to prevent small-subgroup attacks
- C4: Fix path secret derivation off-by-one (RFC 9420 Section 7.4)
- C5: Use constant-time comparison for confirmation/membership tags
- C6: Stage signing keys in proposeSigningKeyRotation, promote on commit
- C7: Make commit conflict tracker per-(group,epoch) not just per-epoch
- C8: Fix tree deserialization leafCount for trimmed trees
- H8: Add Mutex-based thread safety to MlsGroupManager
- H9: Fix extractPrivateKeyBytes little-endian padding
- H12: Blank direct path on addLeaf (RFC 9420 Section 7.7)
- M14: Track actual leaf index from addLeaf for Welcome generation

https://claude.ai/code/session_018gVkmmYgMFtBH7G31pCk9N
2026-04-07 23:02:09 +00:00
Claude 5731275362 fix: prevent old call events from triggering ringing after app restart
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
2026-04-06 20:15:26 +00:00
Claude 6486a0a995 fix: stop ringing immediately when caller cancels WebRTC call
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
2026-04-06 19:29:36 +00:00
Claude 66e6b0b726 fix: track KeyPackage published status and subscribe to own key packages
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
2026-04-06 14:43:06 +00:00
Vitor Pamplona 1d12ae36e5 Removing more warnings 2026-04-06 09:27:37 -04:00
Vitor Pamplona 5727d50fce Fixes test cases 2026-04-06 09:22:21 -04:00
Claude 3e49e650c2 feat: read MIP-01 group metadata from MLS GroupContext extensions
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
2026-04-05 22:10:33 +00:00
Claude ddca55fae6 feat: add Marmot group info screen, member list, and leave group
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
2026-04-05 21:09:25 +00:00
Claude 7ed036bbaa feat: Phase 7 — Marmot group chat UI & ViewModel layer
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
2026-04-05 20:38:23 +00:00
Claude 8b56b3b3bf feat: Phase 6 — Marmot account integration & event routing
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
2026-04-05 19:54:56 +00:00
Vitor Pamplona 78010cfd69 Merge pull request #2143 from vitorpamplona/claude/webrtc-nip-ac-tests-f1l1g
Add comprehensive test suite for NIP-AC call state machine
2026-04-05 12:29:30 -04:00
Claude 86d66673d8 style: rename test methods from snake_case to camelCase
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
2026-04-05 16:12:54 +00:00
Claude 6fbbcbbf3b refactor: clean up PeerSessionManager and CallController integration
- 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
2026-04-05 15:38:28 +00:00
Claude 361ef6798e refactor: simplify thumbnail cache API and remove unused code
- 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
2026-04-05 15:36:34 +00:00
Claude 0df4c5e19c refactor: use typed ProfilePictureUrl class instead of URI scheme string
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
2026-04-05 15:22:38 +00:00
Claude e9f1fcbd2a refactor: extract PeerSessionManager for testable ICE buffering and glare handling
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
2026-04-05 15:17:29 +00:00
Claude fd42c7b725 refactor: use quartz event builders in CallManager test helpers
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
2026-04-05 13:54:03 +00:00
Claude 9b356d31ba feat: add thumbnail disk cache for profile pictures
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
2026-04-05 03:06:52 +00:00
Claude 9854209843 test: add comprehensive NIP-AC WebRTC call state machine tests
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
2026-04-04 15:26:20 +00:00
Claude 799ebd692f feat: switch NIP-AC from GiftWrap with expiration to EphemeralGiftWrap
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
2026-04-03 22:11:19 +00:00
Claude 303e68331a fix: clean up rejected peer's PeerSession to prevent stale sessions blocking hangup
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
2026-04-03 19:33:30 +00:00
Vitor Pamplona 35a1a20bde Merge pull request #2110 from vitorpamplona/claude/debug-webrtc-calls-KqELh
Improve group call peer management and signaling robustness
2026-04-03 15:01:22 -04:00
Claude bde17099a4 fix: WebRTC group call bugs — self-event filtering, mesh setup, per-peer cleanup
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
2026-04-03 19:00:04 +00:00
Claude d08d101b7d fix: remove rejecting peer from group members in IncomingCall state
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
2026-04-03 18:47:04 +00:00
Claude fe13b77e22 fix: harden call cleanup to guarantee nothing is left running
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
2026-04-03 18:16:09 +00:00
Claude fcecb5241c feat: implement full-mesh WebRTC group calls with per-peer PeerConnections
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
2026-04-03 16:54:00 +00:00
Claude 0488245200 fix: send ICE candidates to all peers and exclude self from peer set
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
2026-04-03 15:36:50 +00:00
Claude 4a529a304c fix: prevent group call participants from disconnecting when another member answers/rejects
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
2026-04-03 14:37:29 +00:00
Claude 76477373d9 debug: add comprehensive WebRTC call flow logging to diagnose "stuck on Connecting"
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
2026-04-03 13:30:50 +00:00
Vitor Pamplona 316290ac32 Merge pull request #2092 from vitorpamplona/claude/add-hashtag-limit-filter-N8sMZ
Add maximum hashtag limit security filter
2026-04-03 08:55:13 -04:00
Claude 1bc94f5a29 refactor: remove P2P vs group branching in CallManager
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
2026-04-03 12:35:53 +00:00