Commit Graph

433 Commits

Author SHA1 Message Date
nrobi144 250bb5a1ad feat(multi-account): display names, middle-truncated npub, npub-only account fix
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>
2026-04-29 09:35:33 +03:00
nrobi144 aa33b7d271 feat(multi-account): extract SignerType, AccountInfo, AccountStorage to commons
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>
2026-04-24 07:29:52 +03:00
Vitor Pamplona ce6741ce92 Merge pull request #2450 from vitorpamplona/claude/add-pdf-preview-DIUJE
Add PDF viewing support with preview cards and full-page viewer
2026-04-19 13:25:47 -04:00
Claude ecdbc80fc1 feat: preview PDF links inline with first-page thumbnail and full pager
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.
2026-04-18 23:06:49 +00:00
Claude 85fcf50df9 feat: warn and offer cleanup for deleted items in pin and bookmark lists
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.
2026-04-18 22:58:54 +00:00
Claude 7e93f82577 fix(audio): switch ringtone from Ringtone to MediaPlayer + idempotent start
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
2026-04-16 19:51:25 +00:00
davotoula e4d17afcc5 fix: use _sessionEvents.tryEmit instead of recursive self-call in emitSessionEvent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:56:46 +00:00
davotoula 3e0d79a091 spotless: fix formatting violations in call lifecycle files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 18:56:46 +00:00
Claude 29c5c26af7 fix: restore onDestroy hangup, prevent event drops, fix deadlock risk
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
2026-04-16 18:56:46 +00:00
Claude ac6d8eb417 refactor: replace CallManager mutable callbacks with SharedFlow
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
2026-04-16 18:56:45 +00:00
Claude 980515c0cc fix: add closed guards to WebRTC callbacks, connecting watchdog, account-switch safety
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
2026-04-16 18:56:45 +00:00
Claude 3cddda6ef9 fix: CallActivity owns the entire call session lifecycle
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
2026-04-16 18:56:45 +00:00
Claude 5f5d576f1e feat: implement MIP-04 encrypted media upload for Marmot groups
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
2026-04-16 02:08:45 +00:00
Vitor Pamplona 3356059d50 Removes warnings 2026-04-15 21:32:00 -04:00
Claude 1c9585e5e4 fix(marmot): look up KeyPackageBundle by Nostr event id + add MarmotDbg logs
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.
2026-04-15 23:04:58 +00:00
Claude d5ec7fcaf3 fix(marmot): persist KeyPackage bundles + auto-publish at startup
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.
2026-04-15 22:18:45 +00:00
Claude a7960f9484 fix(marmot): don't mark own messages unread + pad empty group list
- `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.
2026-04-15 21:54:57 +00:00
Claude e3ed630430 feat(marmot): notify invitees + Known/New Requests split
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.
2026-04-15 21:23:50 +00:00
Claude c234832860 style(call): spotless reformat in CallManagerTest
Pre-existing formatting violation surfaced by `./gradlew spotlessApply`.
Unrelated to the Marmot persistence fix in this branch.
2026-04-15 20:57:06 +00:00
Claude 092bac6262 fix(marmot): persist group metadata + decrypted messages across restarts
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.
2026-04-15 20:38:54 +00:00
Claude 421b31f84f docs(call): add ARCHITECTURE.md summarizing the WebRTC call pipeline
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
2026-04-15 19:49:50 +00:00
Claude 822718e687 fix(call): stop ringing on sibling devices without disturbing the one that answered
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
2026-04-15 19:35:21 +00:00
Claude 7f2d44df7e fix(marmot): add diagnostic logs to group persistence path + use explicit AndroidKeyStore provider
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
2026-04-15 18:24:58 +00:00
Claude 3a5c118d35 fix: end call when the last connected peer hangs up
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
2026-04-15 16:30:35 +00:00
Claude f670724cd8 fix: drop unresponsive group-call peers on callee side after 30 s
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.
2026-04-15 14:36:02 +00:00
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