- Remove consumed flag (stale on re-open, double-fire is harmless)
- Fix keyboard nav for WORKSPACES tab (was broken, only worked for screens)
- Reset selectedIndex on tab switch
- Fix delete active workspace → reload columns for new active workspace
- Extract DeckColumnType.param() extension to eliminate 3x duplication
- Simplify moveSelection to handle all modes (tabs + unified search)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add two-tab App Drawer (Screens/Workspaces), workspace cards with CRUD,
editor dialog with icon picker and column configuration, unified search
across screens and workspaces, layout mode auto-switching, and
Cmd+Shift+S to save current layout as workspace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add workspace presets for different usage modes. Each workspace stores
layout mode + column configuration. Switching destroys current layout
and rebuilds from saved config (no background state).
- Add Workspace data model with WorkspaceColumn
- Add WorkspaceManager with save/load/switch/add/delete
- Add WorkspaceIcons registry for Material icon resolution
- Add WorkspaceBar to AppDrawer footer with workspace chips
- Add DeckState.loadFromWorkspace() for column rebuilding
- Add workspaces persistence to DesktopPreferences
- Default workspace: "Social" (Home + Notifications + DMs)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add PinnedNavBarState to manage user-customizable sidebar items.
Replace hardcoded navItems in SinglePaneLayout with pinnedScreens
from state, persisted as CSV to DesktopPreferences. Right-click
context menu on App Drawer items to pin/unpin from sidebar.
- Add PinnedNavBarState with pin/unpin/move/save/load
- Add pinnedNavItems to DesktopPreferences (CSV persistence)
- Update SinglePaneLayout to use pinnedScreens from state
- Update AppDrawer with isPinned indicator and context menu
- Remove NavItem data class and hardcoded navItems list
- HomeFeed and Settings are always pinned (non-removable)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
P1 fixes (must-fix):
- AppRun: VLC path corrected to usr/lib/app/linux/vlc (jpackage actual
path), add VLC_PLUGIN_PATH env var for codec discovery
- linuxdeploy: use APPIMAGE_EXTRACT_AND_RUN=1 env var to bypass FUSE
on CI runners instead of fragile pre-extract + symlink approach
- Prerelease classifier: inverted to positive-match stable format
(^v[0-9]+\.[0-9]+\.[0-9]+$) across desktop, Android, and composite
action — eliminates regex drift between allowlists
- assert-stable-release: now accepts inputs (tag, is_prerelease,
is_draft) so workflow_dispatch on bump workflows also validates
P2 fixes (should-fix):
- .gitignore: add linuxdeploy-*.AppImage and extracted dirs
- RPM version: use replace("-", "~") instead of substringBefore("-")
for correct RPM prerelease ordering (1.08.0~rc1 < 1.08.0)
- linux-portable → linux alias moved into collect_assets() in
scripts/asset-name.sh (single source of truth)
- Android cache key: add gradle/libs.versions.toml to hashFiles
- r0adkll/sign-android-release: SHA-pinned to 349ebdef (v1)
P3 fixes (nice-to-have):
- LINUXDEPLOY_OUTPUT_VERSION env var replaced with
APPIMAGE_EXTRACT_AND_RUN (misleading comment + redundant var)
- nick-fields/retry timeout: 40m → 15m (surface real hangs)
- generate_release_notes: true only on Android job (avoid
last-writer-wins race across 6 concurrent uploaders)
- apt-get install rpm: tightened guard to matrix.family == 'linux'
(linux-portable leg doesn't need rpm tooling)
- 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
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
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
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
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
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
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
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
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
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
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
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
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
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