The conversion pipeline is overwhelmingly CPU/GPU bound (Movie decode,
GL rendering, MediaCodec encode) and can run for several seconds on a
large GIF. Running it on Dispatchers.IO occupies a thread from the
large IO pool with no kernel wait, which can starve legitimate IO
coroutines when multiple uploads run in parallel.
Switching to Dispatchers.Default caps concurrent conversions to the
CPU count, which is also desirable given the hardware encoder
contention that multiple simultaneous MediaCodec instances would
cause.
convertInternal remains a plain (non-suspending) function, so EGL
thread-affinity is still preserved for the lifetime of the call.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers the bounds-checking fixes from the previous commit with 13
pure-JVM unit tests (no Android dependencies, no Robolectric):
- Image Descriptor (0x2C) packed-byte offset and length precheck
- skipSubBlocks clamp against oversized block lengths
- Local and Global Color Table skipping
- Delay normalization (0 and 1 centisecond → 100 ms)
- Multi-frame parsing with variable delays
- Non-GCE extension blocks (Application Extension) skipped safely
- Truncated inputs do not crash
parseGifFrameDelays is exposed as `internal` with
@VisibleForTesting(otherwise = PRIVATE) so production callers still
see it as private.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address issues found in Kotlin code review:
HIGH
- Rethrow CancellationException in convert() to preserve structured
concurrency; prior broad catch silently swallowed cancellation.
- Cap drainEncoder's post-EOS loop at 500 iterations to prevent an
unresponsive hardware encoder from hanging the IO thread forever.
MEDIUM
- Read GIF with a 20 MB size cap via bounded buffer to avoid OOM on
malformed or adversarial inputs.
- Check glCompileShader / glLinkProgram status and throw with
glGetShaderInfoLog / glGetProgramInfoLog on failure, instead of
silently producing blank frames on driver errors.
- Verify numConfigs[0] > 0 after eglChooseConfig and use requireNotNull
on configs[0] with a clear message (avoids NPE from !!).
- Fix Image Descriptor (0x2C) parsing: precheck pos + 10 <= bytes.size
and read the packed byte at the correct offset; bounds-check after
Local Color Table skip.
- Use uris.hasVideo() instead of uris.first().media.isVideo() for the
privacy-toggle visibility so mixed-media selections (image + video)
hide the toggle correctly.
LOW
- skipSubBlocks now clamps pos with minOf(pos + blockSize, bytes.size)
to preserve the invariant that pos is always a valid index.
- Document the function-level @Suppress("deprecation") on
convertInternal (android.graphics.Movie has no modern replacement).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The GIF converter already produces a well-compressed H.264 MP4, so
re-compressing via VideoCompressionHelper is wasteful and can inflate
file size. Hide the quality slider in the upload UI when GIF-to-MP4
is enabled and skip the video compression step in MediaCompressor.
https://claude.ai/code/session_018sKCM3VNWtfPMZSYVWWD4w
When a GIF is selected for upload, a new "Convert GIF to MP4" switch
appears in the upload settings dialog. This converts animated GIFs to
MP4 video using Android's Movie decoder and MediaCodec/MediaMuxer
encoder, resulting in smaller file sizes and better playback
compatibility. The converted video can optionally be further compressed
using the existing video compression pipeline.
https://claude.ai/code/session_018sKCM3VNWtfPMZSYVWWD4w
Add three new test files for the Marmot MLS implementation:
- MlsGroupLifecycleTest: End-to-end lifecycle tests covering Welcome
processing, cross-member encrypt/decrypt, multi-member groups, commit
processing, external joins, PSK proposals, and ReInit proposals.
- MlsGroupEdgeCaseTest: Security boundary tests for wrong epoch rejection,
corrupted ciphertext detection, invalid KeyPackage rejection, out-of-range
leaf indices, empty/large message handling, and add/remove/add cycles.
- MlsConformanceTest: Cross-implementation comparison tests verifying
KeyPackage structure, GroupInfo signatures, Welcome message format,
HPKE seal/open, SignWithLabel/VerifyWithLabel, LeafNode signatures,
deterministic key schedule, and commit structure conformance.
Fix GroupInfo signature bug (RFC 9420 Section 12.4.3.1):
- buildWelcome() and groupInfo() were signing only groupContext.toTlsBytes()
but verifySignature() checked against encodeTbs() which includes
GroupContext + extensions + confirmationTag + signer. Now both methods
build an unsigned GroupInfo first and sign its full TBS encoding.
Enhance MlsGroupManager KDoc with usage examples, responsibility breakdown,
and cross-implementation notes.
8 tests are @Ignore'd documenting a known bug: processCommit() does not
derive the same epoch secrets as commit(), causing cross-member AEAD
failures after epoch transitions.
https://claude.ai/code/session_018f67fqNReg3dEXcDimYLY1
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
Replace the plain text input with the existing UserSuggestionState +
ShowUserSuggestionList pattern used throughout the app (NewGroupDM,
edit post, etc.).
The flow is now:
1. Type a name, npub, or NIP-05 in the search field
2. Debounced search queries local cache + relays + NIP-05 DNS
3. ShowUserSuggestionList displays matching users with profile
pictures and display names
4. Tap a user to select them — shows their profile in a selected
user row with picture, display name, truncated pubkey, and a
Clear button
5. Tap "Add" to fetch their KeyPackage and complete the invite
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
AddMember flow (was TODO, now functional):
- Account.fetchKeyPackageAndAddMember(): queries outbox relays for the
target user's KeyPackageEvent (kind:30443) using client.fetchFirst(),
decodes the base64 KeyPackage content, and calls addMarmotGroupMember()
which publishes the commit then sends the Welcome gift wrap
- AddMemberScreen now calls the real implementation and navigates back
on success
Member display names:
- MemberRow now uses LoadUser to resolve Nostr pubkeys to User profiles
from LocalCache, showing bestDisplayName() instead of raw hex
- UserPicture composable shows profile pictures next to member names
- "(you)" indicator for the current user's entry
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Replace AlertDialog-based CreateGroupDialog and AddMemberDialog with
proper full-screen routes that slide up from the bottom, following the
existing app patterns (ChannelMetadataEdit, NewGroupDM, etc.).
New screens:
- CreateGroupScreen: uses CreatingTopBar with close/create buttons,
replaces CreateGroupDialog
- AddMemberScreen: uses ActionTopBar with close/add buttons,
replaces AddMemberDialog
New routes:
- Route.CreateMarmotGroup → composableFromBottom
- Route.MarmotGroupAddMember(nostrGroupId) → composableFromBottomArgs
Updated call sites:
- MarmotGroupListScreen: FAB navigates to Route.CreateMarmotGroup
- MarmotGroupChatScreen: add-member icon navigates to
Route.MarmotGroupAddMember
- MarmotGroupInfoScreen: add-member icon navigates to
Route.MarmotGroupAddMember
Deleted old files:
- CreateGroupDialog.kt
- AddMemberDialog.kt
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Based on MIP spec review, add missing UI screens:
MarmotManager additions (commons):
- memberPubkeys(): extract Nostr pubkeys from MLS ratchet tree
leaf nodes via Credential.Basic identity
- memberCount(): get group member count
- groupEpoch(): expose current MLS epoch
- GroupMemberInfo data class for leaf index + pubkey pairs
Account & AccountViewModel:
- leaveMarmotGroup(): publish SelfRemove proposal and clean up
- marmotGroupMembers(): expose member list to UI
MarmotGroupInfoScreen (new):
- Group metadata header (name, ID, epoch, member count)
- Member list with leaf indices, clickable to view profiles
- "Leave Group" with confirmation dialog
- "Add Member" access from group info
Navigation:
- Route.MarmotGroupInfo added and wired into nav graph
- Chat screen title now clickable to navigate to group info
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Add complete UI layer for Marmot Protocol MLS group messaging:
State models (commons/commonMain/):
- MarmotGroupChatroom: tracks decrypted inner messages per group
- MarmotGroupList: manages all group chatrooms with change notifications
- MarmotGroupFeedFilter: feed filter for group message feeds
- MarmotGroupFeedViewModel: ViewModel exposing group messages
Account integration:
- Add marmotGroupList to IAccount interface and implementations
- GroupEventHandler indexes inner events into MarmotGroupList
- AccountViewModel gains sendMarmotGroupMessage, createMarmotGroup,
publishMarmotKeyPackage helper methods
Android screens (amethyst/):
- MarmotGroupListScreen: lists all groups with last message preview
- MarmotGroupChatScreen: group conversation with message composer
- MarmotGroupChatView: message feed + simple text composer
- CreateGroupDialog: create new MLS group with display name
- AddMemberDialog: add member by npub/hex (KeyPackage fetch TBD)
Navigation & integration:
- Route.MarmotGroupList and Route.MarmotGroupChat routes
- Wired into AppNavigation nav graph
- "MLS Groups" button added to ChannelFabColumn on Messages screen
- Marmot group newest messages appear in ChatroomListKnownFeedFilter
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Adds ALT constant, FIXED_D_TAG constant, and AltTag import to
PaymentTargetsEvent. The alt tag ("Payment targets") is now included
in both create() and updatePaymentTargets() methods.
https://claude.ai/code/session_013SQtN37Qemiu3vxjXZDbQj
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
Add four protocol-layer components for Marmot group messaging:
1. MarmotSubscriptionManager: Coordinates relay subscriptions for
GroupEvent (kind:445), GiftWrap (kind:1059), and KeyPackage
(kind:30443) events. Tracks per-group since timestamps for
pagination and syncs with MlsGroupManager state.
2. MarmotInboundProcessor: Processes incoming GroupEvents through
outer ChaCha20-Poly1305 decryption → MLS decrypt → inner event
extraction. Handles commit detection, conflict resolution via
CommitOrdering, and Welcome processing with KeyPackage rotation.
3. MarmotOutboundProcessor: Builds outbound GroupEvents by MLS
encrypting inner Nostr events, applying ChaCha20-Poly1305 outer
layer, and signing with ephemeral keys for sender privacy.
4. MarmotWelcomeSender: Wraps MLS Welcome messages through the
NIP-59 gift wrap pipeline for delivery to new group members.
All code in quartz/commonMain (protocol layer). Includes 25 tests
covering roundtrip encryption, subscription management, error
handling, ephemeral key usage, and Welcome wrapping.
https://claude.ai/code/session_01XC5umkmsFB7XQ7xdrouArt
- 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