Commit Graph

11402 Commits

Author SHA1 Message Date
nrobi144 e79096f16b feat(desktop): add App Drawer with categorized screen launcher
Replace AddColumnDialog with full-screen App Drawer overlay (Cmd+K).
Screens organized by category (Social, Long-Form, Discovery, Identity,
Play) with instant search, keyboard navigation (arrows + Enter), and
open-column indicators in Deck mode. Works in both Single Pane and
Deck layout modes.

- Add AppDrawer.kt with ScreenCategory, LAUNCHABLE_SCREENS registry
- Add SinglePaneState for hoisted single-pane navigation
- Wire Cmd+K shortcut, redirect Cmd+T to drawer
- Add "More" button to SinglePaneLayout nav rail
- Delete AddColumnDialog.kt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:19:39 +03:00
Vitor Pamplona a525ebabd5 Merge pull request #2429 from vitorpamplona/claude/redesign-longform-header-ajshi
Enhance long-form article preview with metadata and topics
2026-04-16 21:14:39 -04:00
Claude 71537a8950 feat: redesign LongFormHeader with richer article card layout
- 16:9 cover image with rounded top corners
- Topic/hashtag chips row above title (up to 3)
- Bolder titleLarge headline, max 2 lines
- Gray summary with max 3 lines
- Author row with display name, time ago, and reading-time badge
2026-04-16 22:05:03 +00:00
Vitor Pamplona f0730ce4f7 Merge pull request #2427 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-16 17:17:52 -04:00
Crowdin Bot 55ef3e89f6 New Crowdin translations by GitHub Action 2026-04-16 21:17:27 +00:00
Vitor Pamplona 449c052cb9 Spotless Apply 2026-04-16 17:16:56 -04:00
Vitor Pamplona cb98961f87 Merge pull request #2425 from vitorpamplona/claude/fix-call-ringing-lifecycle-ukm5g
Refactor call session lifecycle to Activity-owned CallSession
2026-04-16 17:15:56 -04:00
Claude bc424acfcf fix: pass Activity context (not applicationContext) to CallSession
WebRTC's Camera2Session accesses WindowManager for device orientation.
WindowManager is a visual service that requires an Activity context —
applicationContext throws IllegalAccessException on Android 12+.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
2026-04-16 20:02:30 +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 89cf5e14d4 fix: move hangup signaling from fire-and-forget scope to CallForegroundService
The previous approach used a detached CoroutineScope in
CallActivity.onDestroy to publish hangup/reject events. This was
unreliable: the process could be killed before the coroutine completed,
leaving the remote peer's phone ringing.

Fix: CallForegroundService now owns hangup signaling via two paths:

1. onTaskRemoved() — fires when user swipes app from Recents. Uses
   runBlocking with 3-second timeout to synchronously publish hangup
   before the service is killed.

2. onDestroy() — fires when the service is stopped for any other
   reason. Same runBlocking pattern as a safety net.

Changes:
- AndroidManifest: stopWithTask="false" so onTaskRemoved fires instead
  of the service being silently killed.
- CallForegroundService: added onTaskRemoved(), onDestroy(), and
  publishHangupBlocking() helper.
- CallActivity.onDestroy: removed fire-and-forget CoroutineScope.
  Session resource cleanup (close()) still happens here; signaling
  hangup is delegated to the foreground service.
- CallSessionBridge.clear(): converted fire-and-forget hangup to
  runBlocking with 3-second timeout for account-switch safety.

The three-layer hangup guarantee is now:
1. CallForegroundService.onTaskRemoved/onDestroy — synchronous,
   reliable (runBlocking).
2. CallManager ringing watchdog (65s) — if nothing else fires.
3. Remote peer's own timeout — last resort.

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 8075579168 fix: address race conditions in CallSession close/callback lifecycle
Fixes found by audit:

1. Set `closed = true` at the top of close() so the init collectors
   (state, renegotiation, proximity) short-circuit when they see
   the flag. Prevents them from calling methods on disposed resources
   during the window between close() and lifecycleScope cancellation.

