When the user disables video, the SurfaceViewRenderer kept showing
the last captured frame (frozen). Now the local video PiP is only
rendered when isVideoEnabled is true — when camera is off, the PiP
disappears entirely revealing the black background.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
The d-tag is the article's identity in Nostr addressable events.
Changing it on edit would create a new article instead of updating
the existing one. The slug field is now disabled when isEditing is true.
https://claude.ai/code/session_011JuQbdA12WdPxC7aGvUBqJ
Four bugs fixed:
1. _isVideoEnabled defaulted to true — voice calls showed the
camera icon as "active" even though no video was being sent.
Changed default to false; only set true when video track is
actually created (video call initiation or acceptance).
2. cleanup() reset _isVideoEnabled to true — next call started
with wrong state. Changed to false.
3. stopCamera() disposed the capturer but toggleVideo re-enable
path only called setVideoEnabled(true) without restarting the
camera. No frames were captured. Now re-enable path calls
startCamera() to create a fresh capturer.
4. startCamera() made public on WebRtcCallSession with guard
against double-start (returns early if cameraCapturer != null).
State flow is now:
- Voice call: _isVideoEnabled=false, icon shows "camera off"
- Tap camera: creates track + starts camera, _isVideoEnabled=true
- Tap camera again: stops camera + disables track, =false
- Tap camera again: restarts camera + enables track, =true
- Video call: _isVideoEnabled=true from start, camera running
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Redesigns the LongFormPostScreen to match the reference UI with:
- Visual banner image placeholder with dashed border (clickable to upload)
- Borderless large title field with placeholder styling
- Labeled Summary section
- URL Slug field mapped to the d-tag for user-defined article identifiers
- Tag/hashtag chip input with add button and removable chips
- Tags are included as hashtags in the published event
Adds tags and slug state to LongFormPostViewModel, wires them into
createTemplate() for event publishing, and loads them from drafts.
https://claude.ai/code/session_011JuQbdA12WdPxC7aGvUBqJ
When a call starts as voice-only, no video track exists. Tapping
the camera toggle was calling setVideoEnabled(true) on a null
track, doing nothing.
Now toggleVideo() checks if a video track exists. If not, it
creates one on-demand (addVideoTrack + startCamera), upgrading
the voice call to include video. If toggling off, it stops the
camera to save battery.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
WebRTC's Camera2Session uses WindowManager to get device orientation
for frame rotation. WindowManager requires a visual (Activity)
context — using Application context throws IllegalAccessException.
Changed initCallController to pass the Activity context directly
instead of context.applicationContext.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Video calls were failing with SecurityException because only
RECORD_AUDIO was requested at runtime. CAMERA permission is also
required for Camera2Capturer to open the front-facing camera.
Changes:
- CallPermissions now uses RequestMultiplePermissions instead of
RequestPermission, with isVideo parameter to request CAMERA
alongside RECORD_AUDIO for video calls
- ChatroomScreen passes isVideo=true for the video call button
- CallScreen passes isVideo based on call type when accepting
incoming video calls
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
With Unified Plan SDP semantics (which we set in RTCConfiguration),
onAddStream is deprecated and doesn't fire. Remote media tracks
arrive via onAddTrack(RtpReceiver, MediaStream[]) instead.
Changed WebRtcCallSession:
- onRemoteStream callback renamed to onRemoteVideoTrack(VideoTrack)
- onAddTrack now extracts VideoTrack from RtpReceiver.track()
- onAddStream kept as fallback for Plan B compatibility
This was why video never appeared after connecting — the remote
video track was never captured and _remoteVideoTrack stayed null.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
When initiateCall() runs async (Dispatchers.IO), navigation to
the ActiveCall screen happens before CallManager state changes
from Idle to Offering. The CallScreen sees Idle and immediately
calls onCallEnded() → popBack(), causing the screen to flash
and disappear.
Fix: CallScreen now waits 500ms on Idle before popping back.
If the state transitions to Offering within that window (normal
case), the screen stays. If it's still Idle after 500ms (no call
in progress), it pops back as before.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Major additions to the NIP specification:
- Gift wrap expiration: documented that outer gift wraps MUST
include expiration tags so relays can garbage-collect signaling
- Event structures: added full JSON examples with all required
fields (pubkey, id, sig, expiration, alt tags)
- ICE candidate buffering: documented that candidates MUST be
buffered while ringing and NOT cleared on accept
- Staleness and deduplication: documented 20s staleness check
and event ID dedup as spam prevention requirements
- NAT traversal: added TURN server guidance for same-WiFi and
restrictive network fallback (~20% of cases)
- Audio routing: documented MODE_IN_COMMUNICATION, earpiece/
speaker/Bluetooth SCO cycling, ringback tone, ringtone
- Platform integration: foreground service (microphone type),
proximity wake lock, PiP mode, runtime permissions
- Error handling: SDP creation failures, ICE_CONNECTION_FAILED,
session creation errors
- JSON escaping: noted that ICE candidate SDP strings MUST be
properly escaped in the content JSON
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
All GiftWrapEvent.create() calls in WebRtcCallFactory now pass
expirationDelta=20 so relays can garbage-collect the wraps after
the signaling data is no longer relevant. The actual expiration
timestamp is createdAt + 20s + 2 days (to account for the
randomized createdAt in NIP-59 gift wraps).
Previously only the inner events had expiration tags — the outer
gift wraps persisted indefinitely on relays.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
- Add case-insensitive role matching in ParticipantTag (accepts both
"host" and "Host" from other clients per NIP-53 spec)
- Add PARTICIPANT role to ROLE enum per NIP-53 spec
- Add MeetingSpaceEvent (30312), MeetingRoomEvent (30313), and
MeetingRoomPresenceEvent (10312) consumption in LocalCache
- Create rendering composables for MeetingSpaceEvent and MeetingRoomEvent
with status flags (OPEN/PRIVATE/CLOSED for spaces, LIVE/PLANNED/ENDED
for rooms) and participant display
- Add meeting kinds to all discovery feed relay filters (global, authors,
hashtag, geohash, community)
- Add meeting kinds to ChannelCardCompose and LiveActivityCard thumb
rendering
- Add meeting kinds to KindRegistry search aliases
https://claude.ai/code/session_01CJ6xfY9gRA1c5r4T1SrUuv
#3 JSON escaping: CallIceCandidateEvent.serializeCandidate() now
escapes backslashes and quotes in SDP/sdpMid strings before
interpolation. Prevents malformed JSON when SDP contains special
characters.
#4 Thread safety: remoteDescriptionSet changed from Boolean to
AtomicBoolean to prevent race conditions between WebRTC callback
threads and main thread accessing the flag simultaneously.
#5 Async cleanup: hangup() no longer calls cleanup() synchronously
after launching the coroutine. Cleanup is now triggered by the
CallManager state collector when Ended state is reached, avoiding
resource disposal during active WebRTC callbacks.
#7 EglBase leak: createWebRtcSession() now wraps initialize() and
createPeerConnection() in try-catch that disposes the session on
failure, preventing EGL context leaks from failed initialization.
#10 SDP failure: createOffer/createAnswer onCreateFailure() now
calls onError() to surface SDP creation failures to the user
instead of silently logging them.
#11 Notification age: EventNotificationConsumer now uses
CallOfferEvent.EXPIRATION_SECONDS (20s) instead of hardcoded 30s
to align with the actual event expiration.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Audio route cycling: the speaker button now cycles through
Earpiece → Bluetooth → Speaker (or Earpiece → Speaker if no
Bluetooth device is connected).
CallAudioManager:
- Detects Bluetooth SCO/A2DP/BLE headset devices via AudioManager
- Manages Bluetooth SCO connection (startBluetoothSco/stopBluetoothSco)
- BroadcastReceiver monitors SCO state changes — auto-falls back
to earpiece if Bluetooth disconnects during call
- Exposes audioRoute and isBluetoothAvailable as StateFlows
CallController:
- Replaced toggleSpeaker() with cycleAudioRoute() which delegates
to CallAudioManager.cycleAudioRoute()
- Exposes audioRoute and isBluetoothAvailable flows to UI
CallScreen:
- Audio route button shows context-aware icon:
VolumeOff (earpiece), VolumeUp (speaker), BluetoothAudio
- Blue tint for Bluetooth, cyan for speaker, white for earpiece
Manifest: BLUETOOTH_CONNECT permission added.
Strings: call_bluetooth string resource added.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Add OpenRelay (metered.ca) free TURN servers to IceServerConfig
as defaults alongside STUN. This fixes calls between devices on
the same WiFi network where hairpin NAT isn't supported — the
~20% case where STUN-only fails.
TURN servers on ports 80, 443, and 443/TCP to bypass corporate
firewalls. Uses openrelayproject public credentials (20GB/month
free tier).
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Localized strings:
- Added 28 string resources for all call UI text in strings.xml
- Replaced hardcoded strings in CallScreen, CallForegroundService,
NotificationUtils, and RenderRoomTopBar with stringRes() calls
- Technical error messages in CallController remain English (no
composable context available)
Picture-in-Picture:
- EnterPipOnLeave composable observes lifecycle and enters PiP
mode when app goes to background during active call
- MainActivity manifest updated with supportsPictureInPicture and
smallestScreenSize configChange
- 9:16 aspect ratio for portrait PiP window
ICE candidate fix (from logcat diagnosis):
- acceptIncomingCall() no longer clears pendingIceCandidates at
start — candidates buffered while ringing were being wiped
before flushPendingIceCandidates() could apply them
- This was causing "Flushing 0 buffered ICE candidates" on callee
StrictMode fix:
- initiateCall/acceptIncomingCall now run WebRTC session creation
on Dispatchers.IO to avoid DiskReadViolation from native lib
loading on main thread
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Critical fixes:
- CallController.initiateCall/acceptIncomingCall now validate
WebRTC session creation with try/catch and null check before
proceeding. Errors shown to user via errorMessage flow.
- AccountViewModel.initCallController() is now @Synchronized to
prevent double initialization. EventProcessor callManager wired
before creating CallController. Callbacks set before exposing
controller to avoid timing races.
- Removed duplicate state (currentCallId/currentPeerPubKey) from
CallController — uses callManager.currentCallId()/
currentPeerPubKey() as single source of truth.
- Removed dead network callback code (was only logging).
Code quality:
- CallScreen null-safety patterns now use remember{} for fallback
StateFlow instances instead of creating new ones per recomposition.
- Removed dead rememberCallPermissionLauncher from CallPermissions.
- CallController cleaned up from 363 to 304 lines.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
1. Move UI toggle state out of CallState.Connected:
- Removed isAudioMuted/isVideoEnabled/isSpeakerOn from domain
CallState. These are UI concerns, not call state.
- Added StateFlows for toggles in CallController (isAudioMuted,
isVideoEnabled, isSpeakerOn) with proper toggle methods.
- CallScreen reads toggle state from CallController flows.
- Removed toggleAudioMute/toggleVideo/toggleSpeaker from
CallManager (no longer manages UI state).
2. Move ICE candidate parsing to quartz layer:
- Added candidateSdp(), sdpMid(), sdpMLineIndex() parsers and
serializeCandidate() to CallIceCandidateEvent in quartz.
- Removed companion object with parseIceCandidate/
serializeIceCandidate from CallController.
- CallController now uses quartz-layer parsing directly.
- Updated IceCandidateSerializationTest accordingly.
3. Add helper methods to CallManager:
- currentCallId() and currentPeerPubKey() extract from any
active state, reducing repeated when expressions.
4. Fix empty callId bug in ChatroomScreen:
- Navigation to ActiveCall now uses callManager.currentCallId()
instead of hardcoded empty string.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Temporary diagnostic logging to identify why calls hang at
Connecting. Logs added to:
- CallManager.onSignalingEvent: event kind, age, state
- CallController: answer received, ICE candidates (buffered vs
direct), flush count, local ICE generation
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
1. Audio mode: AudioManager switches to MODE_IN_COMMUNICATION on
Connecting, restores to MODE_NORMAL on call end. Audio routes
through earpiece by default during calls.
2. Notification actions: Accept/Reject buttons added to incoming
call notification for quick action without opening the app.
3. Background handling: Full-screen intent brings app to foreground.
CallManager state persists in ViewModel so the call screen
picks up current state when navigated to via notification.
4. Call screen icons: Replaced text labels with Material icons -
Mic/MicOff for mute, Videocam/VideocamOff for camera,
VolumeUp/VolumeOff for speaker. Color-coded toggle states.
5. Video rendering: SurfaceViewRenderer composable wraps WebRTC
video tracks. Remote video displays full-screen, local video
as a small PIP in the top-right corner. Falls back to avatar
display when no video tracks are active.
6. Ringback tone: ToneGenerator plays TONE_SUP_RINGTONE during
Offering state (caller hears ringing). Stops on connect/end.
7. Error handling: CallController catches WebRTC session creation
failures, SDP errors, and ICE failures. Errors exposed via
errorMessage StateFlow and displayed as Snackbar on call screen.
8. Network handling: ConnectivityManager.NetworkCallback registered
during active calls to detect WiFi/cellular changes.
Unregistered on call end.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
phoneCall type requires MANAGE_OWN_CALLS on SDK 34+, which needs
the app to be a default dialer. microphone type only requires
RECORD_AUDIO which is already granted via runtime permission.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
- Change EXPIRATION_SECONDS from 300 to 20 for all call signaling
events (offer, answer, ICE, hangup, reject, renegotiate)
- Change MAX_EVENT_AGE_SECONDS from 30 to 20 to match
- Update NIP-AC doc accordingly
- Fix SecurityException crash on SDK 36: phoneCall foreground service
type requires MANAGE_OWN_CALLS permission. Switch to microphone
type which only needs RECORD_AUDIO (already granted).
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Fix call connection stuck at "Connecting":
- Inner events from unwrapped GiftWraps are dispatched through
EventProcessor.consumeEvent(), not LocalCache.newEventBundles.
The previous approach using newEventBundles observer never saw
the inner call events because they're created internally during
GiftWrap processing, not from relay arrivals.
- Restored callManager routing in EventProcessor.consumeEvent()
and wired it via account.newNotesPreProcessor.callManager in
AccountViewModel.initCallController()
Video call button:
- Added Videocam icon button in DM chat header (RenderRoomTopBar)
next to the voice call button
- onVideoCallClick initiates a VIDEO type call with camera capturer
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Ringtone & vibration:
- CallAudioManager plays default ringtone (looping) and vibrates
in a 1s-on/1s-off pattern when IncomingCall state is active
- Automatically stops on accept/reject/hangup via cleanup()
Proximity sensor:
- PROXIMITY_SCREEN_OFF_WAKE_LOCK acquired during Connecting and
Connected states, turns off screen when held to ear
- Released on call end
Camera for video calls:
- Camera2Enumerator finds front-facing camera
- CameraVideoCapturer attached to VideoSource at 640x480@30fps
- Camera stopped and disposed on call end
Full-screen intent on lock screen:
- Incoming call notification uses setFullScreenIntent() to show
the call screen even when device is locked
- USE_FULL_SCREEN_INTENT permission added to manifest
- Notification has VISIBILITY_PUBLIC for lock screen display
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
- Deduplicate signaling events via processedEventIds set in
CallManager to prevent duplicate processing from multiple relays
- BackHandler on CallScreen calls hangup() before navigating back
- KeepScreenOn composable adds FLAG_KEEP_SCREEN_ON during calls
and clears it on dispose
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Runtime permissions:
- RECORD_AUDIO permission prompted before initiating or accepting calls
- rememberCallWithPermission() composable wraps call actions with
Android runtime permission flow via accompanist
Audio routing:
- CallController.setAudioMuted/setVideoEnabled/setSpeakerOn now
control WebRtcCallSession and Android AudioManager
- Mute/speaker/video toggles in ConnectedCallUI wired to actual
hardware controls
Incoming call notifications:
- EventNotificationConsumer handles CallOfferEvent with follow-gate
and 30s staleness check
- New CALL_CHANNEL notification channel (IMPORTANCE_HIGH)
- NotificationUtils.sendCallNotification shows caller name with
auto-dismiss after 60 seconds
- Call notification cancelled on cleanup (accept/reject/hangup)
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Two fixes for call connectivity:
1. ICE candidate buffering: Candidates arriving before the WebRTC
session exists (callee ringing) or before remote description is
set (caller waiting for answer) are now queued in
pendingIceCandidates and flushed once setRemoteDescription is
called. This was the root cause of calls getting stuck at
"Connecting" — ICE candidates were silently dropped.
2. Stale event filter: All signaling events older than 30 seconds
are discarded in CallManager.onSignalingEvent() to prevent
old cached events from triggering phantom calls.
Also: removed cleanup() from WebRTC onDisconnected callback to
avoid double-cleanup race with the CallManager state observer.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
CallManager now auto-resets from Ended to Idle after 2 seconds via
transitionToEnded(). Previously reset() was called from a
LaunchedEffect in CallScreen which could be cancelled when the
composable was disposed via popBack(), leaving the state stuck at
Ended and silently dropping subsequent incoming calls.
Also: CallController now observes CallManager state and auto-cleans
up the WebRTC session when a call ends (handles peer hangup case).
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
- Add ObserveIncomingCalls composable in AppNavigation that watches
callManager.state and navigates to ActiveCall screen when an
IncomingCall is detected
- Wire the accept button in CallScreen to call
callController.acceptIncomingCall(sdpOffer), which sets the remote
SDP, creates a WebRTC answer, and sends it back gift-wrapped
- Pass CallController to CallScreen for accept functionality
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Remove callManager injection from EventProcessor — let the existing
gift wrap pipeline consume and index call events normally. Instead,
observe new notes from LocalCache.live.newEventBundles in
AccountViewModel and route call signaling events to CallManager
from there. This keeps the EventProcessor clean and follows the
existing pattern for UI-layer event observation.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Add CallOfferEvent, CallAnswerEvent, CallIceCandidateEvent,
CallHangupEvent, CallRejectEvent, and CallRenegotiateEvent to
LocalCache.justConsumeInnerInner() so they are properly consumed
and indexed when received from relays.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Connects all call infrastructure so calls flow through the system:
- EventProcessor routes unwrapped call events (offer/answer/ICE/
hangup/reject) from gift wraps to CallManager
- CallController orchestrates WebRTC session lifecycle: creates
PeerConnection, generates SDP offers/answers, exchanges ICE
candidates via gift-wrapped events, and manages foreground service
- AccountViewModel initializes CallManager + CallController and
wires answer/ICE callbacks between them
- Account.publishCallSignaling() publishes gift-wrapped events
- DM chat top bar gets a call button (1-on-1 rooms only) that
initiates a voice call and navigates to ActiveCall screen
- ActiveCall route registered in AppNavigation with CallScreen
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Specifies the signaling protocol for P2P voice/video calls:
- 6 event kinds (25050-25055) for offer/answer/ICE/hangup/reject/renegotiate
- NIP-59 gift wrap delivery (no seal layer)
- Follow-gated spam prevention
- Short expiration for ephemeral signaling data
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Implements P2P calling over Nostr relays using WebRTC for media
transport and NIP-59 Gift Wraps for encrypted signaling. No custom
server required — only public STUN servers for NAT traversal.
Protocol layer (quartz/nip100WebRtcCalls):
- 6 new event kinds (25050-25055): offer, answer, ICE candidate,
hangup, reject, renegotiate
- WebRtcCallFactory for creating and gift-wrapping signaling events
- CallIdTag and CallTypeTag for event metadata
- Events registered in EventFactory
Call state machine (commons/call):
- CallState sealed interface with full lifecycle states
- CallManager orchestrating signaling and state transitions
- Follow-gate spam prevention: only followed users can ring,
non-follows are silently ignored
Android WebRTC integration (amethyst/service/call):
- WebRtcCallSession wrapping Google WebRTC PeerConnection
- CallForegroundService for keeping calls alive in background
- IceServerConfig with default public STUN servers
- User-configurable TURN server support
Android UI (amethyst/ui/call):
- CallScreen with offering, connecting, connected, and ended states
- IncomingCallUI with accept/reject buttons
- ConnectedCallUI with mute, video toggle, speaker, and timer
- Call button added to 1-on-1 DM chat header
- ActiveCall route added to navigation
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Remove callManager injection from EventProcessor — let the existing
gift wrap pipeline consume and index call events normally. Instead,
observe new notes from LocalCache.live.newEventBundles in
AccountViewModel and route call signaling events to CallManager
from there. This keeps the EventProcessor clean and follows the
existing pattern for UI-layer event observation.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
Add CallOfferEvent, CallAnswerEvent, CallIceCandidateEvent,
CallHangupEvent, CallRejectEvent, and CallRenegotiateEvent to
LocalCache.justConsumeInnerInner() so they are properly consumed
and indexed when received from relays.
https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS