- Removed redundant deleteOnExit() in test helper — explicit delete() after each test already handles cleanup; the shutdown hook was unnecessary overhead
Add missing test vector entries for scenarios that have tests but were
not listed in the spec:
- E10-E18: single callee detection, P2P flow sequence, group p-tags
for all event types, group member union, ICE serialization round-trip
- S22-S29: fresh events, wrong call-id handling, ICE forwarding,
peer-left callback, reset, call-type preservation, caller cancel
- R7: renegotiation preserves call-id
- B1-B12: new ICE Candidate Buffering section covering both global
and per-session buffer layers
- W1-W18: new Gift Wrap Round-Trip section covering NIP-44
encrypt/decrypt for all event types including group calls
- I6, I10: renegotiation answer, renumbered full P2P flow
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Replace implementation-specific class names (EphemeralGiftWrapEvent,
SealedRumorEvent) with protocol-neutral language so the spec reads as
a standalone NIP independent of any particular codebase.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Add encrypted local storage for MLS group state so groups survive app
restarts, plus key lifecycle management:
- MlsGroupState: TLS-encoded serializable snapshot of complete group state
(group context, ratchet tree, epoch secrets, private keys, transcript hash)
- MlsGroupStateStore: interface for encrypted per-group storage (platform
implementations provide EncryptedSharedPreferences / encrypted file)
- MlsGroupManager: high-level coordinator managing group lifecycle, epoch
secret retention window (N-1 for late messages), state persistence,
and key export
- RetainedEpochSecrets: bounded window of past epoch decryption secrets
for out-of-order message handling
- MlsGroup.saveState()/restore(): roundtrip serialization via TLS codec
- MlsGroup.proposeSigningKeyRotation(): Update proposal with fresh Ed25519
signing key and X25519 encryption key for forward secrecy
- KeyPackageRotationManager: tracks consumed KeyPackages after Welcome
processing, handles slot rotation and proactive age-based rotation
- Tests: 16 tests covering state serialization roundtrips, manager lifecycle,
multi-group independence, and signing key rotation persistence
https://claude.ai/code/session_01MuRS2zSVm6A36HNFwG7M5p
- 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
Changes viewedPollResultNoteIds from Set<String> to Map<String, Long> where
the value is the expiration timestamp. Uses the poll's endsAt date if set and
in the future, otherwise now + 24 hours. Expired entries are pruned on each
new insertion. Serialized as JSON instead of StringSet.
https://claude.ai/code/session_01EkUYT4giQPUvbAJZ54o1se
- 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
Resolve conflict in ZoomableContentView.kt: keep viewModelScope
from this branch (prevents cancellation on dialog dismiss) and
toast notification from main.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace profilepic:// string scheme with a ProfilePictureUrl data class.
Coil routes to the fetcher by type via Fetcher.Factory<ProfilePictureUrl>
instead of parsing a URI scheme string on every load. This avoids string
concatenation at the call site and string parsing in the fetcher/keyer.
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
Extract the ICE candidate buffering, answer routing, and renegotiation
glare logic from CallController into a platform-independent
PeerSessionManager in commons/. This makes the three most critical
NIP-AC spec requirements testable as JVM unit tests with FakePeerSession:
1. Two-layer ICE candidate buffering (global + per-session)
- Global buffer: candidates arriving before any PeerConnection exists
- Per-session buffer: candidates arriving before setRemoteDescription
- Candidates buffered while ringing are preserved when accepting
2. Renegotiation glare handling (pubkey comparison tiebreaker)
- Higher pubkey wins, lower pubkey rolls back local offer
- Full glare scenario test with two PeerSessionManagers
3. Callee-to-callee mesh initiation (lower pubkey initiates)
CallController now delegates to PeerSessionManager via a PeerSession
interface, with WebRtcPeerSessionAdapter bridging to WebRtcCallSession.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
On cache miss, instead of reading the entire downloaded image into a
byte array (which could be 50MB+ for a malicious profile picture),
the fetcher now passes the NetworkFetcher result straight through to
Coil's normal streaming decoder pipeline — zero overhead vs current.
Thumbnail generation runs in background from Coil's disk cache file
using file-based BitmapFactory.decodeFile(), which is seekable
(supports two-pass bounds+decode) and never buffers the full file.
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
- Atomic writes: write to temp file then rename, so readers never see
a partially written JPEG
- Deduplication: ConcurrentHashMap tracks in-flight URLs so multiple
composables requesting the same profile picture don't trigger
duplicate decode+save work
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
Verify the full encrypt/decrypt pipeline for all 6 NIP-AC signaling event
types through Ephemeral Gift Wraps (kind 21059):
sign inner event → NIP-44 encrypt → gift wrap → unwrap → verify
Tests cover:
- Each event kind round-trips (offer, answer, ICE, hangup, reject, renegotiate)
- Third parties cannot decrypt wraps addressed to others
- Group call per-peer wraps are only decryptable by intended recipient
- "Sign once, wrap per recipient" produces identical inner event IDs
- SDP and ICE candidate special characters survive JSON+NIP-44 round-trip
- Ephemeral wrap keys are unique per wrap and differ from sender
- Inner event signatures are verifiable after unwrapping
- Full P2P call flow (all 7 signaling steps) through gift wraps
Uses real secp256k1 keys and NIP-44 encryption — no mocks.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
On cache miss, the fetcher now returns the decoded bitmap immediately for
display while saving the thumbnail to disk in a background coroutine. This
means the first load is just as fast as the normal Coil pipeline — no
blocking on resize + JPEG compress + disk write. The thumbnail is ready
for the next load.
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
Replace manual tag assembly in test helpers (makeOffer, makeAnswer, etc.)
with the existing build() methods from quartz event classes. This keeps
test tag structures in sync with production code automatically.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Profile pictures are displayed at small fixed sizes (18-100dp) but Coil's
disk cache stores full-size originals, causing expensive re-decode on every
memory cache miss. With 60 avatars on screen, this creates significant I/O.
Adds a ProfilePictureFetcher with a dedicated ThumbnailDiskCache that stores
pre-resized 256px JPEG thumbnails (~5-10KB each). On cache hit, reads a tiny
file instead of re-decoding the full original. The zoomable full-screen dialog
bypasses this cache and loads the original via the normal Coil pipeline.
https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
Adds a "View results" link below poll voting options. Once a user clicks it,
the poll results are shown and the user can no longer vote on that poll.
The viewed state is persisted in account settings via SharedPreferences.
https://claude.ai/code/session_01EkUYT4giQPUvbAJZ54o1se
Display a "Downloading video…" toast when the user taps share on a
remote video, so they know the download is in progress before the
share sheet appears. Uses the existing `downloading_video_for_sharing`
string resource that was already translated in multiple locales.
https://claude.ai/code/session_01B7HLXqbnicfj3eg1Wjgz6Z
- Remove unnecessary local alias; use accountViewModel.viewModelScope
directly at call sites for clarity about scope ownership
- Add deleteOnExit() to test temp files so they're cleaned up even
if assertions fail
https://claude.ai/code/session_01Euj5mXfjneNCx9m49YTp92
Make getImageExtension, getVideoExtension, and getMediaExtension
internal for testing. Add tests covering all supported image formats
(JPEG, PNG, GIF, WebP) and video formats (WebM, AVI, MP4, MOV),
plus edge cases (unknown formats, empty files, short headers).
https://claude.ai/code/session_01Euj5mXfjneNCx9m49YTp92
The share media dialog used `rememberCoroutineScope()` which shadowed
the outer `accountViewModel.viewModelScope`. When onDismiss() was called
immediately after launching the share coroutine, the dialog-scoped
coroutine was cancelled, causing CancellationException to be caught
as a generic error and showing "Unable to share image" toast.
Fix: use viewModelScope for share operations so they survive dialog
dismissal, and re-throw CancellationException in shareImageFile.
https://claude.ai/code/session_01Euj5mXfjneNCx9m49YTp92
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
Adds tabbed navigation to the polls screen, separating polls into
"Open" (active/no end date) and "Closed" (ended) tabs. Uses the same
tab pattern as HomeScreen with HorizontalPager and SecondaryTabRow.
https://claude.ai/code/session_0168P3u8aMmn4HnSBDXR7yv7
Previously the broadcast tracker always waited TIMEOUT_SECONDS + 1 (11s) before
disappearing, even when all relays had already responded. Now it only auto-dismisses
after 3 seconds once all relays have responded, and stays visible while broadcasts
are still in progress.
https://claude.ai/code/session_017KzfovYfAWCgrwMy8RWLb9
The routeEditDraftTo() function was missing cases for PollEvent and
ZapPollEvent, causing poll drafts to fall through to the else->null
branch and preventing navigation to the poll editor.
https://claude.ai/code/session_01WedCekkgCwNVCq2ybSTvqE
The Card wrapping open polls in the notification feed was using the
default Material3 containerColor (surfaceContainerHighest), which is
too bright in dark mode where the app uses pure black backgrounds.
Explicitly set the container color to surface to match the theme.
https://claude.ai/code/session_012ErcSBhXugYCRAwHRXrFr7
Enables non-members to join a group without a Welcome message by using
an external commit with HPKE key encapsulation.
HPKE extensions (Hpke.kt):
- deriveKeyPair(): DHKEM(X25519) key derivation from seed
- setupBaseSExport(): HPKE sender with export-only context
- setupBaseRExport(): HPKE receiver with export-only context
- keyScheduleFull(): Full key schedule returning exporter_secret
- HpkeExportContext: Export secrets via labeled expand
MlsGroup joiner side:
- externalJoin(groupInfoBytes, identity): Static method for joining
via external commit. Performs HPKE encapsulation to external_pub,
derives init_secret, adds self to tree, creates Commit with
ExternalInit proposal and UpdatePath.
MlsGroup member side:
- externalPub(): Returns the group's HPKE public key for external joins
- groupInfo(): Returns GroupInfo with ratchet_tree + external_pub extensions
- deriveExternalInitSecret(): Derives init_secret from ExternalInit kem_output
- processCommit handles ExternalInit proposals by overriding init_secret
RatchetTree fix:
- setLeaf() now expands _leafCount when setting a leaf beyond current size,
fixing external joins where the new member extends the tree
Test: testExternalJoin verifies Alice creates group, Zara joins via external
commit, Alice processes the commit, both at epoch 1 with 2 members.
All 121 MLS tests pass (41 interop + 80 unit), 0 failures.
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3
Addresses critical security gaps identified in the RFC 9420 audit:
1. Epoch and group ID verification in decrypt (RFC 9420 Section 6.1):
- PrivateMessage.epoch must match current group epoch
- PrivateMessage.groupId must match current group ID
- Rejects messages from wrong epoch/group immediately
2. Remove proposal sender authorization (RFC 9420 Section 12.1.2):
- Cannot remove yourself via Remove (use SelfRemove)
- Target leaf index must be in range and non-blank
- Committer is implicitly authorized for inline proposals
3. KeyPackage lifetime validation (RFC 9420 Section 10.1):
- Checks notBefore/notAfter against current time on Add proposals
- Rejects expired or not-yet-valid KeyPackages
4. Unified proposal application in commit():
- commit() now uses applyProposal() for all validation
- Same authorization checks apply to both commit() and processCommit()
- addedMembers tracked before apply for Welcome generation
All 120 MLS tests pass (41 interop + 79 unit), 0 failures.
https://claude.ai/code/session_01NocQDWj2Y92FugjfgazzL3