2. Null out all CallManager callbacks in onDestroy after close() so
   signaling events arriving between Activity destroy and recreate
   don't route to a dead CallSession.

3. Synchronize peerSessionMgr.disposeAll() + reassignment in close()
   to prevent concurrent collector reads on a half-disposed manager.

4. Reorder onDestroy: close() runs first (synchronous resource
   release), then callback nulling, then best-effort hangup signaling.

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
Vitor Pamplona 8466a0c0d5 fixes compilation errors 2026-04-16 09:15:23 -04:00
Vitor Pamplona fb9171fb9a spotless 2026-04-16 08:51:15 -04:00
Vitor Pamplona 42cd1cdf0c Merge pull request #2423 from vitorpamplona/claude/add-discovery-articles-screen-IRDoN
Add Articles feed screen with NIP-23 long-form content support
2026-04-16 08:50:35 -04:00
Vitor Pamplona fbbe93aa3b Merge pull request #2422 from vitorpamplona/claude/add-discovery-products-screen-1hsvy
Add Products feed screen with classified listings support
2026-04-16 08:50:16 -04:00
Vitor Pamplona 6e7849eae7 Merge pull request #2424 from vitorpamplona/claude/add-marmot-media-upload-d0nNK
Implement MIP-04 encrypted media support for Marmot groups
2026-04-16 08:50:06 -04:00
Vitor Pamplona 6eff64f589 Merge pull request #2421 from vitorpamplona/claude/replace-mls-groups-fab-waio7
Add Marmot group messages to notifications feed
2026-04-16 08:43:51 -04:00
davotoula f41c062be4 lightcompressor-enhanced patch upgrade, fix for gif2mp4 2026-04-16 14:33:47 +02:00
Claude 403ce57b79 feat: Add standalone Products screen with own filters and Around Me default
Extracts the Marketplace/Products functionality from the Discovery tab
into a dedicated standalone screen following the Pictures/Polls pattern.
The screen displays NIP-99 classifieds in a 2-column grid with its own
filter system, defaulting to Around Me (geolocation-based) filtering.

https://claude.ai/code/session_01PyrUZzSj7A3ZXucs7DTYbB
2026-04-16 03:28:16 +00:00
Claude 3d5366e608 feat: add standalone Articles screen for long-form content
Add a new dedicated Articles screen accessible from the left drawer that
displays long-form content (NIP-23, kind 30023) with its own filters,
data sources, and FAB. The screen follows the same pattern as Pictures,
Polls, and Longs screens. Default filter is set to "All Follows".

- New route: Route.Articles with navigation and drawer entry
- ArticlesFeedFilter using LongTextNoteEvent with own follow list
- ArticlesFilterAssembler/SubAssembler reusing makeLongFormFilter
- ArticlesScreen with DisappearingScaffold, top bar filter, and FAB
- ArticlesFeedLoaded rendering via ChannelCardCompose/LongFormHeader
- Account settings for defaultArticlesFollowList with persistence

https://claude.ai/code/session_01QnP527Zq3xrtcLDmdx3LNf
2026-04-16 03:24:01 +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
Claude e168b9dde2 feat: replace MLS Groups FAB with Create Group and show marmot messages in notifications
Replace the "MLS Groups" FAB button on MessagesScreen with a "Create
Group" action that navigates directly to the group creation screen
(Route.CreateMarmotGroup) instead of the group list page. This aligns
with the plan to merge all marmot groups into the Messages screen's
Known and New Requests tabs.

Add marmot group messages to the notification screen so they appear
alongside regular DMs. This includes:
- Including marmot group messages in NotificationFeedFilter.feed()
- Accepting marmot group notes in acceptableEvent() via inGatherers check
- Converting marmot group notes to MessageSetCard in CardFeedContentState
- Routing marmot group notification clicks to the group chat screen

https://claude.ai/code/session_012wbykgUYHNVdHVNbDDnMUt
2026-04-16 01:55:44 +00:00
Vitor Pamplona 3356059d50 Removes warnings 2026-04-15 21:32:00 -04:00
Vitor Pamplona e4e7d4816a Fixes desktop compilation 2026-04-15 21:31:40 -04:00
Vitor Pamplona 51992dcf73 Merge pull request #2419 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-15 21:27:55 -04:00
Crowdin Bot af030aa1e2 New Crowdin translations by GitHub Action 2026-04-16 00:43:30 +00:00
Vitor Pamplona 85d9b60843 Merge pull request #2418 from vitorpamplona/claude/fix-group-metadata-persistence-kyTVP
Add persistent storage for Marmot KeyPackages and group messages
2026-04-15 20:42:01 -04:00
Vitor Pamplona 22dc89db70 Merge pull request #2417 from vitorpamplona/claude/review-nip34-compliance-WGPXe
Add NIP-34 Git pull requests, status events, and repository state
2026-04-15 20:41:37 -04:00
Claude 6a0f3982e8 feat(quartz): expand NIP-34 git collaboration coverage
Fills out Quartz's NIP-34 implementation so clients can build and parse
every spec-defined event kind, not just repository announcements and issues.

Kind 30617 (Repository Announcement):
- Multi-value web/clone accessors and builders
- relays, maintainers, hashtags, earliestUniqueCommit, isPersonalFork
- New tag classes: RelaysTag, MaintainersTag, EucTag
- New build() overload covering every optional tag

Kind 30618 (Repository State): new GitRepositoryStateEvent with
refs/heads, refs/tags, HEAD, and a RefTag/HeadTag pair.

Kind 1617 (Patch): rewrite builder to use the eventTemplate DSL with
required a/r/p tags, optional commit/parent-commit/commit-pgp-sig/
committer/root/root-revision markers, and a reply() helper that adds
NIP-10 marked e-tags pointing at a prior patch.

Kind 1618/1619 (Pull Request + Update): new GitPullRequestEvent and
GitPullRequestUpdateEvent with c/clone/branch-name/merge-base tags and
NIP-22 E/P parent references for updates.

Kinds 1630/1631/1632/1633 (Status Open/Applied/Closed/Draft): new
GitStatusEvent base + four subclasses sharing a GitStatusBuilders DSL.
Applied status carries merge-commit, applied-as-commits, and q tags for
the specific patch events that were merged.

Kind 10317 (User Grasp List): new UserGraspListEvent replaceable list of
g tags (websocket URLs in preference order) for discovering grasp
hosting servers.

NIP-51 Kind 10017 (Git Authors List): now uses a dedicated GitAuthorTag
class with optional petname (NIP-02 follow-list 4th element) instead of
the mute-list UserTag, so round-tripping follow lists no longer drops
petnames.

All new event kinds are registered in EventFactory. Spotless-clean and
compiles via :quartz:compileKotlinJvm.

https://claude.ai/code/session_018Fz3AeEdGkeFoeHBKuMwbH
2026-04-16 00:14:08 +00:00
Claude 2265b0635d fix(marmot): discard legacy KeyPackage snapshots on upgrade
The diagnostic trace from the invitee shows the welcome flow working
end-to-end (gift wrap → seal → processMarmotWelcomeFlow) but finally
failing at

    MarmotInboundProcessor.processWelcome: NO matching KeyPackageBundle
    for eventId=41177359… — inviter referenced a KeyPackage we don't
    have private keys for

The eventId the welcome carries IS on relays — the inviter just
fetched it successfully in `fetchKeyPackageAndAddMember` — but on the
invitee's device the `eventIdToSlot` map is empty, so the lookup
misses. Root cause: the invitee's persisted KeyPackage snapshot is
in v1 format (from before the eventId→slot index existed).
`restoreFromStore` loaded v1 just fine, leaving bundles in place but
the eventId map empty. Then `Account.ensureMarmotKeyPackagePublished`
saw `hasActiveKeyPackages()` return true and *skipped republishing*
— so the stale kind:30443 that the inviter just fetched has no
matching mapping on the invitee.

Fix: `restoreFromStore` now refuses to load any snapshot whose
version is not the current `SNAPSHOT_VERSION` (2). On a v1 file it:

  1. Logs a warning.
  2. Deletes the on-disk snapshot via `store.delete()` so the next
     save writes fresh v2.
  3. Returns without touching `activeBundles` / `pendingRotations` /
     `eventIdToSlot`.

This means `hasActiveKeyPackages()` will return false right after
restoreAll finishes, `ensureMarmotKeyPackagePublished` will generate
+ publish a fresh bundle (which `recordPublishedEventId` indexes by
its real Nostr event id), the new kind:30443 replaces the old one on
relays via d-tag addressability, and the next inviter's
`fetchKeyPackageAndAddMember` will find the new event + the invitee
will have the matching private keys to process its Welcome.

The same defensive wipe also fires for a v2 snapshot that somehow
carries bundles with an empty eventId map (corner case, crash during
upgrade, etc.).

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 23:47:40 +00:00
Claude 6c723147b0 fix(marmot): route seal-wrapped welcomes to the welcome handler
User reported the only logs Charlie's device produced when Bob added
him to a group were:

    GiftWrapEventHandler.processNewGiftWrap: id=40e2073c… recipient=9a960f0b…
    GiftWrapEventHandler.processNewGiftWrap: unwrapped innerKind=13 innerId=ffda2949…

That kind=13 is the smoking gun. The previous code in
`processNewGiftWrap` checked `isWelcomeEvent(innerGift)` directly on
the gift-wrap-unwrapped result, expecting kind:444. But Marmot
`WelcomeGiftWrap.wrapForRecipient` actually produces a three-layer
envelope:

    GiftWrap (1059) → SealedRumor (13) → WelcomeEvent (444)

So `event.unwrapOrNull(signer)` returns the kind:13 Seal, not the
WelcomeEvent. The previous check fell through to `eventProcessor.
consumeEvent`, which dispatched to `SealedRumorEventHandler`, which
unsealed correctly to a kind:444 — and then handed it back to
`eventProcessor.consumeEvent` again. There's no LocalCache event
handler registered for kind:444, so the WelcomeEvent was silently
dropped on every invitee.

Fix: extract the welcome-handling code from `GiftWrapEventHandler.
processMarmotWelcome` into a top-level `processMarmotWelcomeFlow`
helper, and call it from BOTH:

  - `GiftWrapEventHandler.processNewGiftWrap` (kept for the unlikely
    case where Marmot ever publishes a Welcome with no Seal layer)
  - `SealedRumorEventHandler.processNewSealedRumor` (the actual
    production path) — after unsealing, if the inner rumor is a
    WelcomeEvent it now goes straight to the shared flow instead of
    being passed to the unrouted event processor.

Also added matching `MarmotDbg` logs in `processNewSealedRumor` so
the seal-side trace is visible: id, unsealed inner kind/id, and the
"detected Marmot WelcomeEvent inside seal" routing line.

Compile-checked: `:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 23:13:49 +00: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 99506e3db0 fix(marmot): land welcomes + group msgs on the invitee's relays
The user reported that NIP-17 DMs from Bob to Charlie arrive instantly,
but Marmot Welcomes and group messages produce nothing on Charlie's
device. Three independent bugs combined to make Marmot invisible on
the receiving side.

### 1. kind:445 EOSE filter never invalidated when joining a group

`MarmotGroupEventsEoseManager` only re-polled its filter set when
`account.homeRelays.flow` emitted. So an invitee that just processed
a Welcome got the new group added to `MarmotSubscriptionManager` and
to `marmotGroupList`, but the per-`h`-tag kind:445 subscription was
never actually sent to the relays — the EOSE manager kept using the
filter snapshot from app start. The same was true for restored groups
on every restart.

Fix: add a second job in `MarmotGroupEventsEoseManager.newSub` that
collects `account.marmotGroupList.groupListChanges` and calls
`invalidateFilters()`. `Account.init`'s restore loop now also fires
`marmotGroupList.notifyGroupChanged(groupId)` for every restored
group so the EOSE manager picks them up at startup.

### 2. Group metadata had no relays — both sides used different sets

`MarmotGroupData.relays` was always empty. `marmotGroupRelays` and
`MarmotGroupEventsEoseManager` both fall back to "the local user's
own relays" when the metadata has none — so Bob published kind:445 to
Bob's outbox while Charlie subscribed on Charlie's home relays. The
two only overlap by accident.

Fix: `AccountViewModel.updateMarmotGroupMetadata` now stamps the
inviter's outbox relays into the GCE proposal (merged with any
existing relays). The Welcome carries the GroupContext extensions, so
the invitee learns the same canonical relay set at join time.
`CreateGroupScreen` now always issues the metadata commit (even when
the user left the name blank) so this stamping happens for every new
group.

### 3. Welcome gift wrap delivery used a different relay path than NIP-17

`addMarmotGroupMember` had its own ad-hoc relay fallback chain
(`outboxRelays + cache.getOrCreateUser(...).dmInboxRelays()`) and
explicitly avoided `computeRelayListToBroadcast`. The comment claimed
that path returned an empty set in some cases, but the actual code in
`computeRelayListToBroadcast(GiftWrapEvent)` falls back through
kind:10050 → NIP-65 read → relay hints — exactly the same chain that
NIP-17 DMs use, which empirically reach the recipient.

Fix: union `computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)`
with the previous outbox + dmInboxRelays set so the welcome takes the
same route NIP-17 DMs do, plus belt-and-braces fallbacks. Added a log
line so we can tell from logcat exactly how many relays the welcome
went out on.

Compile-checked: `:quartz:compileKotlinJvm`, `:commons:compileKotlinJvm`,
`:amethyst:compilePlayDebugKotlin` — BUILD SUCCESSFUL.
2026-04-15 22:45:50 +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
Vitor Pamplona 88b3d8a221 Merge pull request #2416 from davotoula/hls-improvements
keep screen on + smooth per-file upload progress bar
2026-04-15 18:07:12 -04: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
davotoula f28f5730e8 keep screen on
smooth per-file upload progress bar
2026-04-15 23:42:20 +02: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 21b83b5777 style(marmot): spotless reformat in Account.kt restore loop 2026-04-15 20:54:13 +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
Vitor Pamplona feec8ecf9c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-04-15 16:21:22 -04:00
Vitor Pamplona 373d88efcf Merge pull request #2415 from vitorpamplona/claude/fix-group-sync-issue-LKQ0C
Fix Marmot group invite delivery to use recipient's inbox relays
2026-04-15 16:14:23 -04:00
Claude ba06f9a92b fix(marmot): deliver group welcome to the invitee's actual relays
Two related bugs in the Marmot "new group → add member" path were
preventing the invited user from ever discovering the group, even
though the commit applied cleanly on the creator's side.

1. fetchKeyPackageAndAddMember() searched for the invitee's KeyPackage
   on the *creator's* own outbox relays. KeyPackages are published by
   the invitee to their own outbox (see publishMarmotKeyPackage), so
   this only worked when the two happened to overlap. Union the
   invitee's NIP-65 outbox with ours before issuing fetchFirst().

2. addMarmotGroupMember() routed the Welcome gift wrap through
   computeRelayListToBroadcast(). Its GiftWrap branch first tries the
   recipient's NIP-17 kind:10050 and falls back to
   computeRelayListForLinkedUser(), which reaches for
   inboxRelays()/relay hints and can legitimately return an empty set
   when the recipient has no cached NIP-17/NIP-65 data. client.publish
   then silently dropped the welcome, which is why the invitee saw
   nothing while the creator's local state looked healthy.

   Mirror sendNip04PrivateMessage's delivery strategy instead: publish
   to our own outbox (so we retain a copy and the invitee may find it
   via a shared relay) unioned with User.dmInboxRelays(), which already
   ladders NIP-17 kind:10050 → NIP-65 read relays — the exact set
   AccountGiftWrapsEoseManager listens on. A warning log covers the
   pathological case where both sides are empty.

Note: spotlessApply/compile could not be run in the sandbox (Android
Gradle plugin unreachable from this environment). Please run
./gradlew spotlessApply locally before merging.
2026-04-15 20:04:20 +00:00