From 655b64237737f01a07f78eb03664be2fe1a4745b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 21:09:12 +0000 Subject: [PATCH 01/63] feat: add WebRTC voice/video call infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- amethyst/build.gradle | 3 + amethyst/src/main/AndroidManifest.xml | 7 + .../service/call/CallForegroundService.kt | 101 +++++ .../amethyst/service/call/IceServerConfig.kt | 52 +++ .../service/call/WebRtcCallSession.kt | 260 +++++++++++++ .../amethyst/ui/call/CallScreen.kt | 354 ++++++++++++++++++ .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../chats/privateDM/header/ChatroomHeader.kt | 30 +- .../amethyst/commons/call/CallManager.kt | 259 +++++++++++++ .../amethyst/commons/call/CallState.kt | 74 ++++ gradle/libs.versions.toml | 2 + .../nip100WebRtcCalls/WebRtcCallFactory.kt | 113 ++++++ .../events/CallAnswerEvent.kt | 67 ++++ .../events/CallHangupEvent.kt | 67 ++++ .../events/CallIceCandidateEvent.kt | 67 ++++ .../events/CallOfferEvent.kt | 74 ++++ .../events/CallRejectEvent.kt | 67 ++++ .../events/CallRenegotiateEvent.kt | 67 ++++ .../nip100WebRtcCalls/tags/CallIdTag.kt | 39 ++ .../nip100WebRtcCalls/tags/CallTypeTag.kt | 55 +++ .../tags/TagArrayBuilderExt.kt | 28 ++ .../quartz/utils/EventFactory.kt | 12 + 22 files changed, 1802 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index cf843f68f..53bef0cc8 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -366,6 +366,9 @@ dependencies { // Voice anonymization DSP implementation libs.tarsosdsp + // WebRTC for voice/video calls + implementation libs.stream.webrtc.android + // Cbor for cashuB format implementation libs.kotlinx.serialization.cbor diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 4aae58459..9df886f66 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ + @@ -221,6 +222,12 @@ + + { + val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" + val notification = buildNotification(peerName) + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } else { + 0 + }, + ) + } + + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_NOT_STICKY + } + + private fun createNotificationChannel() { + val channel = + NotificationChannel( + CHANNEL_ID, + "Calls", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Ongoing call notification" + } + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + private fun buildNotification(peerName: String): Notification = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Call with $peerName") + .setSmallIcon(R.drawable.amethyst) + .setOngoing(true) + .build() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt new file mode 100644 index 000000000..b83598961 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import org.webrtc.PeerConnection + +object IceServerConfig { + val defaultStunServers = + listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(), + ) + + fun buildIceServers(userTurnServers: List = emptyList()): List { + val servers = defaultStunServers.toMutableList() + userTurnServers.forEach { turn -> + servers.add( + PeerConnection.IceServer + .builder(turn.url) + .setUsername(turn.username) + .setPassword(turn.credential) + .createIceServer(), + ) + } + return servers + } +} + +data class TurnServerConfig( + val url: String, + val username: String, + val credential: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt new file mode 100644 index 000000000..db832d665 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import com.vitorpamplona.quartz.utils.Log +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.VideoSource +import org.webrtc.VideoTrack + +private const val TAG = "WebRtcCallSession" + +class WebRtcCallSession( + private val context: Context, + private val iceServers: List, + private val onIceCandidate: (IceCandidate) -> Unit, + private val onPeerConnected: () -> Unit, + private val onRemoteStream: (MediaStream) -> Unit, + private val onDisconnected: () -> Unit, +) { + private var peerConnectionFactory: PeerConnectionFactory? = null + private var peerConnection: PeerConnection? = null + private var localAudioTrack: AudioTrack? = null + private var localVideoTrack: VideoTrack? = null + private var audioSource: AudioSource? = null + private var videoSource: VideoSource? = null + + val eglBase: EglBase = EglBase.create() + + fun initialize() { + PeerConnectionFactory.initialize( + PeerConnectionFactory + .InitializationOptions + .builder(context) + .createInitializationOptions(), + ) + + peerConnectionFactory = + PeerConnectionFactory + .builder() + .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext)) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .createPeerConnectionFactory() + } + + fun createPeerConnection() { + val rtcConfig = + PeerConnection.RTCConfiguration(iceServers).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + } + + peerConnection = + peerConnectionFactory?.createPeerConnection( + rtcConfig, + object : PeerConnection.Observer { + override fun onIceCandidate(candidate: IceCandidate?) { + candidate?.let { onIceCandidate(it) } + } + + override fun onIceCandidatesRemoved(candidates: Array?) {} + + override fun onSignalingChange(state: PeerConnection.SignalingState?) {} + + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) { + Log.d(TAG) { "ICE connection state: $state" } + when (state) { + PeerConnection.IceConnectionState.CONNECTED -> { + onPeerConnected() + } + + PeerConnection.IceConnectionState.DISCONNECTED, + PeerConnection.IceConnectionState.FAILED, + -> { + onDisconnected() + } + + else -> {} + } + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {} + + override fun onAddStream(stream: MediaStream?) { + stream?.let { onRemoteStream(it) } + } + + override fun onRemoveStream(stream: MediaStream?) {} + + override fun onDataChannel(channel: DataChannel?) {} + + override fun onRenegotiationNeeded() {} + + override fun onAddTrack( + receiver: RtpReceiver?, + streams: Array?, + ) {} + }, + ) + } + + fun addAudioTrack() { + val constraints = MediaConstraints() + audioSource = peerConnectionFactory?.createAudioSource(constraints) + localAudioTrack = + peerConnectionFactory?.createAudioTrack("audio0", audioSource).also { + peerConnection?.addTrack(it) + } + } + + fun addVideoTrack() { + videoSource = peerConnectionFactory?.createVideoSource(false) + localVideoTrack = + peerConnectionFactory?.createVideoTrack("video0", videoSource).also { + peerConnection?.addTrack(it) + } + } + + fun getLocalVideoSource(): VideoSource? = videoSource + + fun getLocalVideoTrack(): VideoTrack? = localVideoTrack + + fun createOffer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createOffer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create offer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun createAnswer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createAnswer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create answer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun setRemoteDescription(sdp: SessionDescription) { + peerConnection?.setRemoteDescription(noOpSdpObserver(), sdp) + } + + fun addIceCandidate(candidate: IceCandidate) { + peerConnection?.addIceCandidate(candidate) + } + + fun setAudioEnabled(enabled: Boolean) { + localAudioTrack?.setEnabled(enabled) + } + + fun setVideoEnabled(enabled: Boolean) { + localVideoTrack?.setEnabled(enabled) + } + + fun dispose() { + localAudioTrack?.dispose() + localVideoTrack?.dispose() + audioSource?.dispose() + videoSource?.dispose() + peerConnection?.close() + peerConnection?.dispose() + peerConnectionFactory?.dispose() + eglBase.release() + + localAudioTrack = null + localVideoTrack = null + audioSource = null + videoSource = null + peerConnection = null + peerConnectionFactory = null + } + + private fun noOpSdpObserver() = + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) {} + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "SDP operation failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) { + Log.e(TAG, "SDP set failed: $error") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt new file mode 100644 index 000000000..3aa9c8d77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.call + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun CallScreen( + callManager: CallManager, + accountViewModel: AccountViewModel, + onCallEnded: () -> Unit, +) { + val callState by callManager.state.collectAsState() + val scope = rememberCoroutineScope() + + when (val state = callState) { + is CallState.Idle -> { + LaunchedEffect(Unit) { onCallEnded() } + } + + is CallState.Offering -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Calling...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.IncomingCall -> { + IncomingCallUI( + callerPubKey = state.callerPubKey, + callType = state.callType, + accountViewModel = accountViewModel, + onAccept = { /* handled by caller */ }, + onReject = { scope.launch { callManager.rejectCall() } }, + ) + } + + is CallState.Connecting -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Connecting...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.Connected -> { + ConnectedCallUI( + state = state, + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + onToggleMute = { callManager.toggleAudioMute() }, + onToggleVideo = { callManager.toggleVideo() }, + onToggleSpeaker = { callManager.toggleSpeaker() }, + ) + } + + is CallState.Ended -> { + LaunchedEffect(Unit) { + delay(2000) + callManager.reset() + onCallEnded() + } + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Call ended", + accountViewModel = accountViewModel, + onHangup = { onCallEnded() }, + ) + } + } +} + +@Composable +private fun CallInProgressUI( + peerPubKey: String, + statusText: String, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = statusText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +@Composable +private fun IncomingCallUI( + callerPubKey: String, + callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType, + accountViewModel: AccountViewModel, + onAccept: () -> Unit, + onReject: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Incoming ${callType.value} call...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + FloatingActionButton( + onClick = onReject, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Reject", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + FloatingActionButton( + onClick = onAccept, + containerColor = Color(0xFF4CAF50), + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.Call, + contentDescription = "Accept", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } +} + +@Composable +private fun ConnectedCallUI( + state: CallState.Connected, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, + onToggleMute: () -> Unit, + onToggleVideo: () -> Unit, + onToggleSpeaker: () -> Unit, +) { + var elapsed by remember { mutableLongStateOf(0L) } + + LaunchedEffect(state.startedAtEpoch) { + while (true) { + elapsed = TimeUtils.now() - state.startedAtEpoch + delay(1000) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatDuration(elapsed), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = onToggleMute) { + Text(if (state.isAudioMuted) "Unmute" else "Mute") + } + IconButton(onClick = onToggleVideo) { + Text(if (state.isVideoEnabled) "Cam Off" else "Cam On") + } + IconButton(onClick = onToggleSpeaker) { + Text(if (state.isSpeakerOn) "Earpiece" else "Speaker") + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +private fun formatDuration(seconds: Long): String { + val mins = seconds / 60 + val secs = seconds % 60 + return "%02d:%02d".format(mins, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f2a0c7424..8d0bdb879 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -300,6 +300,11 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ActiveCall( + val callId: String, + val peerPubKey: HexKey, + ) : Route() + @Serializable data class EventRedirect( val id: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt index ed24d5ccc..09085a2d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt @@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +52,7 @@ fun ChatroomHeader( room: ChatroomKey, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, + onCallClick: ((String) -> Unit)? = null, onClick: () -> Unit, ) { if (room.users.size == 1) { @@ -56,6 +63,7 @@ fun ChatroomHeader( modifier = modifier, accountViewModel = accountViewModel, onClick = onClick, + onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } }, ) } } @@ -75,6 +83,7 @@ fun UserChatroomHeader( modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, onClick: () -> Unit, + onCallClick: (() -> Unit)? = null, ) { Column( Modifier @@ -91,9 +100,28 @@ fun UserChatroomHeader( size = Size34dp, ) - Column(modifier = Modifier.padding(start = 10.dp)) { + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } + + if (onCallClick != null) { + IconButton( + onClick = onCallClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = "Voice call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt new file mode 100644 index 000000000..97af4dcd4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.call + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class CallManager( + private val signer: NostrSigner, + private val scope: CoroutineScope, + private val isFollowing: (HexKey) -> Boolean, + private val publishEvent: (GiftWrapEvent) -> Unit, +) { + private val factory = WebRtcCallFactory() + + private val _state = MutableStateFlow(CallState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var timeoutJob: Job? = null + + companion object { + const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + } + + suspend fun initiateCall( + calleePubKey: HexKey, + callType: CallType, + callId: String, + sdpOffer: String, + ) { + val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) + _state.value = CallState.Offering(callId, calleePubKey, callType) + publishEvent(result.wrap) + startTimeout(callId) + } + + fun onIncomingCallEvent(event: CallOfferEvent) { + val callerPubKey = event.pubKey + val callId = event.callId() ?: return + val callType = event.callType() ?: CallType.VOICE + + if (!isFollowing(callerPubKey)) return + + if (_state.value !is CallState.Idle) return + + _state.value = + CallState.IncomingCall( + callId = callId, + callerPubKey = callerPubKey, + callType = callType, + sdpOffer = event.sdpOffer(), + ) + startTimeout(callId) + } + + suspend fun acceptCall(sdpAnswer: String) { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) + _state.value = CallState.Connecting(current.callId, current.callerPubKey, current.callType) + cancelTimeout() + publishEvent(result.wrap) + } + + suspend fun rejectCall() { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) + _state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onCallAnswered(event: CallAnswerEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) + cancelTimeout() + } + + fun onCallRejected(event: CallRejectEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) + cancelTimeout() + } + + fun onIceCandidate(event: CallIceCandidateEvent) { + // ICE candidates are handled by the WebRTC session directly. + // This method exists for the call manager to validate the call-id. + } + + fun onPeerConnected() { + val current = _state.value + if (current !is CallState.Connecting) return + + _state.value = + CallState.Connected( + callId = current.callId, + peerPubKey = current.peerPubKey, + callType = current.callType, + startedAtEpoch = TimeUtils.now(), + ) + } + + suspend fun hangup() { + val peerPubKey: HexKey + val callId: String + when (val current = _state.value) { + is CallState.Offering -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connecting -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connected -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + else -> { + return + } + } + + val result = factory.createHangup(peerPubKey, callId, signer = signer) + _state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onPeerHangup(event: CallHangupEvent) { + val current = _state.value + val callId = event.callId() ?: return + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.Connecting -> current.callId + is CallState.Connected -> current.callId + is CallState.IncomingCall -> current.callId + else -> return + } + if (callId != currentCallId) return + + val peerPubKey = event.pubKey + _state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP) + cancelTimeout() + } + + fun onSignalingEvent(event: Event) { + when (event) { + is CallOfferEvent -> onIncomingCallEvent(event) + is CallAnswerEvent -> onCallAnswered(event) + is CallRejectEvent -> onCallRejected(event) + is CallHangupEvent -> onPeerHangup(event) + is CallIceCandidateEvent -> onIceCandidate(event) + } + } + + fun toggleAudioMute() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isAudioMuted = !current.isAudioMuted) + } + } + + fun toggleVideo() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isVideoEnabled = !current.isVideoEnabled) + } + } + + fun toggleSpeaker() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isSpeakerOn = !current.isSpeakerOn) + } + } + + fun reset() { + _state.value = CallState.Idle + cancelTimeout() + } + + private fun startTimeout(callId: String) { + cancelTimeout() + timeoutJob = + scope.launch { + delay(CALL_TIMEOUT_MS) + val current = _state.value + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.IncomingCall -> current.callId + else -> null + } + if (currentCallId == callId) { + val peerPubKey = + when (current) { + is CallState.Offering -> current.peerPubKey + is CallState.IncomingCall -> current.callerPubKey + else -> return@launch + } + _state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT) + } + } + } + + private fun cancelTimeout() { + timeoutJob?.cancel() + timeoutJob = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt new file mode 100644 index 000000000..4a7e90630 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.call + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType + +@Immutable +sealed interface CallState { + data object Idle : CallState + + data class Offering( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class IncomingCall( + val callId: String, + val callerPubKey: HexKey, + val callType: CallType, + val sdpOffer: String, + ) : CallState + + data class Connecting( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class Connected( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + val startedAtEpoch: Long, + val isAudioMuted: Boolean = false, + val isVideoEnabled: Boolean = true, + val isSpeakerOn: Boolean = false, + ) : CallState + + data class Ended( + val callId: String, + val peerPubKey: HexKey, + val reason: EndReason, + ) : CallState +} + +enum class EndReason { + HANGUP, + REJECTED, + TIMEOUT, + ERROR, + PEER_HANGUP, + PEER_REJECTED, +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdaed5d14..f1be67e66 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,6 +50,7 @@ secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" slf4j = "2.0.17" spotless = "8.4.0" +streamWebrtcAndroid = "1.3.8" tarsosdsp = "2.5" torAndroid = "0.4.9.5.1" translate = "17.0.3" @@ -162,6 +163,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } +stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" } tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt new file mode 100644 index 000000000..858c5b591 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +class WebRtcCallFactory { + data class Result( + val msg: Event, + val wrap: GiftWrapEvent, + ) + + suspend fun createCallOffer( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + callType: CallType, + signer: NostrSigner, + ): Result { + val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey) + return Result(signed, wrap) + } + + suspend fun createCallAnswer( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createIceCandidate( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createHangup( + peerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallHangupEvent.build(peerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createReject( + callerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallRejectEvent.build(callerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createRenegotiate( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt new file mode 100644 index 000000000..b09c7da97 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallAnswerEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpAnswer() = content + + companion object { + const val KIND = 25051 + const val ALT_DESCRIPTION = "WebRTC call answer" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpAnswer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt new file mode 100644 index 000000000..648cb6489 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallHangupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25053 + const val ALT_DESCRIPTION = "WebRTC call hangup" + const val EXPIRATION_SECONDS = 300L + + fun build( + peerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt new file mode 100644 index 000000000..8a3d13087 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallIceCandidateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun candidateJson() = content + + companion object { + const val KIND = 25052 + const val ALT_DESCRIPTION = "WebRTC ICE candidate" + const val EXPIRATION_SECONDS = 300L + + fun build( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, candidateJson, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt new file mode 100644 index 000000000..e4cd9be14 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25050 + const val ALT_DESCRIPTION = "WebRTC call offer" + const val EXPIRATION_SECONDS = 300L // 5 minutes + + fun build( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + type: CallType, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(calleePubKey) + callId(callId) + callType(type) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt new file mode 100644 index 000000000..84be6ad0f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRejectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25054 + const val ALT_DESCRIPTION = "WebRTC call rejection" + const val EXPIRATION_SECONDS = 300L + + fun build( + callerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt new file mode 100644 index 000000000..f38898f70 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRenegotiateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25055 + const val ALT_DESCRIPTION = "WebRTC call renegotiation" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt new file mode 100644 index 000000000..d70865550 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CallIdTag { + companion object { + const val TAG_NAME = "call-id" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(callId: String) = arrayOf(TAG_NAME, callId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt new file mode 100644 index 000000000..f0b055af9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class CallType( + val value: String, +) { + VOICE("voice"), + VIDEO("video"), + ; + + companion object { + fun fromString(value: String): CallType? = + when (value) { + "voice" -> VOICE + "video" -> VIDEO + else -> null + } + } +} + +class CallTypeTag { + companion object { + const val TAG_NAME = "call-type" + + fun parse(tag: Array): CallType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return CallType.fromString(tag[1]) + } + + fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt new file mode 100644 index 000000000..17631ee78 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.callId(callId: String) = addUnique(CallIdTag.assemble(callId)) + +fun TagArrayBuilder.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 06b51ad2e..446615569 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,6 +50,12 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -295,6 +301,12 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + CallAnswerEvent.KIND -> CallAnswerEvent(id, pubKey, createdAt, tags, content, sig) + CallHangupEvent.KIND -> CallHangupEvent(id, pubKey, createdAt, tags, content, sig) + CallIceCandidateEvent.KIND -> CallIceCandidateEvent(id, pubKey, createdAt, tags, content, sig) + CallOfferEvent.KIND -> CallOfferEvent(id, pubKey, createdAt, tags, content, sig) + CallRejectEvent.KIND -> CallRejectEvent(id, pubKey, createdAt, tags, content, sig) + CallRenegotiateEvent.KIND -> CallRenegotiateEvent(id, pubKey, createdAt, tags, content, sig) CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig) CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig) CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig) From 5f29afbe5869743453898f8d2a2f7654b5ff9d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:13:12 +0000 Subject: [PATCH 02/63] docs: add NIP-100 draft for WebRTC calls over Nostr 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 --- .../quartz/nip100WebRtcCalls/NIP-100.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md new file mode 100644 index 000000000..c239fbe2d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md @@ -0,0 +1,265 @@ +NIP-100 +======= + +WebRTC Calls +------------ + +`draft` `optional` + +This NIP defines a protocol for establishing peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport. + +## Motivation + +Nostr users currently lack a way to make real-time voice or video calls without relying on centralized services. By using Nostr relays for WebRTC signaling and public STUN servers for NAT traversal, calls can be established in a fully decentralized manner — no custom server infrastructure is required. Once a WebRTC peer connection is established, the relay is no longer involved in the media stream. + +## Overview + +The protocol works as follows: + +1. **Caller** creates a signed call offer event containing an SDP offer +2. The event is **gift-wrapped** ([NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md)) and published to relays +3. **Callee** unwraps the event, verifies the signature, and decides whether to accept +4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer +5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal +6. A **direct WebRTC peer connection** is established for audio/video + +All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random ephemeral key already hides the sender from relay operators. + +## Event Kinds + +| Kind | Name | Description | +|-------|---------------------|----------------------------------------------| +| 25050 | Call Offer | SDP offer initiating a call | +| 25051 | Call Answer | SDP answer accepting a call | +| 25052 | ICE Candidate | ICE candidate for NAT traversal | +| 25053 | Call Hangup | Terminates an active or pending call | +| 25054 | Call Reject | Rejects an incoming call | +| 25055 | Call Renegotiate | New SDP offer for mid-call changes | + +## Tags + +All signaling events MUST include: + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `p` | Hex pubkey of the recipient | YES | +| `call-id` | UUID identifying the call session | YES | +| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be ~5 minutes from `created_at` | YES | +| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | + +Additional tags for **Call Offer** (kind 25050): + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `call-type` | `"voice"` or `"video"` | YES | + +## Event Structures + +### Call Offer (kind 25050) + +The `content` field contains the SDP offer string. + +```json +{ + "kind": 25050, + "pubkey": "", + "created_at": 1234567890, + "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["call-type", "video"], + ["expiration", "1234568190"], + ["alt", "WebRTC call offer"] + ], + "id": "", + "sig": "" +} +``` + +### Call Answer (kind 25051) + +The `content` field contains the SDP answer string. + +```json +{ + "kind": 25051, + "pubkey": "", + "created_at": 1234567895, + "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568195"], + ["alt", "WebRTC call answer"] + ], + "id": "", + "sig": "" +} +``` + +### ICE Candidate (kind 25052) + +The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. + +```json +{ + "kind": 25052, + "pubkey": "", + "created_at": 1234567896, + "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568196"], + ["alt", "WebRTC ICE candidate"] + ], + "id": "", + "sig": "" +} +``` + +### Call Hangup (kind 25053) + +The `content` field MAY contain a human-readable reason or be empty. + +```json +{ + "kind": 25053, + "pubkey": "", + "created_at": 1234568000, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568300"], + ["alt", "WebRTC call hangup"] + ], + "id": "", + "sig": "" +} +``` + +### Call Reject (kind 25054) + +The `content` field MAY contain a reason or be empty. + +```json +{ + "kind": 25054, + "pubkey": "", + "created_at": 1234567893, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568193"], + ["alt", "WebRTC call rejection"] + ], + "id": "", + "sig": "" +} +``` + +### Call Renegotiate (kind 25055) + +Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. + +```json +{ + "kind": 25055, + "pubkey": "", + "created_at": 1234568100, + "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568400"], + ["alt", "WebRTC call renegotiation"] + ], + "id": "", + "sig": "" +} +``` + +## Encryption and Delivery + +All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps: + +1. **Sign** the signaling event with the sender's key +2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption +3. **Publish** the gift wrap to the recipient's relay list + +The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides: + +- **NIP-44 encryption** — content is unreadable to relay operators +- **Random ephemeral pubkey** — the relay cannot identify the sender +- **`p` tag** — reveals only the recipient (necessary for delivery) + +Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message. + +## Protocol Flow + +### Initiating a Call + +``` +Caller Relay Callee + | | | + |-- GiftWrap(CallOffer) ------->| | + | |-- GiftWrap(CallOffer) ------->| + | | | + | | [Callee unwraps, verifies signature] + | | [Checks: is caller followed?] + | | [YES → ring / NO → ignore] + | | | + |<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------| + | | | + |<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->| + | | | + |============= WebRTC P2P Connection Established ===============| + | (relay no longer involved) | +``` + +### Ending a Call + +Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it. + +### Rejecting a Call + +The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state. + +## Spam Prevention + +Clients SHOULD implement call filtering: + +- **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored. +- **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window. +- **Expiration enforcement**: Clients MUST check the `expiration` tag and discard signaling events that have expired. + +## NAT Traversal + +This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: + +- Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`) +- Allow users to configure custom TURN servers for restrictive network environments +- Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer + +## Implementation Notes + +- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. +- Events SHOULD have short expiration times (~5 minutes) since signaling data is ephemeral and has no long-term value. +- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. +- Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. +- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. +- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. + +## References + +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure +- [NIP-31: Alt Tag](https://github.com/nostr-protocol/nips/blob/master/31.md) — Human-readable event descriptions +- [NIP-40: Expiration](https://github.com/nostr-protocol/nips/blob/master/40.md) — Event expiration timestamps +- [NIP-44: Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) — XChaCha20-Poly1305 encryption +- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md) — Encrypted event delivery +- [WebRTC Specification](https://www.w3.org/TR/webrtc/) — Peer-to-peer real-time communication +- [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) — Interactive Connectivity Establishment +- [nostr-protocol/nips#771](https://github.com/nostr-protocol/nips/issues/771) — WebRTC signaling discussion From 8cdf664610f303a49352b4e020414587a1eae5d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:40:13 +0000 Subject: [PATCH 03/63] feat: wire WebRTC call signaling end-to-end 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 --- .../vitorpamplona/amethyst/model/Account.kt | 5 + .../amethyst/service/call/CallController.kt | 196 ++++++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 14 ++ .../ui/screen/loggedIn/AccountViewModel.kt | 36 ++++ .../loggedIn/DecryptAndIndexProcessor.kt | 20 ++ .../chats/privateDM/ChatroomScreen.kt | 18 +- .../privateDM/header/RenderRoomTopBar.kt | 19 ++ .../amethyst/commons/call/CallManager.kt | 7 +- 8 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e7e4eb3c6..0f30f4259 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1686,6 +1686,11 @@ class Account( suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) + suspend fun publishCallSignaling(wrap: GiftWrapEvent) { + val relayList = computeRelayListToBroadcast(wrap) + client.publish(wrap, relayList) + } + suspend fun updateStatus( oldStatus: AddressableNote, newStatus: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt new file mode 100644 index 000000000..57c37ab78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.SessionDescription +import java.util.UUID + +class CallController( + private val context: Context, + private val callManager: CallManager, + private val scope: CoroutineScope, + private val publishWrap: suspend (GiftWrapEvent) -> Unit, + private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, +) { + private var webRtcSession: WebRtcCallSession? = null + private val callFactory = WebRtcCallFactory() + private var currentCallId: String? = null + private var currentPeerPubKey: HexKey? = null + + fun initiateCall( + peerPubKey: HexKey, + callType: CallType, + ) { + val callId = UUID.randomUUID().toString() + currentCallId = callId + currentPeerPubKey = peerPubKey + + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + } + + webRtcSession?.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + } + } + } + + fun acceptIncomingCall(sdpOffer: String) { + val state = callManager.state.value + if (state !is CallState.IncomingCall) return + + currentCallId = state.callId + currentPeerPubKey = state.callerPubKey + + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (state.callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + } + + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.OFFER, sdpOffer), + ) + + webRtcSession?.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } + } + } + + fun onCallAnswerReceived(sdpAnswer: String) { + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), + ) + } + + fun onIceCandidateReceived(event: CallIceCandidateEvent) { + val json = event.candidateJson() + try { + val candidate = parseIceCandidate(json) + webRtcSession?.addIceCandidate(candidate) + } catch (_: Exception) { + // Ignore malformed ICE candidates + } + } + + fun hangup() { + scope.launch { callManager.hangup() } + cleanup() + } + + fun cleanup() { + stopForegroundService() + webRtcSession?.dispose() + webRtcSession = null + currentCallId = null + currentPeerPubKey = null + } + + private fun createWebRtcSession() { + val iceServers = IceServerConfig.buildIceServers() + + webRtcSession = + WebRtcCallSession( + context = context, + iceServers = iceServers, + onIceCandidate = { candidate -> onLocalIceCandidate(candidate) }, + onPeerConnected = { + callManager.onPeerConnected() + startForegroundService() + }, + onRemoteStream = { _: MediaStream -> }, + onDisconnected = { + scope.launch { callManager.hangup() } + cleanup() + }, + ) + webRtcSession?.initialize() + webRtcSession?.createPeerConnection() + } + + private fun onLocalIceCandidate(candidate: IceCandidate) { + val callId = currentCallId ?: return + val peerPubKey = currentPeerPubKey ?: return + val candidateJson = serializeIceCandidate(candidate) + + scope.launch { + val signer = signerProvider() + val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer) + publishWrap(result.wrap) + } + } + + private fun startForegroundService() { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_START + putExtra(CallForegroundService.EXTRA_PEER_NAME, currentPeerPubKey ?: "") + } + context.startForegroundService(intent) + } + + private fun stopForegroundService() { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_STOP + } + context.startService(intent) + } + + companion object { + fun serializeIceCandidate(candidate: IceCandidate): String = """{"candidate":"${candidate.sdp}","sdpMid":"${candidate.sdpMid}","sdpMLineIndex":${candidate.sdpMLineIndex}}""" + + fun parseIceCandidate(json: String): IceCandidate { + val candidateRegex = """"candidate"\s*:\s*"([^"]*)"""".toRegex() + val sdpMidRegex = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex() + val sdpMLineIndexRegex = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex() + + val sdp = candidateRegex.find(json)?.groupValues?.get(1) ?: "" + val sdpMid = sdpMidRegex.find(json)?.groupValues?.get(1) ?: "0" + val sdpMLineIndex = + sdpMLineIndexRegex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + + return IceCandidate(sdpMid, sdpMLineIndex, sdp) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index fc83584fb..5d888706d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress +import com.vitorpamplona.amethyst.ui.call.CallScreen import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd @@ -168,6 +169,11 @@ fun BuildNavigation( accountViewModel: AccountViewModel, nav: Nav, ) { + val context = androidx.compose.ui.platform.LocalContext.current + androidx.compose.runtime.LaunchedEffect(Unit) { + accountViewModel.initCallController(context) + } + NavHost( navController = nav.controller, startDestination = Route.Home, @@ -254,6 +260,14 @@ fun BuildNavigation( composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + composableFromEndArgs { + CallScreen( + callManager = accountViewModel.callManager, + accountViewModel = accountViewModel, + onCallEnded = { nav.popBack() }, + ) + } + composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 029e535c2..900744d8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers @@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker +import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -187,6 +189,40 @@ class AccountViewModel( val broadcastTracker = BroadcastTracker() val feedStates = AccountFeedContentStates(account, viewModelScope) + val callManager = + CallManager( + signer = account.signer, + scope = viewModelScope, + isFollowing = { account.isFollowing(it) }, + publishEvent = { wrap -> + viewModelScope.launch { + account.publishCallSignaling(wrap) + } + }, + ) + + var callController: CallController? = null + private set + + fun initCallController(context: Context) { + if (callController != null) return + val controller = + CallController( + context = context.applicationContext, + callManager = callManager, + scope = viewModelScope, + publishWrap = { wrap -> account.publishCallSignaling(wrap) }, + signerProvider = { account.signer }, + ) + callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) } + callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } + callController = controller + } + + init { + account.newNotesPreProcessor.callManager = callManager + } + val eventSync = EventSync( accountPubKey = account.signer.pubKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a569b5ac9..88a628fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -27,6 +28,12 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -42,6 +49,7 @@ import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, + var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -68,10 +76,22 @@ class EventProcessor( publicNote: Note, ) { when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager?.onSignalingEvent(event) + is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) + is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index c1d324abd..5580dc9f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -46,7 +46,23 @@ fun ChatroomScreen( DisappearingScaffold( isInvertedLayout = true, topBar = { - RenderRoomTopBar(roomId, accountViewModel, nav) + RenderRoomTopBar( + room = roomId, + accountViewModel = accountViewModel, + nav = nav, + onCallClick = { peerPubKey -> + accountViewModel.callController?.initiateCall( + peerPubKey, + com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE, + ) + nav.nav( + com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( + callId = "", + peerPubKey = peerPubKey, + ), + ) + }, + ) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 33513c931..8ec60cc3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.EditNote import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -69,6 +73,7 @@ fun RenderRoomTopBar( room: ChatroomKey, accountViewModel: AccountViewModel, nav: INav, + onCallClick: ((String) -> Unit)? = null, ) { if (room.users.size == 1) { TopBarExtensibleWithBackButton( @@ -84,6 +89,20 @@ fun RenderRoomTopBar( Spacer(modifier = DoubleHorzSpacer) UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) + + if (onCallClick != null) { + IconButton( + onClick = { onCallClick(baseUser.pubkeyHex) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = "Voice call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } } }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 97af4dcd4..e312e608f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -51,6 +51,9 @@ class CallManager( private val _state = MutableStateFlow(CallState.Idle) val state: StateFlow = _state.asStateFlow() + var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null + var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null + private var timeoutJob: Job? = null companion object { @@ -115,6 +118,7 @@ class CallManager( _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) cancelTimeout() + onAnswerReceived?.invoke(event) } fun onCallRejected(event: CallRejectEvent) { @@ -127,8 +131,7 @@ class CallManager( } fun onIceCandidate(event: CallIceCandidateEvent) { - // ICE candidates are handled by the WebRTC session directly. - // This method exists for the call manager to validate the call-id. + onIceCandidateReceived?.invoke(event) } fun onPeerConnected() { From 2837bcbe43d482f13a0d8d72791a6d74ca1e15c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:06:13 +0000 Subject: [PATCH 04/63] fix: register call event kinds in LocalCache consumer 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 --- .../com/vitorpamplona/amethyst/model/LocalCache.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 31fd76865..1d1c0cfd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,6 +96,12 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -2567,6 +2573,12 @@ object LocalCache : ILocalCache, ICacheProvider { is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CallAnswerEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallHangupEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallIceCandidateEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallOfferEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallRejectEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallRenegotiateEvent -> consumeRegularEvent(event, relay, wasVerified) is ChannelCreateEvent -> consume(event, relay, wasVerified) is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ChannelHideMessageEvent -> consume(event, relay, wasVerified) From ecccf8b681cc3d3582cbc861d9266ef59d20d418 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:18:50 +0000 Subject: [PATCH 05/63] refactor: rename NIP-100 to NIP-AC for WebRTC calls Rename package nip100WebRtcCalls -> nipACWebRtcCalls and NIP-100.md -> NIP-AC.md across all modules. https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../vitorpamplona/amethyst/model/LocalCache.kt | 12 ++++++------ .../amethyst/service/call/CallController.kt | 6 +++--- .../vitorpamplona/amethyst/ui/call/CallScreen.kt | 2 +- .../screen/loggedIn/DecryptAndIndexProcessor.kt | 12 ++++++------ .../loggedIn/chats/privateDM/ChatroomScreen.kt | 2 +- .../amethyst/commons/call/CallManager.kt | 14 +++++++------- .../amethyst/commons/call/CallState.kt | 2 +- .../NIP-100.md => nipACWebRtcCalls/NIP-AC.md} | 4 ++-- .../WebRtcCallFactory.kt | 16 ++++++++-------- .../events/CallAnswerEvent.kt | 6 +++--- .../events/CallHangupEvent.kt | 6 +++--- .../events/CallIceCandidateEvent.kt | 6 +++--- .../events/CallOfferEvent.kt | 12 ++++++------ .../events/CallRejectEvent.kt | 6 +++--- .../events/CallRenegotiateEvent.kt | 6 +++--- .../tags/CallIdTag.kt | 2 +- .../tags/CallTypeTag.kt | 2 +- .../tags/TagArrayBuilderExt.kt | 2 +- .../vitorpamplona/quartz/utils/EventFactory.kt | 12 ++++++------ 19 files changed, 65 insertions(+), 65 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls/NIP-100.md => nipACWebRtcCalls/NIP-AC.md} (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/WebRtcCallFactory.kt (87%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallAnswerEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallHangupEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallIceCandidateEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallOfferEvent.kt (87%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallRejectEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallRenegotiateEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/CallIdTag.kt (96%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/CallTypeTag.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/TagArrayBuilderExt.kt (96%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1d1c0cfd4..ad1a43318 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,12 +96,6 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -222,6 +216,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 57c37ab78..a5e3637b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -25,10 +25,10 @@ import android.content.Intent import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.webrtc.IceCandidate diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 3aa9c8d77..337b16430 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -192,7 +192,7 @@ private fun CallInProgressUI( @Composable private fun IncomingCallUI( callerPubKey: String, - callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType, + callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType, accountViewModel: AccountViewModel, onAccept: () -> Unit, onReject: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 88a628fe9..b36bf9d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -28,12 +28,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -43,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 5580dc9f0..d2b6a9219 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -53,7 +53,7 @@ fun ChatroomScreen( onCallClick = { peerPubKey -> accountViewModel.callController?.initiateCall( peerPubKey, - com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE, + com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VOICE, ) nav.nav( com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index e312e608f..7a63a5e76 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -23,14 +23,14 @@ package com.vitorpamplona.amethyst.commons.call import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt index 4a7e90630..ea20a4667 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.call import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType @Immutable sealed interface CallState { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index c239fbe2d..a0b8338f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -1,5 +1,5 @@ -NIP-100 -======= +NIP-AC +====== WebRTC Calls ------------ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index 858c5b591..74e278937 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls +package com.vitorpamplona.quartz.nipACWebRtcCalls import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType class WebRtcCallFactory { data class Result( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt index b09c7da97..6f9baf445 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt index 648cb6489..46d6854ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt index 8a3d13087..bf4e2ab47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt index e4cd9be14..f63a64c1f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,13 +26,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callType import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt index 84be6ad0f..6fb1c63bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt index f38898f70..8524d580d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt index d70865550..371913dfd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt index f0b055af9..0dc18bdf0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt index 17631ee78..f58ba6177 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 446615569..c4eb7d8d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,12 +50,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -244,6 +238,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent From 55368db5d9bb14b7c80b5c1c7653f0c72defbdd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:23:45 +0000 Subject: [PATCH 06/63] refactor: observe call events from LocalCache instead of EventProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/screen/loggedIn/AccountViewModel.kt | 27 ++++++++++++++++--- .../loggedIn/DecryptAndIndexProcessor.kt | 20 -------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 900744d8a..7b7de3288 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -146,6 +146,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -219,10 +225,6 @@ class AccountViewModel( callController = controller } - init { - account.newNotesPreProcessor.callManager = callManager - } - val eventSync = EventSync( accountPubKey = account.signer.pubKey, @@ -1413,6 +1415,23 @@ class AccountViewModel( } } } + + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + newNotes.forEach { note -> + val event = note.event ?: return@forEach + when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager.onSignalingEvent(event) + } + } + } + } } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index b36bf9d3d..a569b5ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn -import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -37,19 +36,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, - var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -76,22 +68,10 @@ class EventProcessor( publicNote: Note, ) { when (event) { - is CallOfferEvent, - is CallAnswerEvent, - is CallIceCandidateEvent, - is CallHangupEvent, - is CallRejectEvent, - is CallRenegotiateEvent, - -> callManager?.onSignalingEvent(event) - is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) - is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) - is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) - is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) - is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } From 7bba3cda9cc3ebe2f72a7543beb6f6c418558784 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 1 Apr 2026 19:27:39 -0400 Subject: [PATCH 07/63] Fixes infinite loop --- .../vitorpamplona/amethyst/service/call/WebRtcCallSession.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index db832d665..aa9784449 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -86,7 +86,7 @@ class WebRtcCallSession( rtcConfig, object : PeerConnection.Observer { override fun onIceCandidate(candidate: IceCandidate?) { - candidate?.let { onIceCandidate(it) } + candidate?.let { this@WebRtcCallSession.onIceCandidate(it) } } override fun onIceCandidatesRemoved(candidates: Array?) {} From 12b2731eb50419cd2a8fa896878debeac8932610 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 21:09:12 +0000 Subject: [PATCH 08/63] feat: add WebRTC voice/video call infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- amethyst/build.gradle | 3 + amethyst/src/main/AndroidManifest.xml | 7 + .../service/call/CallForegroundService.kt | 101 +++++ .../amethyst/service/call/IceServerConfig.kt | 52 +++ .../service/call/WebRtcCallSession.kt | 260 +++++++++++++ .../amethyst/ui/call/CallScreen.kt | 354 ++++++++++++++++++ .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../chats/privateDM/header/ChatroomHeader.kt | 30 +- .../amethyst/commons/call/CallManager.kt | 259 +++++++++++++ .../amethyst/commons/call/CallState.kt | 74 ++++ gradle/libs.versions.toml | 2 + .../nip100WebRtcCalls/WebRtcCallFactory.kt | 113 ++++++ .../events/CallAnswerEvent.kt | 67 ++++ .../events/CallHangupEvent.kt | 67 ++++ .../events/CallIceCandidateEvent.kt | 67 ++++ .../events/CallOfferEvent.kt | 74 ++++ .../events/CallRejectEvent.kt | 67 ++++ .../events/CallRenegotiateEvent.kt | 67 ++++ .../nip100WebRtcCalls/tags/CallIdTag.kt | 39 ++ .../nip100WebRtcCalls/tags/CallTypeTag.kt | 55 +++ .../tags/TagArrayBuilderExt.kt | 28 ++ .../quartz/utils/EventFactory.kt | 12 + 22 files changed, 1802 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 5b4516875..57630fc71 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -363,6 +363,9 @@ dependencies { // Voice anonymization DSP implementation libs.tarsosdsp + // WebRTC for voice/video calls + implementation libs.stream.webrtc.android + // Cbor for cashuB format implementation libs.kotlinx.serialization.cbor diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 4aae58459..9df886f66 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ + @@ -221,6 +222,12 @@ + + { + val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" + val notification = buildNotification(peerName) + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } else { + 0 + }, + ) + } + + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_NOT_STICKY + } + + private fun createNotificationChannel() { + val channel = + NotificationChannel( + CHANNEL_ID, + "Calls", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Ongoing call notification" + } + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + private fun buildNotification(peerName: String): Notification = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Call with $peerName") + .setSmallIcon(R.drawable.amethyst) + .setOngoing(true) + .build() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt new file mode 100644 index 000000000..b83598961 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import org.webrtc.PeerConnection + +object IceServerConfig { + val defaultStunServers = + listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(), + ) + + fun buildIceServers(userTurnServers: List = emptyList()): List { + val servers = defaultStunServers.toMutableList() + userTurnServers.forEach { turn -> + servers.add( + PeerConnection.IceServer + .builder(turn.url) + .setUsername(turn.username) + .setPassword(turn.credential) + .createIceServer(), + ) + } + return servers + } +} + +data class TurnServerConfig( + val url: String, + val username: String, + val credential: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt new file mode 100644 index 000000000..db832d665 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import com.vitorpamplona.quartz.utils.Log +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.VideoSource +import org.webrtc.VideoTrack + +private const val TAG = "WebRtcCallSession" + +class WebRtcCallSession( + private val context: Context, + private val iceServers: List, + private val onIceCandidate: (IceCandidate) -> Unit, + private val onPeerConnected: () -> Unit, + private val onRemoteStream: (MediaStream) -> Unit, + private val onDisconnected: () -> Unit, +) { + private var peerConnectionFactory: PeerConnectionFactory? = null + private var peerConnection: PeerConnection? = null + private var localAudioTrack: AudioTrack? = null + private var localVideoTrack: VideoTrack? = null + private var audioSource: AudioSource? = null + private var videoSource: VideoSource? = null + + val eglBase: EglBase = EglBase.create() + + fun initialize() { + PeerConnectionFactory.initialize( + PeerConnectionFactory + .InitializationOptions + .builder(context) + .createInitializationOptions(), + ) + + peerConnectionFactory = + PeerConnectionFactory + .builder() + .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext)) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .createPeerConnectionFactory() + } + + fun createPeerConnection() { + val rtcConfig = + PeerConnection.RTCConfiguration(iceServers).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + } + + peerConnection = + peerConnectionFactory?.createPeerConnection( + rtcConfig, + object : PeerConnection.Observer { + override fun onIceCandidate(candidate: IceCandidate?) { + candidate?.let { onIceCandidate(it) } + } + + override fun onIceCandidatesRemoved(candidates: Array?) {} + + override fun onSignalingChange(state: PeerConnection.SignalingState?) {} + + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) { + Log.d(TAG) { "ICE connection state: $state" } + when (state) { + PeerConnection.IceConnectionState.CONNECTED -> { + onPeerConnected() + } + + PeerConnection.IceConnectionState.DISCONNECTED, + PeerConnection.IceConnectionState.FAILED, + -> { + onDisconnected() + } + + else -> {} + } + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {} + + override fun onAddStream(stream: MediaStream?) { + stream?.let { onRemoteStream(it) } + } + + override fun onRemoveStream(stream: MediaStream?) {} + + override fun onDataChannel(channel: DataChannel?) {} + + override fun onRenegotiationNeeded() {} + + override fun onAddTrack( + receiver: RtpReceiver?, + streams: Array?, + ) {} + }, + ) + } + + fun addAudioTrack() { + val constraints = MediaConstraints() + audioSource = peerConnectionFactory?.createAudioSource(constraints) + localAudioTrack = + peerConnectionFactory?.createAudioTrack("audio0", audioSource).also { + peerConnection?.addTrack(it) + } + } + + fun addVideoTrack() { + videoSource = peerConnectionFactory?.createVideoSource(false) + localVideoTrack = + peerConnectionFactory?.createVideoTrack("video0", videoSource).also { + peerConnection?.addTrack(it) + } + } + + fun getLocalVideoSource(): VideoSource? = videoSource + + fun getLocalVideoTrack(): VideoTrack? = localVideoTrack + + fun createOffer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createOffer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create offer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun createAnswer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createAnswer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create answer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun setRemoteDescription(sdp: SessionDescription) { + peerConnection?.setRemoteDescription(noOpSdpObserver(), sdp) + } + + fun addIceCandidate(candidate: IceCandidate) { + peerConnection?.addIceCandidate(candidate) + } + + fun setAudioEnabled(enabled: Boolean) { + localAudioTrack?.setEnabled(enabled) + } + + fun setVideoEnabled(enabled: Boolean) { + localVideoTrack?.setEnabled(enabled) + } + + fun dispose() { + localAudioTrack?.dispose() + localVideoTrack?.dispose() + audioSource?.dispose() + videoSource?.dispose() + peerConnection?.close() + peerConnection?.dispose() + peerConnectionFactory?.dispose() + eglBase.release() + + localAudioTrack = null + localVideoTrack = null + audioSource = null + videoSource = null + peerConnection = null + peerConnectionFactory = null + } + + private fun noOpSdpObserver() = + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) {} + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "SDP operation failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) { + Log.e(TAG, "SDP set failed: $error") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt new file mode 100644 index 000000000..3aa9c8d77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.call + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun CallScreen( + callManager: CallManager, + accountViewModel: AccountViewModel, + onCallEnded: () -> Unit, +) { + val callState by callManager.state.collectAsState() + val scope = rememberCoroutineScope() + + when (val state = callState) { + is CallState.Idle -> { + LaunchedEffect(Unit) { onCallEnded() } + } + + is CallState.Offering -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Calling...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.IncomingCall -> { + IncomingCallUI( + callerPubKey = state.callerPubKey, + callType = state.callType, + accountViewModel = accountViewModel, + onAccept = { /* handled by caller */ }, + onReject = { scope.launch { callManager.rejectCall() } }, + ) + } + + is CallState.Connecting -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Connecting...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.Connected -> { + ConnectedCallUI( + state = state, + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + onToggleMute = { callManager.toggleAudioMute() }, + onToggleVideo = { callManager.toggleVideo() }, + onToggleSpeaker = { callManager.toggleSpeaker() }, + ) + } + + is CallState.Ended -> { + LaunchedEffect(Unit) { + delay(2000) + callManager.reset() + onCallEnded() + } + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Call ended", + accountViewModel = accountViewModel, + onHangup = { onCallEnded() }, + ) + } + } +} + +@Composable +private fun CallInProgressUI( + peerPubKey: String, + statusText: String, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = statusText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +@Composable +private fun IncomingCallUI( + callerPubKey: String, + callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType, + accountViewModel: AccountViewModel, + onAccept: () -> Unit, + onReject: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Incoming ${callType.value} call...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + FloatingActionButton( + onClick = onReject, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Reject", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + FloatingActionButton( + onClick = onAccept, + containerColor = Color(0xFF4CAF50), + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.Call, + contentDescription = "Accept", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } +} + +@Composable +private fun ConnectedCallUI( + state: CallState.Connected, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, + onToggleMute: () -> Unit, + onToggleVideo: () -> Unit, + onToggleSpeaker: () -> Unit, +) { + var elapsed by remember { mutableLongStateOf(0L) } + + LaunchedEffect(state.startedAtEpoch) { + while (true) { + elapsed = TimeUtils.now() - state.startedAtEpoch + delay(1000) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatDuration(elapsed), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = onToggleMute) { + Text(if (state.isAudioMuted) "Unmute" else "Mute") + } + IconButton(onClick = onToggleVideo) { + Text(if (state.isVideoEnabled) "Cam Off" else "Cam On") + } + IconButton(onClick = onToggleSpeaker) { + Text(if (state.isSpeakerOn) "Earpiece" else "Speaker") + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +private fun formatDuration(seconds: Long): String { + val mins = seconds / 60 + val secs = seconds % 60 + return "%02d:%02d".format(mins, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b6dcdbcc6..8f69aaaf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -302,6 +302,11 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ActiveCall( + val callId: String, + val peerPubKey: HexKey, + ) : Route() + @Serializable data class EventRedirect( val id: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt index ed24d5ccc..09085a2d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt @@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +52,7 @@ fun ChatroomHeader( room: ChatroomKey, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, + onCallClick: ((String) -> Unit)? = null, onClick: () -> Unit, ) { if (room.users.size == 1) { @@ -56,6 +63,7 @@ fun ChatroomHeader( modifier = modifier, accountViewModel = accountViewModel, onClick = onClick, + onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } }, ) } } @@ -75,6 +83,7 @@ fun UserChatroomHeader( modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, onClick: () -> Unit, + onCallClick: (() -> Unit)? = null, ) { Column( Modifier @@ -91,9 +100,28 @@ fun UserChatroomHeader( size = Size34dp, ) - Column(modifier = Modifier.padding(start = 10.dp)) { + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } + + if (onCallClick != null) { + IconButton( + onClick = onCallClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = "Voice call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt new file mode 100644 index 000000000..97af4dcd4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.call + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class CallManager( + private val signer: NostrSigner, + private val scope: CoroutineScope, + private val isFollowing: (HexKey) -> Boolean, + private val publishEvent: (GiftWrapEvent) -> Unit, +) { + private val factory = WebRtcCallFactory() + + private val _state = MutableStateFlow(CallState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var timeoutJob: Job? = null + + companion object { + const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + } + + suspend fun initiateCall( + calleePubKey: HexKey, + callType: CallType, + callId: String, + sdpOffer: String, + ) { + val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) + _state.value = CallState.Offering(callId, calleePubKey, callType) + publishEvent(result.wrap) + startTimeout(callId) + } + + fun onIncomingCallEvent(event: CallOfferEvent) { + val callerPubKey = event.pubKey + val callId = event.callId() ?: return + val callType = event.callType() ?: CallType.VOICE + + if (!isFollowing(callerPubKey)) return + + if (_state.value !is CallState.Idle) return + + _state.value = + CallState.IncomingCall( + callId = callId, + callerPubKey = callerPubKey, + callType = callType, + sdpOffer = event.sdpOffer(), + ) + startTimeout(callId) + } + + suspend fun acceptCall(sdpAnswer: String) { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) + _state.value = CallState.Connecting(current.callId, current.callerPubKey, current.callType) + cancelTimeout() + publishEvent(result.wrap) + } + + suspend fun rejectCall() { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) + _state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onCallAnswered(event: CallAnswerEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) + cancelTimeout() + } + + fun onCallRejected(event: CallRejectEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) + cancelTimeout() + } + + fun onIceCandidate(event: CallIceCandidateEvent) { + // ICE candidates are handled by the WebRTC session directly. + // This method exists for the call manager to validate the call-id. + } + + fun onPeerConnected() { + val current = _state.value + if (current !is CallState.Connecting) return + + _state.value = + CallState.Connected( + callId = current.callId, + peerPubKey = current.peerPubKey, + callType = current.callType, + startedAtEpoch = TimeUtils.now(), + ) + } + + suspend fun hangup() { + val peerPubKey: HexKey + val callId: String + when (val current = _state.value) { + is CallState.Offering -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connecting -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connected -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + else -> { + return + } + } + + val result = factory.createHangup(peerPubKey, callId, signer = signer) + _state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onPeerHangup(event: CallHangupEvent) { + val current = _state.value + val callId = event.callId() ?: return + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.Connecting -> current.callId + is CallState.Connected -> current.callId + is CallState.IncomingCall -> current.callId + else -> return + } + if (callId != currentCallId) return + + val peerPubKey = event.pubKey + _state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP) + cancelTimeout() + } + + fun onSignalingEvent(event: Event) { + when (event) { + is CallOfferEvent -> onIncomingCallEvent(event) + is CallAnswerEvent -> onCallAnswered(event) + is CallRejectEvent -> onCallRejected(event) + is CallHangupEvent -> onPeerHangup(event) + is CallIceCandidateEvent -> onIceCandidate(event) + } + } + + fun toggleAudioMute() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isAudioMuted = !current.isAudioMuted) + } + } + + fun toggleVideo() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isVideoEnabled = !current.isVideoEnabled) + } + } + + fun toggleSpeaker() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isSpeakerOn = !current.isSpeakerOn) + } + } + + fun reset() { + _state.value = CallState.Idle + cancelTimeout() + } + + private fun startTimeout(callId: String) { + cancelTimeout() + timeoutJob = + scope.launch { + delay(CALL_TIMEOUT_MS) + val current = _state.value + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.IncomingCall -> current.callId + else -> null + } + if (currentCallId == callId) { + val peerPubKey = + when (current) { + is CallState.Offering -> current.peerPubKey + is CallState.IncomingCall -> current.callerPubKey + else -> return@launch + } + _state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT) + } + } + } + + private fun cancelTimeout() { + timeoutJob?.cancel() + timeoutJob = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt new file mode 100644 index 000000000..4a7e90630 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.call + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType + +@Immutable +sealed interface CallState { + data object Idle : CallState + + data class Offering( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class IncomingCall( + val callId: String, + val callerPubKey: HexKey, + val callType: CallType, + val sdpOffer: String, + ) : CallState + + data class Connecting( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class Connected( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + val startedAtEpoch: Long, + val isAudioMuted: Boolean = false, + val isVideoEnabled: Boolean = true, + val isSpeakerOn: Boolean = false, + ) : CallState + + data class Ended( + val callId: String, + val peerPubKey: HexKey, + val reason: EndReason, + ) : CallState +} + +enum class EndReason { + HANGUP, + REJECTED, + TIMEOUT, + ERROR, + PEER_HANGUP, + PEER_REJECTED, +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc6e06459..60485652c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" slf4j = "2.0.17" spotless = "8.4.0" +streamWebrtcAndroid = "1.3.8" tarsosdsp = "2.5" translate = "17.0.3" jetbrainsCompose = "1.10.3" @@ -157,6 +158,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } +stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" } tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt new file mode 100644 index 000000000..858c5b591 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +class WebRtcCallFactory { + data class Result( + val msg: Event, + val wrap: GiftWrapEvent, + ) + + suspend fun createCallOffer( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + callType: CallType, + signer: NostrSigner, + ): Result { + val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey) + return Result(signed, wrap) + } + + suspend fun createCallAnswer( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createIceCandidate( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createHangup( + peerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallHangupEvent.build(peerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createReject( + callerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallRejectEvent.build(callerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createRenegotiate( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt new file mode 100644 index 000000000..b09c7da97 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallAnswerEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpAnswer() = content + + companion object { + const val KIND = 25051 + const val ALT_DESCRIPTION = "WebRTC call answer" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpAnswer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt new file mode 100644 index 000000000..648cb6489 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallHangupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25053 + const val ALT_DESCRIPTION = "WebRTC call hangup" + const val EXPIRATION_SECONDS = 300L + + fun build( + peerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt new file mode 100644 index 000000000..8a3d13087 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallIceCandidateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun candidateJson() = content + + companion object { + const val KIND = 25052 + const val ALT_DESCRIPTION = "WebRTC ICE candidate" + const val EXPIRATION_SECONDS = 300L + + fun build( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, candidateJson, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt new file mode 100644 index 000000000..e4cd9be14 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25050 + const val ALT_DESCRIPTION = "WebRTC call offer" + const val EXPIRATION_SECONDS = 300L // 5 minutes + + fun build( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + type: CallType, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(calleePubKey) + callId(callId) + callType(type) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt new file mode 100644 index 000000000..84be6ad0f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRejectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25054 + const val ALT_DESCRIPTION = "WebRTC call rejection" + const val EXPIRATION_SECONDS = 300L + + fun build( + callerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt new file mode 100644 index 000000000..f38898f70 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRenegotiateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25055 + const val ALT_DESCRIPTION = "WebRTC call renegotiation" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt new file mode 100644 index 000000000..d70865550 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CallIdTag { + companion object { + const val TAG_NAME = "call-id" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(callId: String) = arrayOf(TAG_NAME, callId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt new file mode 100644 index 000000000..f0b055af9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class CallType( + val value: String, +) { + VOICE("voice"), + VIDEO("video"), + ; + + companion object { + fun fromString(value: String): CallType? = + when (value) { + "voice" -> VOICE + "video" -> VIDEO + else -> null + } + } +} + +class CallTypeTag { + companion object { + const val TAG_NAME = "call-type" + + fun parse(tag: Array): CallType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return CallType.fromString(tag[1]) + } + + fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt new file mode 100644 index 000000000..17631ee78 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.callId(callId: String) = addUnique(CallIdTag.assemble(callId)) + +fun TagArrayBuilder.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 06b51ad2e..446615569 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,6 +50,12 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -295,6 +301,12 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + CallAnswerEvent.KIND -> CallAnswerEvent(id, pubKey, createdAt, tags, content, sig) + CallHangupEvent.KIND -> CallHangupEvent(id, pubKey, createdAt, tags, content, sig) + CallIceCandidateEvent.KIND -> CallIceCandidateEvent(id, pubKey, createdAt, tags, content, sig) + CallOfferEvent.KIND -> CallOfferEvent(id, pubKey, createdAt, tags, content, sig) + CallRejectEvent.KIND -> CallRejectEvent(id, pubKey, createdAt, tags, content, sig) + CallRenegotiateEvent.KIND -> CallRenegotiateEvent(id, pubKey, createdAt, tags, content, sig) CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig) CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig) CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig) From 8ae65df96be863b4b89a7fd6bc844fd226461a43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:13:12 +0000 Subject: [PATCH 09/63] docs: add NIP-100 draft for WebRTC calls over Nostr 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 --- .../quartz/nip100WebRtcCalls/NIP-100.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md new file mode 100644 index 000000000..c239fbe2d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md @@ -0,0 +1,265 @@ +NIP-100 +======= + +WebRTC Calls +------------ + +`draft` `optional` + +This NIP defines a protocol for establishing peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport. + +## Motivation + +Nostr users currently lack a way to make real-time voice or video calls without relying on centralized services. By using Nostr relays for WebRTC signaling and public STUN servers for NAT traversal, calls can be established in a fully decentralized manner — no custom server infrastructure is required. Once a WebRTC peer connection is established, the relay is no longer involved in the media stream. + +## Overview + +The protocol works as follows: + +1. **Caller** creates a signed call offer event containing an SDP offer +2. The event is **gift-wrapped** ([NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md)) and published to relays +3. **Callee** unwraps the event, verifies the signature, and decides whether to accept +4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer +5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal +6. A **direct WebRTC peer connection** is established for audio/video + +All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random ephemeral key already hides the sender from relay operators. + +## Event Kinds + +| Kind | Name | Description | +|-------|---------------------|----------------------------------------------| +| 25050 | Call Offer | SDP offer initiating a call | +| 25051 | Call Answer | SDP answer accepting a call | +| 25052 | ICE Candidate | ICE candidate for NAT traversal | +| 25053 | Call Hangup | Terminates an active or pending call | +| 25054 | Call Reject | Rejects an incoming call | +| 25055 | Call Renegotiate | New SDP offer for mid-call changes | + +## Tags + +All signaling events MUST include: + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `p` | Hex pubkey of the recipient | YES | +| `call-id` | UUID identifying the call session | YES | +| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be ~5 minutes from `created_at` | YES | +| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | + +Additional tags for **Call Offer** (kind 25050): + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `call-type` | `"voice"` or `"video"` | YES | + +## Event Structures + +### Call Offer (kind 25050) + +The `content` field contains the SDP offer string. + +```json +{ + "kind": 25050, + "pubkey": "", + "created_at": 1234567890, + "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["call-type", "video"], + ["expiration", "1234568190"], + ["alt", "WebRTC call offer"] + ], + "id": "", + "sig": "" +} +``` + +### Call Answer (kind 25051) + +The `content` field contains the SDP answer string. + +```json +{ + "kind": 25051, + "pubkey": "", + "created_at": 1234567895, + "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568195"], + ["alt", "WebRTC call answer"] + ], + "id": "", + "sig": "" +} +``` + +### ICE Candidate (kind 25052) + +The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. + +```json +{ + "kind": 25052, + "pubkey": "", + "created_at": 1234567896, + "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568196"], + ["alt", "WebRTC ICE candidate"] + ], + "id": "", + "sig": "" +} +``` + +### Call Hangup (kind 25053) + +The `content` field MAY contain a human-readable reason or be empty. + +```json +{ + "kind": 25053, + "pubkey": "", + "created_at": 1234568000, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568300"], + ["alt", "WebRTC call hangup"] + ], + "id": "", + "sig": "" +} +``` + +### Call Reject (kind 25054) + +The `content` field MAY contain a reason or be empty. + +```json +{ + "kind": 25054, + "pubkey": "", + "created_at": 1234567893, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568193"], + ["alt", "WebRTC call rejection"] + ], + "id": "", + "sig": "" +} +``` + +### Call Renegotiate (kind 25055) + +Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. + +```json +{ + "kind": 25055, + "pubkey": "", + "created_at": 1234568100, + "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568400"], + ["alt", "WebRTC call renegotiation"] + ], + "id": "", + "sig": "" +} +``` + +## Encryption and Delivery + +All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps: + +1. **Sign** the signaling event with the sender's key +2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption +3. **Publish** the gift wrap to the recipient's relay list + +The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides: + +- **NIP-44 encryption** — content is unreadable to relay operators +- **Random ephemeral pubkey** — the relay cannot identify the sender +- **`p` tag** — reveals only the recipient (necessary for delivery) + +Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message. + +## Protocol Flow + +### Initiating a Call + +``` +Caller Relay Callee + | | | + |-- GiftWrap(CallOffer) ------->| | + | |-- GiftWrap(CallOffer) ------->| + | | | + | | [Callee unwraps, verifies signature] + | | [Checks: is caller followed?] + | | [YES → ring / NO → ignore] + | | | + |<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------| + | | | + |<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->| + | | | + |============= WebRTC P2P Connection Established ===============| + | (relay no longer involved) | +``` + +### Ending a Call + +Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it. + +### Rejecting a Call + +The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state. + +## Spam Prevention + +Clients SHOULD implement call filtering: + +- **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored. +- **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window. +- **Expiration enforcement**: Clients MUST check the `expiration` tag and discard signaling events that have expired. + +## NAT Traversal + +This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: + +- Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`) +- Allow users to configure custom TURN servers for restrictive network environments +- Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer + +## Implementation Notes + +- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. +- Events SHOULD have short expiration times (~5 minutes) since signaling data is ephemeral and has no long-term value. +- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. +- Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. +- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. +- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. + +## References + +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure +- [NIP-31: Alt Tag](https://github.com/nostr-protocol/nips/blob/master/31.md) — Human-readable event descriptions +- [NIP-40: Expiration](https://github.com/nostr-protocol/nips/blob/master/40.md) — Event expiration timestamps +- [NIP-44: Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) — XChaCha20-Poly1305 encryption +- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md) — Encrypted event delivery +- [WebRTC Specification](https://www.w3.org/TR/webrtc/) — Peer-to-peer real-time communication +- [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) — Interactive Connectivity Establishment +- [nostr-protocol/nips#771](https://github.com/nostr-protocol/nips/issues/771) — WebRTC signaling discussion From 6128e35765bbe531189d3529291fe1ce4b525931 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:40:13 +0000 Subject: [PATCH 10/63] feat: wire WebRTC call signaling end-to-end 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 --- .../vitorpamplona/amethyst/model/Account.kt | 5 + .../amethyst/service/call/CallController.kt | 196 ++++++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 14 ++ .../ui/screen/loggedIn/AccountViewModel.kt | 36 ++++ .../loggedIn/DecryptAndIndexProcessor.kt | 20 ++ .../chats/privateDM/ChatroomScreen.kt | 18 +- .../privateDM/header/RenderRoomTopBar.kt | 19 ++ .../amethyst/commons/call/CallManager.kt | 7 +- 8 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e7e4eb3c6..0f30f4259 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1686,6 +1686,11 @@ class Account( suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) + suspend fun publishCallSignaling(wrap: GiftWrapEvent) { + val relayList = computeRelayListToBroadcast(wrap) + client.publish(wrap, relayList) + } + suspend fun updateStatus( oldStatus: AddressableNote, newStatus: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt new file mode 100644 index 000000000..57c37ab78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.SessionDescription +import java.util.UUID + +class CallController( + private val context: Context, + private val callManager: CallManager, + private val scope: CoroutineScope, + private val publishWrap: suspend (GiftWrapEvent) -> Unit, + private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, +) { + private var webRtcSession: WebRtcCallSession? = null + private val callFactory = WebRtcCallFactory() + private var currentCallId: String? = null + private var currentPeerPubKey: HexKey? = null + + fun initiateCall( + peerPubKey: HexKey, + callType: CallType, + ) { + val callId = UUID.randomUUID().toString() + currentCallId = callId + currentPeerPubKey = peerPubKey + + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + } + + webRtcSession?.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + } + } + } + + fun acceptIncomingCall(sdpOffer: String) { + val state = callManager.state.value + if (state !is CallState.IncomingCall) return + + currentCallId = state.callId + currentPeerPubKey = state.callerPubKey + + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (state.callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + } + + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.OFFER, sdpOffer), + ) + + webRtcSession?.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } + } + } + + fun onCallAnswerReceived(sdpAnswer: String) { + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), + ) + } + + fun onIceCandidateReceived(event: CallIceCandidateEvent) { + val json = event.candidateJson() + try { + val candidate = parseIceCandidate(json) + webRtcSession?.addIceCandidate(candidate) + } catch (_: Exception) { + // Ignore malformed ICE candidates + } + } + + fun hangup() { + scope.launch { callManager.hangup() } + cleanup() + } + + fun cleanup() { + stopForegroundService() + webRtcSession?.dispose() + webRtcSession = null + currentCallId = null + currentPeerPubKey = null + } + + private fun createWebRtcSession() { + val iceServers = IceServerConfig.buildIceServers() + + webRtcSession = + WebRtcCallSession( + context = context, + iceServers = iceServers, + onIceCandidate = { candidate -> onLocalIceCandidate(candidate) }, + onPeerConnected = { + callManager.onPeerConnected() + startForegroundService() + }, + onRemoteStream = { _: MediaStream -> }, + onDisconnected = { + scope.launch { callManager.hangup() } + cleanup() + }, + ) + webRtcSession?.initialize() + webRtcSession?.createPeerConnection() + } + + private fun onLocalIceCandidate(candidate: IceCandidate) { + val callId = currentCallId ?: return + val peerPubKey = currentPeerPubKey ?: return + val candidateJson = serializeIceCandidate(candidate) + + scope.launch { + val signer = signerProvider() + val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer) + publishWrap(result.wrap) + } + } + + private fun startForegroundService() { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_START + putExtra(CallForegroundService.EXTRA_PEER_NAME, currentPeerPubKey ?: "") + } + context.startForegroundService(intent) + } + + private fun stopForegroundService() { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_STOP + } + context.startService(intent) + } + + companion object { + fun serializeIceCandidate(candidate: IceCandidate): String = """{"candidate":"${candidate.sdp}","sdpMid":"${candidate.sdpMid}","sdpMLineIndex":${candidate.sdpMLineIndex}}""" + + fun parseIceCandidate(json: String): IceCandidate { + val candidateRegex = """"candidate"\s*:\s*"([^"]*)"""".toRegex() + val sdpMidRegex = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex() + val sdpMLineIndexRegex = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex() + + val sdp = candidateRegex.find(json)?.groupValues?.get(1) ?: "" + val sdpMid = sdpMidRegex.find(json)?.groupValues?.get(1) ?: "0" + val sdpMLineIndex = + sdpMLineIndexRegex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + + return IceCandidate(sdpMid, sdpMLineIndex, sdp) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ed83311a8..a7827efc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress +import com.vitorpamplona.amethyst.ui.call.CallScreen import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd @@ -168,6 +169,11 @@ fun BuildNavigation( accountViewModel: AccountViewModel, nav: Nav, ) { + val context = androidx.compose.ui.platform.LocalContext.current + androidx.compose.runtime.LaunchedEffect(Unit) { + accountViewModel.initCallController(context) + } + NavHost( navController = nav.controller, startDestination = Route.Home, @@ -254,6 +260,14 @@ fun BuildNavigation( composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + composableFromEndArgs { + CallScreen( + callManager = accountViewModel.callManager, + accountViewModel = accountViewModel, + onCallEnded = { nav.popBack() }, + ) + } + composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1c01f7a6f..4d185e9f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers @@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker +import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -187,6 +189,40 @@ class AccountViewModel( val broadcastTracker = BroadcastTracker() val feedStates = AccountFeedContentStates(account, viewModelScope) + val callManager = + CallManager( + signer = account.signer, + scope = viewModelScope, + isFollowing = { account.isFollowing(it) }, + publishEvent = { wrap -> + viewModelScope.launch { + account.publishCallSignaling(wrap) + } + }, + ) + + var callController: CallController? = null + private set + + fun initCallController(context: Context) { + if (callController != null) return + val controller = + CallController( + context = context.applicationContext, + callManager = callManager, + scope = viewModelScope, + publishWrap = { wrap -> account.publishCallSignaling(wrap) }, + signerProvider = { account.signer }, + ) + callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) } + callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } + callController = controller + } + + init { + account.newNotesPreProcessor.callManager = callManager + } + val eventSync = EventSync( accountPubKey = account.signer.pubKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a569b5ac9..88a628fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -27,6 +28,12 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -42,6 +49,7 @@ import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, + var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -68,10 +76,22 @@ class EventProcessor( publicNote: Note, ) { when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager?.onSignalingEvent(event) + is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) + is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index c1d324abd..5580dc9f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -46,7 +46,23 @@ fun ChatroomScreen( DisappearingScaffold( isInvertedLayout = true, topBar = { - RenderRoomTopBar(roomId, accountViewModel, nav) + RenderRoomTopBar( + room = roomId, + accountViewModel = accountViewModel, + nav = nav, + onCallClick = { peerPubKey -> + accountViewModel.callController?.initiateCall( + peerPubKey, + com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE, + ) + nav.nav( + com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( + callId = "", + peerPubKey = peerPubKey, + ), + ) + }, + ) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 33513c931..8ec60cc3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.EditNote import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -69,6 +73,7 @@ fun RenderRoomTopBar( room: ChatroomKey, accountViewModel: AccountViewModel, nav: INav, + onCallClick: ((String) -> Unit)? = null, ) { if (room.users.size == 1) { TopBarExtensibleWithBackButton( @@ -84,6 +89,20 @@ fun RenderRoomTopBar( Spacer(modifier = DoubleHorzSpacer) UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) + + if (onCallClick != null) { + IconButton( + onClick = { onCallClick(baseUser.pubkeyHex) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = "Voice call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } } }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 97af4dcd4..e312e608f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -51,6 +51,9 @@ class CallManager( private val _state = MutableStateFlow(CallState.Idle) val state: StateFlow = _state.asStateFlow() + var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null + var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null + private var timeoutJob: Job? = null companion object { @@ -115,6 +118,7 @@ class CallManager( _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) cancelTimeout() + onAnswerReceived?.invoke(event) } fun onCallRejected(event: CallRejectEvent) { @@ -127,8 +131,7 @@ class CallManager( } fun onIceCandidate(event: CallIceCandidateEvent) { - // ICE candidates are handled by the WebRTC session directly. - // This method exists for the call manager to validate the call-id. + onIceCandidateReceived?.invoke(event) } fun onPeerConnected() { From 934ebd3a891edfb7044cf2fe4c43d6f41d9f8c8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:06:13 +0000 Subject: [PATCH 11/63] fix: register call event kinds in LocalCache consumer 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 --- .../com/vitorpamplona/amethyst/model/LocalCache.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 31fd76865..1d1c0cfd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,6 +96,12 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -2567,6 +2573,12 @@ object LocalCache : ILocalCache, ICacheProvider { is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CallAnswerEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallHangupEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallIceCandidateEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallOfferEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallRejectEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallRenegotiateEvent -> consumeRegularEvent(event, relay, wasVerified) is ChannelCreateEvent -> consume(event, relay, wasVerified) is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ChannelHideMessageEvent -> consume(event, relay, wasVerified) From a261727d95c282cc37b3b5bb365137750087c1f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:18:50 +0000 Subject: [PATCH 12/63] refactor: rename NIP-100 to NIP-AC for WebRTC calls Rename package nip100WebRtcCalls -> nipACWebRtcCalls and NIP-100.md -> NIP-AC.md across all modules. https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../vitorpamplona/amethyst/model/LocalCache.kt | 12 ++++++------ .../amethyst/service/call/CallController.kt | 6 +++--- .../vitorpamplona/amethyst/ui/call/CallScreen.kt | 2 +- .../screen/loggedIn/DecryptAndIndexProcessor.kt | 12 ++++++------ .../loggedIn/chats/privateDM/ChatroomScreen.kt | 2 +- .../amethyst/commons/call/CallManager.kt | 14 +++++++------- .../amethyst/commons/call/CallState.kt | 2 +- .../NIP-100.md => nipACWebRtcCalls/NIP-AC.md} | 4 ++-- .../WebRtcCallFactory.kt | 16 ++++++++-------- .../events/CallAnswerEvent.kt | 6 +++--- .../events/CallHangupEvent.kt | 6 +++--- .../events/CallIceCandidateEvent.kt | 6 +++--- .../events/CallOfferEvent.kt | 12 ++++++------ .../events/CallRejectEvent.kt | 6 +++--- .../events/CallRenegotiateEvent.kt | 6 +++--- .../tags/CallIdTag.kt | 2 +- .../tags/CallTypeTag.kt | 2 +- .../tags/TagArrayBuilderExt.kt | 2 +- .../vitorpamplona/quartz/utils/EventFactory.kt | 12 ++++++------ 19 files changed, 65 insertions(+), 65 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls/NIP-100.md => nipACWebRtcCalls/NIP-AC.md} (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/WebRtcCallFactory.kt (87%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallAnswerEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallHangupEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallIceCandidateEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallOfferEvent.kt (87%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallRejectEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallRenegotiateEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/CallIdTag.kt (96%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/CallTypeTag.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/TagArrayBuilderExt.kt (96%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1d1c0cfd4..ad1a43318 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,12 +96,6 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -222,6 +216,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 57c37ab78..a5e3637b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -25,10 +25,10 @@ import android.content.Intent import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.webrtc.IceCandidate diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 3aa9c8d77..337b16430 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -192,7 +192,7 @@ private fun CallInProgressUI( @Composable private fun IncomingCallUI( callerPubKey: String, - callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType, + callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType, accountViewModel: AccountViewModel, onAccept: () -> Unit, onReject: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 88a628fe9..b36bf9d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -28,12 +28,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -43,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 5580dc9f0..d2b6a9219 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -53,7 +53,7 @@ fun ChatroomScreen( onCallClick = { peerPubKey -> accountViewModel.callController?.initiateCall( peerPubKey, - com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE, + com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VOICE, ) nav.nav( com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index e312e608f..7a63a5e76 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -23,14 +23,14 @@ package com.vitorpamplona.amethyst.commons.call import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt index 4a7e90630..ea20a4667 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.call import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType @Immutable sealed interface CallState { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index c239fbe2d..a0b8338f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -1,5 +1,5 @@ -NIP-100 -======= +NIP-AC +====== WebRTC Calls ------------ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index 858c5b591..74e278937 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls +package com.vitorpamplona.quartz.nipACWebRtcCalls import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType class WebRtcCallFactory { data class Result( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt index b09c7da97..6f9baf445 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt index 648cb6489..46d6854ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt index 8a3d13087..bf4e2ab47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt index e4cd9be14..f63a64c1f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,13 +26,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callType import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt index 84be6ad0f..6fb1c63bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt index f38898f70..8524d580d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt index d70865550..371913dfd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt index f0b055af9..0dc18bdf0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt index 17631ee78..f58ba6177 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 446615569..c4eb7d8d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,12 +50,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -244,6 +238,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent From 9b9ee52317c21203e1a0e5ad6887909401bf75c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:23:45 +0000 Subject: [PATCH 13/63] refactor: observe call events from LocalCache instead of EventProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/screen/loggedIn/AccountViewModel.kt | 27 ++++++++++++++++--- .../loggedIn/DecryptAndIndexProcessor.kt | 20 -------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 4d185e9f1..7a740b2af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -146,6 +146,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -219,10 +225,6 @@ class AccountViewModel( callController = controller } - init { - account.newNotesPreProcessor.callManager = callManager - } - val eventSync = EventSync( accountPubKey = account.signer.pubKey, @@ -1413,6 +1415,23 @@ class AccountViewModel( } } } + + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + newNotes.forEach { note -> + val event = note.event ?: return@forEach + when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager.onSignalingEvent(event) + } + } + } + } } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index b36bf9d3d..a569b5ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn -import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -37,19 +36,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, - var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -76,22 +68,10 @@ class EventProcessor( publicNote: Note, ) { when (event) { - is CallOfferEvent, - is CallAnswerEvent, - is CallIceCandidateEvent, - is CallHangupEvent, - is CallRejectEvent, - is CallRenegotiateEvent, - -> callManager?.onSignalingEvent(event) - is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) - is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) - is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) - is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) - is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } From 336adc4948331932e8f9c018291003b55ac5cbaf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:47:35 +0000 Subject: [PATCH 14/63] feat: wire incoming call navigation and accept button - 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 --- .../amethyst/ui/call/CallScreen.kt | 4 +++- .../amethyst/ui/navigation/AppNavigation.kt | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 337b16430..b3f1e687e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -66,6 +67,7 @@ import kotlinx.coroutines.launch @Composable fun CallScreen( callManager: CallManager, + callController: CallController?, accountViewModel: AccountViewModel, onCallEnded: () -> Unit, ) { @@ -91,7 +93,7 @@ fun CallScreen( callerPubKey = state.callerPubKey, callType = state.callType, accountViewModel = accountViewModel, - onAccept = { /* handled by caller */ }, + onAccept = { callController?.acceptIncomingCall(state.sdpOffer) }, onReject = { scope.launch { callManager.rejectCall() } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a7827efc0..c78c9c0c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -28,6 +28,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +40,7 @@ import androidx.core.util.Consumer import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen @@ -48,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.call.CallScreen import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -162,6 +165,23 @@ fun AppNavigation( DisplayNotifyMessages(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) + + ObserveIncomingCalls(accountViewModel, nav) +} + +@Composable +private fun ObserveIncomingCalls( + accountViewModel: AccountViewModel, + nav: INav, +) { + val callState by accountViewModel.callManager.state.collectAsState() + + LaunchedEffect(callState) { + val state = callState + if (state is CallState.IncomingCall) { + nav.nav(Route.ActiveCall(state.callId, state.callerPubKey)) + } + } } @Composable @@ -263,6 +283,7 @@ fun BuildNavigation( composableFromEndArgs { CallScreen( callManager = accountViewModel.callManager, + callController = accountViewModel.callController, accountViewModel = accountViewModel, onCallEnded = { nav.popBack() }, ) From e42258e3c38b3e28cd869d9b4965a267a8d27669 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 1 Apr 2026 19:48:01 -0400 Subject: [PATCH 15/63] Improves the language of the NIP --- .../quartz/nipACWebRtcCalls/NIP-AC.md | 70 +++++-------------- 1 file changed, 19 insertions(+), 51 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index a0b8338f6..0aab7141a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -6,11 +6,9 @@ WebRTC Calls `draft` `optional` -This NIP defines a protocol for establishing peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport. - -## Motivation - -Nostr users currently lack a way to make real-time voice or video calls without relying on centralized services. By using Nostr relays for WebRTC signaling and public STUN servers for NAT traversal, calls can be established in a fully decentralized manner — no custom server infrastructure is required. Once a WebRTC peer connection is established, the relay is no longer involved in the media stream. +This NIP defines a protocol for establishing private peer-to-peer voice and video calls between Nostr +users using WebRTC, with Nostr relays serving as the signaling transport and public STUN servers for +NAT traversal — no custom server infrastructure is required ## Overview @@ -23,7 +21,9 @@ The protocol works as follows: 5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal 6. A **direct WebRTC peer connection** is established for audio/video -All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random ephemeral key already hides the sender from relay operators. +All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. +Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's +random ephemeral key already hides the sender from relay operators. ## Event Kinds @@ -44,8 +44,6 @@ All signaling events MUST include: |---------------|-------------------------------------------------------|----------| | `p` | Hex pubkey of the recipient | YES | | `call-id` | UUID identifying the call session | YES | -| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be ~5 minutes from `created_at` | YES | -| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | Additional tags for **Call Offer** (kind 25050): @@ -59,21 +57,16 @@ Additional tags for **Call Offer** (kind 25050): The `content` field contains the SDP offer string. -```json +```yaml { "kind": 25050, - "pubkey": "", - "created_at": 1234567890, "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["call-type", "video"], - ["expiration", "1234568190"], - ["alt", "WebRTC call offer"] + ["call-type", "video"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -81,20 +74,15 @@ The `content` field contains the SDP offer string. The `content` field contains the SDP answer string. -```json +```yaml { "kind": 25051, - "pubkey": "", - "created_at": 1234567895, "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568195"], - ["alt", "WebRTC call answer"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -102,20 +90,15 @@ The `content` field contains the SDP answer string. The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. -```json +```yaml { "kind": 25052, - "pubkey": "", - "created_at": 1234567896, "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568196"], - ["alt", "WebRTC ICE candidate"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -123,20 +106,15 @@ The `content` field contains the ICE candidate as a JSON string with the fields The `content` field MAY contain a human-readable reason or be empty. -```json +```yaml { "kind": 25053, - "pubkey": "", - "created_at": 1234568000, "content": "", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568300"], - ["alt", "WebRTC call hangup"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -144,20 +122,15 @@ The `content` field MAY contain a human-readable reason or be empty. The `content` field MAY contain a reason or be empty. -```json +```yaml { "kind": 25054, - "pubkey": "", - "created_at": 1234567893, "content": "", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568193"], - ["alt", "WebRTC call rejection"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -165,20 +138,15 @@ The `content` field MAY contain a reason or be empty. Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. -```json +```yaml { "kind": 25055, - "pubkey": "", - "created_at": 1234568100, "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568400"], - ["alt", "WebRTC call renegotiation"] ], - "id": "", - "sig": "" + # other fields } ``` From f1fd5ec7866f54647e7664eb2e055dd22cd91c0d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:06:35 +0000 Subject: [PATCH 16/63] fix: auto-reset CallManager state after call ends 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 --- .../amethyst/service/call/CallController.kt | 10 ++++++ .../amethyst/ui/call/CallScreen.kt | 5 --- .../amethyst/commons/call/CallManager.kt | 35 ++++++++++++++----- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index a5e3637b6..a91ff58aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -48,6 +48,16 @@ class CallController( private var currentCallId: String? = null private var currentPeerPubKey: HexKey? = null + init { + scope.launch { + callManager.state.collect { state -> + if (state is CallState.Ended && webRtcSession != null) { + cleanup() + } + } + } + } + fun initiateCall( peerPubKey: HexKey, callType: CallType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index b3f1e687e..dadd3b2f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -119,11 +119,6 @@ fun CallScreen( } is CallState.Ended -> { - LaunchedEffect(Unit) { - delay(2000) - callManager.reset() - onCallEnded() - } CallInProgressUI( peerPubKey = state.peerPubKey, statusText = "Call ended", diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 7a63a5e76..6527c80d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -55,9 +55,11 @@ class CallManager( var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null private var timeoutJob: Job? = null + private var resetJob: Job? = null companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting } suspend fun initiateCall( @@ -106,8 +108,7 @@ class CallManager( if (current !is CallState.IncomingCall) return val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) - _state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED) - cancelTimeout() + transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED) publishEvent(result.wrap) } @@ -126,8 +127,7 @@ class CallManager( if (current !is CallState.Offering) return if (event.callId() != current.callId) return - _state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) - cancelTimeout() + transitionToEnded(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) } fun onIceCandidate(event: CallIceCandidateEvent) { @@ -172,8 +172,7 @@ class CallManager( } val result = factory.createHangup(peerPubKey, callId, signer = signer) - _state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP) - cancelTimeout() + transitionToEnded(callId, peerPubKey, EndReason.HANGUP) publishEvent(result.wrap) } @@ -191,8 +190,7 @@ class CallManager( if (callId != currentCallId) return val peerPubKey = event.pubKey - _state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP) - cancelTimeout() + transitionToEnded(callId, peerPubKey, EndReason.PEER_HANGUP) } fun onSignalingEvent(event: Event) { @@ -229,6 +227,25 @@ class CallManager( fun reset() { _state.value = CallState.Idle cancelTimeout() + resetJob?.cancel() + resetJob = null + } + + private fun transitionToEnded( + callId: String, + peerPubKey: HexKey, + reason: EndReason, + ) { + _state.value = CallState.Ended(callId, peerPubKey, reason) + cancelTimeout() + resetJob?.cancel() + resetJob = + scope.launch { + delay(ENDED_DISPLAY_MS) + if (_state.value is CallState.Ended) { + _state.value = CallState.Idle + } + } } private fun startTimeout(callId: String) { @@ -250,7 +267,7 @@ class CallManager( is CallState.IncomingCall -> current.callerPubKey else -> return@launch } - _state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT) + transitionToEnded(callId, peerPubKey, EndReason.TIMEOUT) } } } From e072b9ac99067c2a57a22674902842d39cbee9ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:22:41 +0000 Subject: [PATCH 17/63] fix: buffer ICE candidates and discard stale signaling events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/service/call/CallController.kt | 26 +++++++++++++++++-- .../amethyst/commons/call/CallManager.kt | 5 ++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index a91ff58aa..9280010ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -35,6 +35,7 @@ import org.webrtc.IceCandidate import org.webrtc.MediaStream import org.webrtc.SessionDescription import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList class CallController( private val context: Context, @@ -47,6 +48,8 @@ class CallController( private val callFactory = WebRtcCallFactory() private var currentCallId: String? = null private var currentPeerPubKey: HexKey? = null + private var remoteDescriptionSet = false + private val pendingIceCandidates = CopyOnWriteArrayList() init { scope.launch { @@ -65,6 +68,8 @@ class CallController( val callId = UUID.randomUUID().toString() currentCallId = callId currentPeerPubKey = peerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() createWebRtcSession() webRtcSession?.addAudioTrack() @@ -85,6 +90,8 @@ class CallController( currentCallId = state.callId currentPeerPubKey = state.callerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() createWebRtcSession() webRtcSession?.addAudioTrack() @@ -95,6 +102,7 @@ class CallController( webRtcSession?.setRemoteDescription( SessionDescription(SessionDescription.Type.OFFER, sdpOffer), ) + flushPendingIceCandidates() webRtcSession?.createAnswer { sdp -> scope.launch { @@ -107,18 +115,31 @@ class CallController( webRtcSession?.setRemoteDescription( SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), ) + flushPendingIceCandidates() } fun onIceCandidateReceived(event: CallIceCandidateEvent) { val json = event.candidateJson() try { val candidate = parseIceCandidate(json) - webRtcSession?.addIceCandidate(candidate) + if (webRtcSession != null && remoteDescriptionSet) { + webRtcSession?.addIceCandidate(candidate) + } else { + pendingIceCandidates.add(candidate) + } } catch (_: Exception) { // Ignore malformed ICE candidates } } + private fun flushPendingIceCandidates() { + remoteDescriptionSet = true + val session = webRtcSession ?: return + val candidates = pendingIceCandidates.toList() + pendingIceCandidates.clear() + candidates.forEach { session.addIceCandidate(it) } + } + fun hangup() { scope.launch { callManager.hangup() } cleanup() @@ -130,6 +151,8 @@ class CallController( webRtcSession = null currentCallId = null currentPeerPubKey = null + remoteDescriptionSet = false + pendingIceCandidates.clear() } private fun createWebRtcSession() { @@ -147,7 +170,6 @@ class CallController( onRemoteStream = { _: MediaStream -> }, onDisconnected = { scope.launch { callManager.hangup() } - cleanup() }, ) webRtcSession?.initialize() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 6527c80d9..0ad79e1f3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -60,8 +60,11 @@ class CallManager( companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting + const val MAX_EVENT_AGE_SECONDS = 30L // discard signaling events older than this } + private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS + suspend fun initiateCall( calleePubKey: HexKey, callType: CallType, @@ -194,6 +197,8 @@ class CallManager( } fun onSignalingEvent(event: Event) { + if (isEventTooOld(event)) return + when (event) { is CallOfferEvent -> onIncomingCallEvent(event) is CallAnswerEvent -> onCallAnswered(event) From b04d05250791a1bf9968bcab692220cc5326f1b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:35:24 +0000 Subject: [PATCH 18/63] feat: add runtime permissions, audio routing, and call notifications 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 --- .../amethyst/service/call/CallController.kt | 16 +++++ .../EventNotificationConsumer.kt | 25 +++++++ .../notifications/NotificationUtils.kt | 68 +++++++++++++++++++ .../amethyst/ui/call/CallPermissions.kt | 63 +++++++++++++++++ .../amethyst/ui/call/CallScreen.kt | 22 ++++-- .../chats/privateDM/ChatroomScreen.kt | 25 +++---- 6 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 9280010ef..9726c458a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent +import android.media.AudioManager import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory @@ -140,6 +142,19 @@ class CallController( candidates.forEach { session.addIceCandidate(it) } } + fun setAudioMuted(muted: Boolean) { + webRtcSession?.setAudioEnabled(!muted) + } + + fun setVideoEnabled(enabled: Boolean) { + webRtcSession?.setVideoEnabled(enabled) + } + + fun setSpeakerOn(on: Boolean) { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.isSpeakerphoneOn = on + } + fun hangup() { scope.launch { callManager.hangup() } cleanup() @@ -147,6 +162,7 @@ class CallController( fun cleanup() { stopForegroundService() + NotificationUtils.cancelCallNotification(context) webRtcSession?.dispose() webRtcSession = null currentCallId = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 567b4ac44..7ae2310ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import java.math.BigDecimal @@ -138,6 +139,10 @@ class EventNotificationConsumer( is LiveChessMoveEvent -> { notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn) } + + is CallOfferEvent -> { + notifyIncomingCall(innerEvent, account) + } } } } @@ -620,6 +625,26 @@ class EventNotificationConsumer( } } + private fun notifyIncomingCall( + event: CallOfferEvent, + account: Account, + ) { + if (!account.isFollowing(event.pubKey)) return + + if (TimeUtils.now() - event.createdAt > 30) return + + val callerUser = LocalCache.getUserIfExists(event.pubKey) + val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..." + + NotificationUtils + .sendCallNotification( + callerName = callerName, + callerBitmap = null, + uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}", + applicationContext = applicationContext, + ) + } + fun notificationManager(): NotificationManager = ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index ed5e76ac9..2dd661bf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -47,11 +47,14 @@ object NotificationUtils { private var zapChannel: NotificationChannel? = null private var reactionChannel: NotificationChannel? = null private var chessChannel: NotificationChannel? = null + private var callChannel: NotificationChannel? = null private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION" private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION" + private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL" + private const val CALL_NOTIFICATION_ID = 0x50000 const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" @@ -510,6 +513,71 @@ object NotificationUtils { notify(summaryId, summaryBuilder.build()) } + fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel { + if (callChannel != null) return callChannel!! + + callChannel = + NotificationChannel( + CALL_CHANNEL_ID, + "Incoming calls", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Notifications for incoming voice and video calls" + } + + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(callChannel!!) + + return callChannel!! + } + + fun sendCallNotification( + callerName: String, + callerBitmap: Bitmap?, + uri: String, + applicationContext: Context, + ) { + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val channel = getOrCreateCallChannel(applicationContext) + + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } + + val contentPendingIntent = + PendingIntent.getActivity( + applicationContext, + CALL_NOTIFICATION_ID, + contentIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val builder = + NotificationCompat + .Builder(applicationContext, channel.id) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle("Incoming call") + .setContentText(callerName) + .setLargeIcon(callerBitmap) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setAutoCancel(true) + .setOngoing(true) + .setTimeoutAfter(60_000) + + notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) + } + + fun cancelCallNotification(applicationContext: Context) { + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel("call", CALL_NOTIFICATION_ID) + } + private fun NotificationManager.isDuplicate(notId: Int): Boolean { val notifications: Array = activeNotifications for (notification in notifications) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt new file mode 100644 index 000000000..41f2acbea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.call + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat + +@Composable +fun rememberCallPermissionLauncher(onGranted: () -> Unit) = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onGranted() + } + +fun hasAudioPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + +@Composable +fun rememberCallWithPermission( + context: android.content.Context, + onCall: () -> Unit, +): () -> Unit { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onCall() + } + + return remember(onCall) { + { + if (hasAudioPermission(context)) { + onCall() + } else { + launcher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index dadd3b2f6..0e531a8df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -73,6 +73,7 @@ fun CallScreen( ) { val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current when (val state = callState) { is CallState.Idle -> { @@ -89,11 +90,15 @@ fun CallScreen( } is CallState.IncomingCall -> { + val acceptWithPermission = + rememberCallWithPermission(context) { + callController?.acceptIncomingCall(state.sdpOffer) + } IncomingCallUI( callerPubKey = state.callerPubKey, callType = state.callType, accountViewModel = accountViewModel, - onAccept = { callController?.acceptIncomingCall(state.sdpOffer) }, + onAccept = acceptWithPermission, onReject = { scope.launch { callManager.rejectCall() } }, ) } @@ -112,9 +117,18 @@ fun CallScreen( state = state, accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, - onToggleMute = { callManager.toggleAudioMute() }, - onToggleVideo = { callManager.toggleVideo() }, - onToggleSpeaker = { callManager.toggleSpeaker() }, + onToggleMute = { + callManager.toggleAudioMute() + callController?.setAudioMuted(!state.isAudioMuted) + }, + onToggleVideo = { + callManager.toggleVideo() + callController?.setVideoEnabled(!state.isVideoEnabled) + }, + onToggleSpeaker = { + callManager.toggleSpeaker() + callController?.setSpeakerOn(!state.isSpeakerOn) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index d2b6a9219..e338e5747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -26,12 +26,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType @Composable fun ChatroomScreen( @@ -43,6 +47,14 @@ fun ChatroomScreen( accountViewModel: AccountViewModel, nav: INav, ) { + val context = LocalContext.current + val startCall = + rememberCallWithPermission(context) { + val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) + nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) + } + DisappearingScaffold( isInvertedLayout = true, topBar = { @@ -50,18 +62,7 @@ fun ChatroomScreen( room = roomId, accountViewModel = accountViewModel, nav = nav, - onCallClick = { peerPubKey -> - accountViewModel.callController?.initiateCall( - peerPubKey, - com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VOICE, - ) - nav.nav( - com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( - callId = "", - peerPubKey = peerPubKey, - ), - ) - }, + onCallClick = { _ -> startCall() }, ) }, accountViewModel = accountViewModel, From 073e9f052a8bb16a679120865fa748a2ea4ca935 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:46:44 +0000 Subject: [PATCH 19/63] fix: add event dedup, back button handling, and wake lock for calls - 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 --- .../amethyst/ui/call/CallScreen.kt | 24 ++++++++++++++++++- .../amethyst/commons/call/CallManager.kt | 3 +++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 0e531a8df..4f7ef31bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.call +import android.view.WindowManager +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,6 +42,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -50,6 +53,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -73,7 +77,13 @@ fun CallScreen( ) { val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current + + BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) { + scope.launch { callManager.hangup() } + } + + KeepScreenOn() when (val state = callState) { is CallState.Idle -> { @@ -363,3 +373,15 @@ private fun formatDuration(seconds: Long): String { val secs = seconds % 60 return "%02d:%02d".format(mins, secs) } + +@Composable +private fun KeepScreenOn() { + val context = LocalContext.current + DisposableEffect(Unit) { + val window = (context as? android.app.Activity)?.window + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 0ad79e1f3..fd77b4d99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -56,6 +56,7 @@ class CallManager( private var timeoutJob: Job? = null private var resetJob: Job? = null + private val processedEventIds = mutableSetOf() companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout @@ -198,6 +199,7 @@ class CallManager( fun onSignalingEvent(event: Event) { if (isEventTooOld(event)) return + if (!processedEventIds.add(event.id)) return when (event) { is CallOfferEvent -> onIncomingCallEvent(event) @@ -234,6 +236,7 @@ class CallManager( cancelTimeout() resetJob?.cancel() resetJob = null + processedEventIds.clear() } private fun transitionToEnded( From c2528d249b330790b43d23c95b16b55474c773f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:50:25 +0000 Subject: [PATCH 20/63] feat: add ringtone, vibration, proximity sensor, camera, and lock screen call UI 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 --- amethyst/src/main/AndroidManifest.xml | 1 + .../amethyst/service/call/CallAudioManager.kt | 119 ++++++++++++++++++ .../amethyst/service/call/CallController.kt | 23 +++- .../service/call/WebRtcCallSession.kt | 29 +++++ .../notifications/NotificationUtils.kt | 16 +++ 5 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 9df886f66..47d037c35 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt new file mode 100644 index 000000000..efbce5687 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import android.media.AudioAttributes +import android.media.Ringtone +import android.media.RingtoneManager +import android.os.Build +import android.os.PowerManager +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager + +class CallAudioManager( + private val context: Context, +) { + private var ringtone: Ringtone? = null + private var vibrator: Vibrator? = null + private var proximityWakeLock: PowerManager.WakeLock? = null + + fun startRinging() { + startRingtone() + startVibration() + } + + fun stopRinging() { + stopRingtone() + stopVibration() + } + + fun acquireProximityWakeLock() { + if (proximityWakeLock != null) return + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + proximityWakeLock = + powerManager.newWakeLock( + PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, + "amethyst:call_proximity", + ) + proximityWakeLock?.acquire(60 * 60 * 1000L) // 1 hour max + } + + fun releaseProximityWakeLock() { + proximityWakeLock?.let { + if (it.isHeld) it.release() + } + proximityWakeLock = null + } + + fun release() { + stopRinging() + releaseProximityWakeLock() + } + + private fun startRingtone() { + try { + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + ringtone = + RingtoneManager.getRingtone(context, ringtoneUri)?.apply { + audioAttributes = + AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + isLooping = true + play() + } + } catch (_: Exception) { + // Ringtone may not be available + } + } + + private fun stopRingtone() { + ringtone?.stop() + ringtone = null + } + + private fun startVibration() { + try { + vibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + val pattern = longArrayOf(0, 1000, 1000) // vibrate 1s, pause 1s, repeat + vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0)) + } catch (_: Exception) { + // Vibrator may not be available + } + } + + private fun stopVibration() { + vibrator?.cancel() + vibrator = null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 9726c458a..bf1293b42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -52,12 +52,30 @@ class CallController( private var currentPeerPubKey: HexKey? = null private var remoteDescriptionSet = false private val pendingIceCandidates = CopyOnWriteArrayList() + val audioManager = CallAudioManager(context) init { scope.launch { callManager.state.collect { state -> - if (state is CallState.Ended && webRtcSession != null) { - cleanup() + when (state) { + is CallState.IncomingCall -> { + audioManager.startRinging() + } + + is CallState.Connecting -> { + audioManager.stopRinging() + audioManager.acquireProximityWakeLock() + } + + is CallState.Connected -> { + audioManager.acquireProximityWakeLock() + } + + is CallState.Ended -> { + cleanup() + } + + else -> {} } } } @@ -161,6 +179,7 @@ class CallController( } fun cleanup() { + audioManager.release() stopForegroundService() NotificationUtils.cancelCallNotification(context) webRtcSession?.dispose() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index aa9784449..b877c044a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -24,6 +24,8 @@ import android.content.Context import com.vitorpamplona.quartz.utils.Log import org.webrtc.AudioSource import org.webrtc.AudioTrack +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer import org.webrtc.DataChannel import org.webrtc.DefaultVideoDecoderFactory import org.webrtc.DefaultVideoEncoderFactory @@ -36,6 +38,7 @@ import org.webrtc.PeerConnectionFactory import org.webrtc.RtpReceiver import org.webrtc.SdpObserver import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper import org.webrtc.VideoSource import org.webrtc.VideoTrack @@ -55,6 +58,7 @@ class WebRtcCallSession( private var localVideoTrack: VideoTrack? = null private var audioSource: AudioSource? = null private var videoSource: VideoSource? = null + private var cameraCapturer: CameraVideoCapturer? = null val eglBase: EglBase = EglBase.create() @@ -147,6 +151,30 @@ class WebRtcCallSession( peerConnectionFactory?.createVideoTrack("video0", videoSource).also { peerConnection?.addTrack(it) } + startCamera() + } + + private fun startCamera() { + val source = videoSource ?: return + val enumerator = Camera2Enumerator(context) + val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) } + val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return + + cameraCapturer = + enumerator.createCapturer(camera, null)?.also { + it.initialize( + SurfaceTextureHelper.create("CaptureThread", eglBase.eglBaseContext), + context, + source.capturerObserver, + ) + it.startCapture(640, 480, 30) + } + } + + fun stopCamera() { + cameraCapturer?.stopCapture() + cameraCapturer?.dispose() + cameraCapturer = null } fun getLocalVideoSource(): VideoSource? = videoSource @@ -226,6 +254,7 @@ class WebRtcCallSession( } fun dispose() { + stopCamera() localAudioTrack?.dispose() localVideoTrack?.dispose() audioSource?.dispose() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 2dd661bf6..ee6325639 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -555,6 +555,20 @@ object NotificationUtils { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + val fullScreenIntent = + Intent(applicationContext, MainActivity::class.java).apply { + data = uri.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + + val fullScreenPendingIntent = + PendingIntent.getActivity( + applicationContext, + CALL_NOTIFICATION_ID + 1, + fullScreenIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val builder = NotificationCompat .Builder(applicationContext, channel.id) @@ -563,11 +577,13 @@ object NotificationUtils { .setContentText(callerName) .setLargeIcon(callerBitmap) .setContentIntent(contentPendingIntent) + .setFullScreenIntent(fullScreenPendingIntent, true) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_CALL) .setAutoCancel(true) .setOngoing(true) .setTimeoutAfter(60_000) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) } From 731f0e02736b7d7156ca7f0ce7b9c18ce5acf673 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 01:12:04 +0000 Subject: [PATCH 21/63] fix: route call events via EventProcessor and add video call button 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 --- .../ui/screen/loggedIn/AccountViewModel.kt | 24 +------------------ .../loggedIn/DecryptAndIndexProcessor.kt | 21 ++++++++++++++++ .../chats/privateDM/ChatroomScreen.kt | 11 +++++++-- .../privateDM/header/RenderRoomTopBar.kt | 16 +++++++++++++ 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7a740b2af..acdea1d56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -146,12 +146,6 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -222,6 +216,7 @@ class AccountViewModel( ) callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) } callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } + account.newNotesPreProcessor.callManager = callManager callController = controller } @@ -1415,23 +1410,6 @@ class AccountViewModel( } } } - - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - newNotes.forEach { note -> - val event = note.event ?: return@forEach - when (event) { - is CallOfferEvent, - is CallAnswerEvent, - is CallIceCandidateEvent, - is CallHangupEvent, - is CallRejectEvent, - is CallRenegotiateEvent, - -> callManager.onSignalingEvent(event) - } - } - } - } } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a569b5ac9..8b6c15a4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -36,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -52,6 +59,8 @@ class EventProcessor( private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache) private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache) + var callManager: CallManager? = null + suspend fun consume(note: Note) { note.event?.let { event -> try { @@ -68,10 +77,22 @@ class EventProcessor( publicNote: Note, ) { when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager?.onSignalingEvent(event) + is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) + is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index e338e5747..d41e37ee8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -48,12 +48,18 @@ fun ChatroomScreen( nav: INav, ) { val context = LocalContext.current - val startCall = + val startVoiceCall = rememberCallWithPermission(context) { val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) } + val startVideoCall = + rememberCallWithPermission(context) { + val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) + nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) + } DisappearingScaffold( isInvertedLayout = true, @@ -62,7 +68,8 @@ fun ChatroomScreen( room = roomId, accountViewModel = accountViewModel, nav = nav, - onCallClick = { _ -> startCall() }, + onCallClick = { _ -> startVoiceCall() }, + onVideoCallClick = { _ -> startVideoCall() }, ) }, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 8ec60cc3f..739338e76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -74,6 +75,7 @@ fun RenderRoomTopBar( accountViewModel: AccountViewModel, nav: INav, onCallClick: ((String) -> Unit)? = null, + onVideoCallClick: ((String) -> Unit)? = null, ) { if (room.users.size == 1) { TopBarExtensibleWithBackButton( @@ -90,6 +92,20 @@ fun RenderRoomTopBar( UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) + if (onVideoCallClick != null) { + IconButton( + onClick = { onVideoCallClick(baseUser.pubkeyHex) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Videocam, + contentDescription = "Video call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + if (onCallClick != null) { IconButton( onClick = { onCallClick(baseUser.pubkeyHex) }, From 6fd6cd82c536d735b3078f572e6afc7f9a971bf3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 1 Apr 2026 21:18:49 -0400 Subject: [PATCH 22/63] Fixes missing permission --- amethyst/src/main/AndroidManifest.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 47d037c35..e605b4576 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -42,6 +42,9 @@ + + + From 2ee48da06449e3941ef0ba43494f73ef14c24e93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 21:09:12 +0000 Subject: [PATCH 23/63] feat: add WebRTC voice/video call infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- amethyst/build.gradle | 3 + amethyst/src/main/AndroidManifest.xml | 7 + .../service/call/CallForegroundService.kt | 101 +++++ .../amethyst/service/call/IceServerConfig.kt | 52 +++ .../service/call/WebRtcCallSession.kt | 260 +++++++++++++ .../amethyst/ui/call/CallScreen.kt | 354 ++++++++++++++++++ .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../chats/privateDM/header/ChatroomHeader.kt | 30 +- .../amethyst/commons/call/CallManager.kt | 259 +++++++++++++ .../amethyst/commons/call/CallState.kt | 74 ++++ gradle/libs.versions.toml | 2 + .../nip100WebRtcCalls/WebRtcCallFactory.kt | 113 ++++++ .../events/CallAnswerEvent.kt | 67 ++++ .../events/CallHangupEvent.kt | 67 ++++ .../events/CallIceCandidateEvent.kt | 67 ++++ .../events/CallOfferEvent.kt | 74 ++++ .../events/CallRejectEvent.kt | 67 ++++ .../events/CallRenegotiateEvent.kt | 67 ++++ .../nip100WebRtcCalls/tags/CallIdTag.kt | 39 ++ .../nip100WebRtcCalls/tags/CallTypeTag.kt | 55 +++ .../tags/TagArrayBuilderExt.kt | 28 ++ .../quartz/utils/EventFactory.kt | 12 + 22 files changed, 1802 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 5b4516875..57630fc71 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -363,6 +363,9 @@ dependencies { // Voice anonymization DSP implementation libs.tarsosdsp + // WebRTC for voice/video calls + implementation libs.stream.webrtc.android + // Cbor for cashuB format implementation libs.kotlinx.serialization.cbor diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 4aae58459..9df886f66 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -39,6 +39,7 @@ + @@ -221,6 +222,12 @@ + + { + val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" + val notification = buildNotification(peerName) + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } else { + 0 + }, + ) + } + + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_NOT_STICKY + } + + private fun createNotificationChannel() { + val channel = + NotificationChannel( + CHANNEL_ID, + "Calls", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Ongoing call notification" + } + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + + private fun buildNotification(peerName: String): Notification = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Call with $peerName") + .setSmallIcon(R.drawable.amethyst) + .setOngoing(true) + .build() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt new file mode 100644 index 000000000..b83598961 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import org.webrtc.PeerConnection + +object IceServerConfig { + val defaultStunServers = + listOf( + PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(), + PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(), + ) + + fun buildIceServers(userTurnServers: List = emptyList()): List { + val servers = defaultStunServers.toMutableList() + userTurnServers.forEach { turn -> + servers.add( + PeerConnection.IceServer + .builder(turn.url) + .setUsername(turn.username) + .setPassword(turn.credential) + .createIceServer(), + ) + } + return servers + } +} + +data class TurnServerConfig( + val url: String, + val username: String, + val credential: String, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt new file mode 100644 index 000000000..db832d665 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import com.vitorpamplona.quartz.utils.Log +import org.webrtc.AudioSource +import org.webrtc.AudioTrack +import org.webrtc.DataChannel +import org.webrtc.DefaultVideoDecoderFactory +import org.webrtc.DefaultVideoEncoderFactory +import org.webrtc.EglBase +import org.webrtc.IceCandidate +import org.webrtc.MediaConstraints +import org.webrtc.MediaStream +import org.webrtc.PeerConnection +import org.webrtc.PeerConnectionFactory +import org.webrtc.RtpReceiver +import org.webrtc.SdpObserver +import org.webrtc.SessionDescription +import org.webrtc.VideoSource +import org.webrtc.VideoTrack + +private const val TAG = "WebRtcCallSession" + +class WebRtcCallSession( + private val context: Context, + private val iceServers: List, + private val onIceCandidate: (IceCandidate) -> Unit, + private val onPeerConnected: () -> Unit, + private val onRemoteStream: (MediaStream) -> Unit, + private val onDisconnected: () -> Unit, +) { + private var peerConnectionFactory: PeerConnectionFactory? = null + private var peerConnection: PeerConnection? = null + private var localAudioTrack: AudioTrack? = null + private var localVideoTrack: VideoTrack? = null + private var audioSource: AudioSource? = null + private var videoSource: VideoSource? = null + + val eglBase: EglBase = EglBase.create() + + fun initialize() { + PeerConnectionFactory.initialize( + PeerConnectionFactory + .InitializationOptions + .builder(context) + .createInitializationOptions(), + ) + + peerConnectionFactory = + PeerConnectionFactory + .builder() + .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext)) + .setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true)) + .createPeerConnectionFactory() + } + + fun createPeerConnection() { + val rtcConfig = + PeerConnection.RTCConfiguration(iceServers).apply { + sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN + continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY + } + + peerConnection = + peerConnectionFactory?.createPeerConnection( + rtcConfig, + object : PeerConnection.Observer { + override fun onIceCandidate(candidate: IceCandidate?) { + candidate?.let { onIceCandidate(it) } + } + + override fun onIceCandidatesRemoved(candidates: Array?) {} + + override fun onSignalingChange(state: PeerConnection.SignalingState?) {} + + override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) { + Log.d(TAG) { "ICE connection state: $state" } + when (state) { + PeerConnection.IceConnectionState.CONNECTED -> { + onPeerConnected() + } + + PeerConnection.IceConnectionState.DISCONNECTED, + PeerConnection.IceConnectionState.FAILED, + -> { + onDisconnected() + } + + else -> {} + } + } + + override fun onIceConnectionReceivingChange(receiving: Boolean) {} + + override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {} + + override fun onAddStream(stream: MediaStream?) { + stream?.let { onRemoteStream(it) } + } + + override fun onRemoveStream(stream: MediaStream?) {} + + override fun onDataChannel(channel: DataChannel?) {} + + override fun onRenegotiationNeeded() {} + + override fun onAddTrack( + receiver: RtpReceiver?, + streams: Array?, + ) {} + }, + ) + } + + fun addAudioTrack() { + val constraints = MediaConstraints() + audioSource = peerConnectionFactory?.createAudioSource(constraints) + localAudioTrack = + peerConnectionFactory?.createAudioTrack("audio0", audioSource).also { + peerConnection?.addTrack(it) + } + } + + fun addVideoTrack() { + videoSource = peerConnectionFactory?.createVideoSource(false) + localVideoTrack = + peerConnectionFactory?.createVideoTrack("video0", videoSource).also { + peerConnection?.addTrack(it) + } + } + + fun getLocalVideoSource(): VideoSource? = videoSource + + fun getLocalVideoTrack(): VideoTrack? = localVideoTrack + + fun createOffer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createOffer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create offer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun createAnswer(onSdpCreated: (SessionDescription) -> Unit) { + val constraints = + MediaConstraints().apply { + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true")) + mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true")) + } + + peerConnection?.createAnswer( + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) { + sdp?.let { + peerConnection?.setLocalDescription(noOpSdpObserver(), it) + onSdpCreated(it) + } + } + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "Create answer failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) {} + }, + constraints, + ) + } + + fun setRemoteDescription(sdp: SessionDescription) { + peerConnection?.setRemoteDescription(noOpSdpObserver(), sdp) + } + + fun addIceCandidate(candidate: IceCandidate) { + peerConnection?.addIceCandidate(candidate) + } + + fun setAudioEnabled(enabled: Boolean) { + localAudioTrack?.setEnabled(enabled) + } + + fun setVideoEnabled(enabled: Boolean) { + localVideoTrack?.setEnabled(enabled) + } + + fun dispose() { + localAudioTrack?.dispose() + localVideoTrack?.dispose() + audioSource?.dispose() + videoSource?.dispose() + peerConnection?.close() + peerConnection?.dispose() + peerConnectionFactory?.dispose() + eglBase.release() + + localAudioTrack = null + localVideoTrack = null + audioSource = null + videoSource = null + peerConnection = null + peerConnectionFactory = null + } + + private fun noOpSdpObserver() = + object : SdpObserver { + override fun onCreateSuccess(sdp: SessionDescription?) {} + + override fun onCreateFailure(error: String?) { + Log.e(TAG, "SDP operation failed: $error") + } + + override fun onSetSuccess() {} + + override fun onSetFailure(error: String?) { + Log.e(TAG, "SDP set failed: $error") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt new file mode 100644 index 000000000..3aa9c8d77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.call + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun CallScreen( + callManager: CallManager, + accountViewModel: AccountViewModel, + onCallEnded: () -> Unit, +) { + val callState by callManager.state.collectAsState() + val scope = rememberCoroutineScope() + + when (val state = callState) { + is CallState.Idle -> { + LaunchedEffect(Unit) { onCallEnded() } + } + + is CallState.Offering -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Calling...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.IncomingCall -> { + IncomingCallUI( + callerPubKey = state.callerPubKey, + callType = state.callType, + accountViewModel = accountViewModel, + onAccept = { /* handled by caller */ }, + onReject = { scope.launch { callManager.rejectCall() } }, + ) + } + + is CallState.Connecting -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Connecting...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.Connected -> { + ConnectedCallUI( + state = state, + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + onToggleMute = { callManager.toggleAudioMute() }, + onToggleVideo = { callManager.toggleVideo() }, + onToggleSpeaker = { callManager.toggleSpeaker() }, + ) + } + + is CallState.Ended -> { + LaunchedEffect(Unit) { + delay(2000) + callManager.reset() + onCallEnded() + } + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Call ended", + accountViewModel = accountViewModel, + onHangup = { onCallEnded() }, + ) + } + } +} + +@Composable +private fun CallInProgressUI( + peerPubKey: String, + statusText: String, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = statusText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +@Composable +private fun IncomingCallUI( + callerPubKey: String, + callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType, + accountViewModel: AccountViewModel, + onAccept: () -> Unit, + onReject: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Incoming ${callType.value} call...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + FloatingActionButton( + onClick = onReject, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Reject", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + FloatingActionButton( + onClick = onAccept, + containerColor = Color(0xFF4CAF50), + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.Call, + contentDescription = "Accept", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } +} + +@Composable +private fun ConnectedCallUI( + state: CallState.Connected, + accountViewModel: AccountViewModel, + onHangup: () -> Unit, + onToggleMute: () -> Unit, + onToggleVideo: () -> Unit, + onToggleSpeaker: () -> Unit, +) { + var elapsed by remember { mutableLongStateOf(0L) } + + LaunchedEffect(state.startedAtEpoch) { + while (true) { + elapsed = TimeUtils.now() - state.startedAtEpoch + delay(1000) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatDuration(elapsed), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = onToggleMute) { + Text(if (state.isAudioMuted) "Unmute" else "Mute") + } + IconButton(onClick = onToggleVideo) { + Text(if (state.isVideoEnabled) "Cam Off" else "Cam On") + } + IconButton(onClick = onToggleSpeaker) { + Text(if (state.isSpeakerOn) "Earpiece" else "Speaker") + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = onHangup, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +private fun formatDuration(seconds: Long): String { + val mins = seconds / 60 + val secs = seconds % 60 + return "%02d:%02d".format(mins, secs) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b6dcdbcc6..8f69aaaf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -302,6 +302,11 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ActiveCall( + val callId: String, + val peerPubKey: HexKey, + ) : Route() + @Serializable data class EventRedirect( val id: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt index ed24d5ccc..09085a2d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt @@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +52,7 @@ fun ChatroomHeader( room: ChatroomKey, modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, + onCallClick: ((String) -> Unit)? = null, onClick: () -> Unit, ) { if (room.users.size == 1) { @@ -56,6 +63,7 @@ fun ChatroomHeader( modifier = modifier, accountViewModel = accountViewModel, onClick = onClick, + onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } }, ) } } @@ -75,6 +83,7 @@ fun UserChatroomHeader( modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, onClick: () -> Unit, + onCallClick: (() -> Unit)? = null, ) { Column( Modifier @@ -91,9 +100,28 @@ fun UserChatroomHeader( size = Size34dp, ) - Column(modifier = Modifier.padding(start = 10.dp)) { + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) { UsernameDisplay(baseUser, accountViewModel = accountViewModel) } + + if (onCallClick != null) { + IconButton( + onClick = onCallClick, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = "Voice call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt new file mode 100644 index 000000000..97af4dcd4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.call + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class CallManager( + private val signer: NostrSigner, + private val scope: CoroutineScope, + private val isFollowing: (HexKey) -> Boolean, + private val publishEvent: (GiftWrapEvent) -> Unit, +) { + private val factory = WebRtcCallFactory() + + private val _state = MutableStateFlow(CallState.Idle) + val state: StateFlow = _state.asStateFlow() + + private var timeoutJob: Job? = null + + companion object { + const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + } + + suspend fun initiateCall( + calleePubKey: HexKey, + callType: CallType, + callId: String, + sdpOffer: String, + ) { + val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) + _state.value = CallState.Offering(callId, calleePubKey, callType) + publishEvent(result.wrap) + startTimeout(callId) + } + + fun onIncomingCallEvent(event: CallOfferEvent) { + val callerPubKey = event.pubKey + val callId = event.callId() ?: return + val callType = event.callType() ?: CallType.VOICE + + if (!isFollowing(callerPubKey)) return + + if (_state.value !is CallState.Idle) return + + _state.value = + CallState.IncomingCall( + callId = callId, + callerPubKey = callerPubKey, + callType = callType, + sdpOffer = event.sdpOffer(), + ) + startTimeout(callId) + } + + suspend fun acceptCall(sdpAnswer: String) { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) + _state.value = CallState.Connecting(current.callId, current.callerPubKey, current.callType) + cancelTimeout() + publishEvent(result.wrap) + } + + suspend fun rejectCall() { + val current = _state.value + if (current !is CallState.IncomingCall) return + + val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) + _state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onCallAnswered(event: CallAnswerEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) + cancelTimeout() + } + + fun onCallRejected(event: CallRejectEvent) { + val current = _state.value + if (current !is CallState.Offering) return + if (event.callId() != current.callId) return + + _state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) + cancelTimeout() + } + + fun onIceCandidate(event: CallIceCandidateEvent) { + // ICE candidates are handled by the WebRTC session directly. + // This method exists for the call manager to validate the call-id. + } + + fun onPeerConnected() { + val current = _state.value + if (current !is CallState.Connecting) return + + _state.value = + CallState.Connected( + callId = current.callId, + peerPubKey = current.peerPubKey, + callType = current.callType, + startedAtEpoch = TimeUtils.now(), + ) + } + + suspend fun hangup() { + val peerPubKey: HexKey + val callId: String + when (val current = _state.value) { + is CallState.Offering -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connecting -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + is CallState.Connected -> { + peerPubKey = current.peerPubKey + callId = current.callId + } + + else -> { + return + } + } + + val result = factory.createHangup(peerPubKey, callId, signer = signer) + _state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP) + cancelTimeout() + publishEvent(result.wrap) + } + + fun onPeerHangup(event: CallHangupEvent) { + val current = _state.value + val callId = event.callId() ?: return + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.Connecting -> current.callId + is CallState.Connected -> current.callId + is CallState.IncomingCall -> current.callId + else -> return + } + if (callId != currentCallId) return + + val peerPubKey = event.pubKey + _state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP) + cancelTimeout() + } + + fun onSignalingEvent(event: Event) { + when (event) { + is CallOfferEvent -> onIncomingCallEvent(event) + is CallAnswerEvent -> onCallAnswered(event) + is CallRejectEvent -> onCallRejected(event) + is CallHangupEvent -> onPeerHangup(event) + is CallIceCandidateEvent -> onIceCandidate(event) + } + } + + fun toggleAudioMute() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isAudioMuted = !current.isAudioMuted) + } + } + + fun toggleVideo() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isVideoEnabled = !current.isVideoEnabled) + } + } + + fun toggleSpeaker() { + val current = _state.value + if (current is CallState.Connected) { + _state.value = current.copy(isSpeakerOn = !current.isSpeakerOn) + } + } + + fun reset() { + _state.value = CallState.Idle + cancelTimeout() + } + + private fun startTimeout(callId: String) { + cancelTimeout() + timeoutJob = + scope.launch { + delay(CALL_TIMEOUT_MS) + val current = _state.value + val currentCallId = + when (current) { + is CallState.Offering -> current.callId + is CallState.IncomingCall -> current.callId + else -> null + } + if (currentCallId == callId) { + val peerPubKey = + when (current) { + is CallState.Offering -> current.peerPubKey + is CallState.IncomingCall -> current.callerPubKey + else -> return@launch + } + _state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT) + } + } + } + + private fun cancelTimeout() { + timeoutJob?.cancel() + timeoutJob = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt new file mode 100644 index 000000000..4a7e90630 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.call + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType + +@Immutable +sealed interface CallState { + data object Idle : CallState + + data class Offering( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class IncomingCall( + val callId: String, + val callerPubKey: HexKey, + val callType: CallType, + val sdpOffer: String, + ) : CallState + + data class Connecting( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + ) : CallState + + data class Connected( + val callId: String, + val peerPubKey: HexKey, + val callType: CallType, + val startedAtEpoch: Long, + val isAudioMuted: Boolean = false, + val isVideoEnabled: Boolean = true, + val isSpeakerOn: Boolean = false, + ) : CallState + + data class Ended( + val callId: String, + val peerPubKey: HexKey, + val reason: EndReason, + ) : CallState +} + +enum class EndReason { + HANGUP, + REJECTED, + TIMEOUT, + ERROR, + PEER_HANGUP, + PEER_REJECTED, +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc6e06459..60485652c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ secp256k1KmpJniAndroid = "0.23.0" securityCryptoKtx = "1.1.0" slf4j = "2.0.17" spotless = "8.4.0" +streamWebrtcAndroid = "1.3.8" tarsosdsp = "2.5" translate = "17.0.3" jetbrainsCompose = "1.10.3" @@ -157,6 +158,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines", secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } +stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" } tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt new file mode 100644 index 000000000..858c5b591 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +class WebRtcCallFactory { + data class Result( + val msg: Event, + val wrap: GiftWrapEvent, + ) + + suspend fun createCallOffer( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + callType: CallType, + signer: NostrSigner, + ): Result { + val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey) + return Result(signed, wrap) + } + + suspend fun createCallAnswer( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createIceCandidate( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createHangup( + peerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallHangupEvent.build(peerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createReject( + callerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallRejectEvent.build(callerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createRenegotiate( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt new file mode 100644 index 000000000..b09c7da97 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallAnswerEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpAnswer() = content + + companion object { + const val KIND = 25051 + const val ALT_DESCRIPTION = "WebRTC call answer" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpAnswer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt new file mode 100644 index 000000000..648cb6489 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallHangupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25053 + const val ALT_DESCRIPTION = "WebRTC call hangup" + const val EXPIRATION_SECONDS = 300L + + fun build( + peerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt new file mode 100644 index 000000000..8a3d13087 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallIceCandidateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun candidateJson() = content + + companion object { + const val KIND = 25052 + const val ALT_DESCRIPTION = "WebRTC ICE candidate" + const val EXPIRATION_SECONDS = 300L + + fun build( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, candidateJson, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt new file mode 100644 index 000000000..e4cd9be14 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25050 + const val ALT_DESCRIPTION = "WebRTC call offer" + const val EXPIRATION_SECONDS = 300L // 5 minutes + + fun build( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + type: CallType, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(calleePubKey) + callId(callId) + callType(type) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt new file mode 100644 index 000000000..84be6ad0f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRejectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25054 + const val ALT_DESCRIPTION = "WebRTC call rejection" + const val EXPIRATION_SECONDS = 300L + + fun build( + callerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt new file mode 100644 index 000000000..f38898f70 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRenegotiateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25055 + const val ALT_DESCRIPTION = "WebRTC call renegotiation" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt new file mode 100644 index 000000000..d70865550 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CallIdTag { + companion object { + const val TAG_NAME = "call-id" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(callId: String) = arrayOf(TAG_NAME, callId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt new file mode 100644 index 000000000..f0b055af9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class CallType( + val value: String, +) { + VOICE("voice"), + VIDEO("video"), + ; + + companion object { + fun fromString(value: String): CallType? = + when (value) { + "voice" -> VOICE + "video" -> VIDEO + else -> null + } + } +} + +class CallTypeTag { + companion object { + const val TAG_NAME = "call-type" + + fun parse(tag: Array): CallType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return CallType.fromString(tag[1]) + } + + fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt new file mode 100644 index 000000000..17631ee78 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.callId(callId: String) = addUnique(CallIdTag.assemble(callId)) + +fun TagArrayBuilder.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 06b51ad2e..446615569 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,6 +50,12 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -295,6 +301,12 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + CallAnswerEvent.KIND -> CallAnswerEvent(id, pubKey, createdAt, tags, content, sig) + CallHangupEvent.KIND -> CallHangupEvent(id, pubKey, createdAt, tags, content, sig) + CallIceCandidateEvent.KIND -> CallIceCandidateEvent(id, pubKey, createdAt, tags, content, sig) + CallOfferEvent.KIND -> CallOfferEvent(id, pubKey, createdAt, tags, content, sig) + CallRejectEvent.KIND -> CallRejectEvent(id, pubKey, createdAt, tags, content, sig) + CallRenegotiateEvent.KIND -> CallRenegotiateEvent(id, pubKey, createdAt, tags, content, sig) CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig) CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig) CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig) From 3c486a383b37b7bc6c5f3c1bd83f9c47b3d17be0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:13:12 +0000 Subject: [PATCH 24/63] docs: add NIP-100 draft for WebRTC calls over Nostr 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 --- .../quartz/nip100WebRtcCalls/NIP-100.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md new file mode 100644 index 000000000..c239fbe2d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md @@ -0,0 +1,265 @@ +NIP-100 +======= + +WebRTC Calls +------------ + +`draft` `optional` + +This NIP defines a protocol for establishing peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport. + +## Motivation + +Nostr users currently lack a way to make real-time voice or video calls without relying on centralized services. By using Nostr relays for WebRTC signaling and public STUN servers for NAT traversal, calls can be established in a fully decentralized manner — no custom server infrastructure is required. Once a WebRTC peer connection is established, the relay is no longer involved in the media stream. + +## Overview + +The protocol works as follows: + +1. **Caller** creates a signed call offer event containing an SDP offer +2. The event is **gift-wrapped** ([NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md)) and published to relays +3. **Callee** unwraps the event, verifies the signature, and decides whether to accept +4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer +5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal +6. A **direct WebRTC peer connection** is established for audio/video + +All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random ephemeral key already hides the sender from relay operators. + +## Event Kinds + +| Kind | Name | Description | +|-------|---------------------|----------------------------------------------| +| 25050 | Call Offer | SDP offer initiating a call | +| 25051 | Call Answer | SDP answer accepting a call | +| 25052 | ICE Candidate | ICE candidate for NAT traversal | +| 25053 | Call Hangup | Terminates an active or pending call | +| 25054 | Call Reject | Rejects an incoming call | +| 25055 | Call Renegotiate | New SDP offer for mid-call changes | + +## Tags + +All signaling events MUST include: + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `p` | Hex pubkey of the recipient | YES | +| `call-id` | UUID identifying the call session | YES | +| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be ~5 minutes from `created_at` | YES | +| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | + +Additional tags for **Call Offer** (kind 25050): + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `call-type` | `"voice"` or `"video"` | YES | + +## Event Structures + +### Call Offer (kind 25050) + +The `content` field contains the SDP offer string. + +```json +{ + "kind": 25050, + "pubkey": "", + "created_at": 1234567890, + "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["call-type", "video"], + ["expiration", "1234568190"], + ["alt", "WebRTC call offer"] + ], + "id": "", + "sig": "" +} +``` + +### Call Answer (kind 25051) + +The `content` field contains the SDP answer string. + +```json +{ + "kind": 25051, + "pubkey": "", + "created_at": 1234567895, + "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568195"], + ["alt", "WebRTC call answer"] + ], + "id": "", + "sig": "" +} +``` + +### ICE Candidate (kind 25052) + +The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. + +```json +{ + "kind": 25052, + "pubkey": "", + "created_at": 1234567896, + "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568196"], + ["alt", "WebRTC ICE candidate"] + ], + "id": "", + "sig": "" +} +``` + +### Call Hangup (kind 25053) + +The `content` field MAY contain a human-readable reason or be empty. + +```json +{ + "kind": 25053, + "pubkey": "", + "created_at": 1234568000, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568300"], + ["alt", "WebRTC call hangup"] + ], + "id": "", + "sig": "" +} +``` + +### Call Reject (kind 25054) + +The `content` field MAY contain a reason or be empty. + +```json +{ + "kind": 25054, + "pubkey": "", + "created_at": 1234567893, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568193"], + ["alt", "WebRTC call rejection"] + ], + "id": "", + "sig": "" +} +``` + +### Call Renegotiate (kind 25055) + +Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. + +```json +{ + "kind": 25055, + "pubkey": "", + "created_at": 1234568100, + "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568400"], + ["alt", "WebRTC call renegotiation"] + ], + "id": "", + "sig": "" +} +``` + +## Encryption and Delivery + +All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps: + +1. **Sign** the signaling event with the sender's key +2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption +3. **Publish** the gift wrap to the recipient's relay list + +The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides: + +- **NIP-44 encryption** — content is unreadable to relay operators +- **Random ephemeral pubkey** — the relay cannot identify the sender +- **`p` tag** — reveals only the recipient (necessary for delivery) + +Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message. + +## Protocol Flow + +### Initiating a Call + +``` +Caller Relay Callee + | | | + |-- GiftWrap(CallOffer) ------->| | + | |-- GiftWrap(CallOffer) ------->| + | | | + | | [Callee unwraps, verifies signature] + | | [Checks: is caller followed?] + | | [YES → ring / NO → ignore] + | | | + |<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------| + | | | + |<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->| + | | | + |============= WebRTC P2P Connection Established ===============| + | (relay no longer involved) | +``` + +### Ending a Call + +Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it. + +### Rejecting a Call + +The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state. + +## Spam Prevention + +Clients SHOULD implement call filtering: + +- **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored. +- **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window. +- **Expiration enforcement**: Clients MUST check the `expiration` tag and discard signaling events that have expired. + +## NAT Traversal + +This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: + +- Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`) +- Allow users to configure custom TURN servers for restrictive network environments +- Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer + +## Implementation Notes + +- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. +- Events SHOULD have short expiration times (~5 minutes) since signaling data is ephemeral and has no long-term value. +- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. +- Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. +- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. +- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. + +## References + +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure +- [NIP-31: Alt Tag](https://github.com/nostr-protocol/nips/blob/master/31.md) — Human-readable event descriptions +- [NIP-40: Expiration](https://github.com/nostr-protocol/nips/blob/master/40.md) — Event expiration timestamps +- [NIP-44: Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) — XChaCha20-Poly1305 encryption +- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md) — Encrypted event delivery +- [WebRTC Specification](https://www.w3.org/TR/webrtc/) — Peer-to-peer real-time communication +- [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) — Interactive Connectivity Establishment +- [nostr-protocol/nips#771](https://github.com/nostr-protocol/nips/issues/771) — WebRTC signaling discussion From d39c8801c918837b7999ccd6f1472da9845cd97a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:40:13 +0000 Subject: [PATCH 25/63] feat: wire WebRTC call signaling end-to-end 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 --- .../vitorpamplona/amethyst/model/Account.kt | 5 + .../amethyst/service/call/CallController.kt | 196 ++++++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 14 ++ .../ui/screen/loggedIn/AccountViewModel.kt | 36 ++++ .../loggedIn/DecryptAndIndexProcessor.kt | 20 ++ .../chats/privateDM/ChatroomScreen.kt | 18 +- .../privateDM/header/RenderRoomTopBar.kt | 19 ++ .../amethyst/commons/call/CallManager.kt | 7 +- 8 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e7e4eb3c6..0f30f4259 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1686,6 +1686,11 @@ class Account( suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) + suspend fun publishCallSignaling(wrap: GiftWrapEvent) { + val relayList = computeRelayListToBroadcast(wrap) + client.publish(wrap, relayList) + } + suspend fun updateStatus( oldStatus: AddressableNote, newStatus: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt new file mode 100644 index 000000000..57c37ab78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.webrtc.IceCandidate +import org.webrtc.MediaStream +import org.webrtc.SessionDescription +import java.util.UUID + +class CallController( + private val context: Context, + private val callManager: CallManager, + private val scope: CoroutineScope, + private val publishWrap: suspend (GiftWrapEvent) -> Unit, + private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, +) { + private var webRtcSession: WebRtcCallSession? = null + private val callFactory = WebRtcCallFactory() + private var currentCallId: String? = null + private var currentPeerPubKey: HexKey? = null + + fun initiateCall( + peerPubKey: HexKey, + callType: CallType, + ) { + val callId = UUID.randomUUID().toString() + currentCallId = callId + currentPeerPubKey = peerPubKey + + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + } + + webRtcSession?.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + } + } + } + + fun acceptIncomingCall(sdpOffer: String) { + val state = callManager.state.value + if (state !is CallState.IncomingCall) return + + currentCallId = state.callId + currentPeerPubKey = state.callerPubKey + + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (state.callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + } + + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.OFFER, sdpOffer), + ) + + webRtcSession?.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } + } + } + + fun onCallAnswerReceived(sdpAnswer: String) { + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), + ) + } + + fun onIceCandidateReceived(event: CallIceCandidateEvent) { + val json = event.candidateJson() + try { + val candidate = parseIceCandidate(json) + webRtcSession?.addIceCandidate(candidate) + } catch (_: Exception) { + // Ignore malformed ICE candidates + } + } + + fun hangup() { + scope.launch { callManager.hangup() } + cleanup() + } + + fun cleanup() { + stopForegroundService() + webRtcSession?.dispose() + webRtcSession = null + currentCallId = null + currentPeerPubKey = null + } + + private fun createWebRtcSession() { + val iceServers = IceServerConfig.buildIceServers() + + webRtcSession = + WebRtcCallSession( + context = context, + iceServers = iceServers, + onIceCandidate = { candidate -> onLocalIceCandidate(candidate) }, + onPeerConnected = { + callManager.onPeerConnected() + startForegroundService() + }, + onRemoteStream = { _: MediaStream -> }, + onDisconnected = { + scope.launch { callManager.hangup() } + cleanup() + }, + ) + webRtcSession?.initialize() + webRtcSession?.createPeerConnection() + } + + private fun onLocalIceCandidate(candidate: IceCandidate) { + val callId = currentCallId ?: return + val peerPubKey = currentPeerPubKey ?: return + val candidateJson = serializeIceCandidate(candidate) + + scope.launch { + val signer = signerProvider() + val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer) + publishWrap(result.wrap) + } + } + + private fun startForegroundService() { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_START + putExtra(CallForegroundService.EXTRA_PEER_NAME, currentPeerPubKey ?: "") + } + context.startForegroundService(intent) + } + + private fun stopForegroundService() { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_STOP + } + context.startService(intent) + } + + companion object { + fun serializeIceCandidate(candidate: IceCandidate): String = """{"candidate":"${candidate.sdp}","sdpMid":"${candidate.sdpMid}","sdpMLineIndex":${candidate.sdpMLineIndex}}""" + + fun parseIceCandidate(json: String): IceCandidate { + val candidateRegex = """"candidate"\s*:\s*"([^"]*)"""".toRegex() + val sdpMidRegex = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex() + val sdpMLineIndexRegex = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex() + + val sdp = candidateRegex.find(json)?.groupValues?.get(1) ?: "" + val sdpMid = sdpMidRegex.find(json)?.groupValues?.get(1) ?: "0" + val sdpMLineIndex = + sdpMLineIndexRegex + .find(json) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + + return IceCandidate(sdpMid, sdpMLineIndex, sdp) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index ed83311a8..a7827efc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.Disp import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress +import com.vitorpamplona.amethyst.ui.call.CallScreen import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd @@ -168,6 +169,11 @@ fun BuildNavigation( accountViewModel: AccountViewModel, nav: Nav, ) { + val context = androidx.compose.ui.platform.LocalContext.current + androidx.compose.runtime.LaunchedEffect(Unit) { + accountViewModel.initCallController(context) + } + NavHost( navController = nav.controller, startDestination = Route.Home, @@ -254,6 +260,14 @@ fun BuildNavigation( composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + composableFromEndArgs { + CallScreen( + callManager = accountViewModel.callManager, + accountViewModel = accountViewModel, + onCallEnded = { nav.popBack() }, + ) + } + composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1c01f7a6f..4d185e9f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers @@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker +import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -187,6 +189,40 @@ class AccountViewModel( val broadcastTracker = BroadcastTracker() val feedStates = AccountFeedContentStates(account, viewModelScope) + val callManager = + CallManager( + signer = account.signer, + scope = viewModelScope, + isFollowing = { account.isFollowing(it) }, + publishEvent = { wrap -> + viewModelScope.launch { + account.publishCallSignaling(wrap) + } + }, + ) + + var callController: CallController? = null + private set + + fun initCallController(context: Context) { + if (callController != null) return + val controller = + CallController( + context = context.applicationContext, + callManager = callManager, + scope = viewModelScope, + publishWrap = { wrap -> account.publishCallSignaling(wrap) }, + signerProvider = { account.signer }, + ) + callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) } + callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } + callController = controller + } + + init { + account.newNotesPreProcessor.callManager = callManager + } + val eventSync = EventSync( accountPubKey = account.signer.pubKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a569b5ac9..88a628fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -27,6 +28,12 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -42,6 +49,7 @@ import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, + var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -68,10 +76,22 @@ class EventProcessor( publicNote: Note, ) { when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager?.onSignalingEvent(event) + is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) + is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index c1d324abd..5580dc9f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -46,7 +46,23 @@ fun ChatroomScreen( DisappearingScaffold( isInvertedLayout = true, topBar = { - RenderRoomTopBar(roomId, accountViewModel, nav) + RenderRoomTopBar( + room = roomId, + accountViewModel = accountViewModel, + nav = nav, + onCallClick = { peerPubKey -> + accountViewModel.callController?.initiateCall( + peerPubKey, + com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE, + ) + nav.nav( + com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( + callId = "", + peerPubKey = peerPubKey, + ), + ) + }, + ) }, accountViewModel = accountViewModel, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 33513c931..8ec60cc3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -25,15 +25,19 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.EditNote import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -69,6 +73,7 @@ fun RenderRoomTopBar( room: ChatroomKey, accountViewModel: AccountViewModel, nav: INav, + onCallClick: ((String) -> Unit)? = null, ) { if (room.users.size == 1) { TopBarExtensibleWithBackButton( @@ -84,6 +89,20 @@ fun RenderRoomTopBar( Spacer(modifier = DoubleHorzSpacer) UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) + + if (onCallClick != null) { + IconButton( + onClick = { onCallClick(baseUser.pubkeyHex) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = "Voice call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } } }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 97af4dcd4..e312e608f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -51,6 +51,9 @@ class CallManager( private val _state = MutableStateFlow(CallState.Idle) val state: StateFlow = _state.asStateFlow() + var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null + var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null + private var timeoutJob: Job? = null companion object { @@ -115,6 +118,7 @@ class CallManager( _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) cancelTimeout() + onAnswerReceived?.invoke(event) } fun onCallRejected(event: CallRejectEvent) { @@ -127,8 +131,7 @@ class CallManager( } fun onIceCandidate(event: CallIceCandidateEvent) { - // ICE candidates are handled by the WebRTC session directly. - // This method exists for the call manager to validate the call-id. + onIceCandidateReceived?.invoke(event) } fun onPeerConnected() { From e59dbfebc42e0de1473a98df2c8ca4cf586e720d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:06:13 +0000 Subject: [PATCH 26/63] fix: register call event kinds in LocalCache consumer 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 --- .../com/vitorpamplona/amethyst/model/LocalCache.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 31fd76865..1d1c0cfd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,6 +96,12 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -2567,6 +2573,12 @@ object LocalCache : ILocalCache, ICacheProvider { is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CallAnswerEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallHangupEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallIceCandidateEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallOfferEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallRejectEvent -> consumeRegularEvent(event, relay, wasVerified) + is CallRenegotiateEvent -> consumeRegularEvent(event, relay, wasVerified) is ChannelCreateEvent -> consume(event, relay, wasVerified) is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ChannelHideMessageEvent -> consume(event, relay, wasVerified) From 69469ae75651fd4346d0ad83bc88405c6f5d5de8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:18:50 +0000 Subject: [PATCH 27/63] refactor: rename NIP-100 to NIP-AC for WebRTC calls Rename package nip100WebRtcCalls -> nipACWebRtcCalls and NIP-100.md -> NIP-AC.md across all modules. https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../vitorpamplona/amethyst/model/LocalCache.kt | 12 ++++++------ .../amethyst/service/call/CallController.kt | 6 +++--- .../vitorpamplona/amethyst/ui/call/CallScreen.kt | 2 +- .../screen/loggedIn/DecryptAndIndexProcessor.kt | 12 ++++++------ .../loggedIn/chats/privateDM/ChatroomScreen.kt | 2 +- .../amethyst/commons/call/CallManager.kt | 14 +++++++------- .../amethyst/commons/call/CallState.kt | 2 +- .../NIP-100.md => nipACWebRtcCalls/NIP-AC.md} | 4 ++-- .../WebRtcCallFactory.kt | 16 ++++++++-------- .../events/CallAnswerEvent.kt | 6 +++--- .../events/CallHangupEvent.kt | 6 +++--- .../events/CallIceCandidateEvent.kt | 6 +++--- .../events/CallOfferEvent.kt | 12 ++++++------ .../events/CallRejectEvent.kt | 6 +++--- .../events/CallRenegotiateEvent.kt | 6 +++--- .../tags/CallIdTag.kt | 2 +- .../tags/CallTypeTag.kt | 2 +- .../tags/TagArrayBuilderExt.kt | 2 +- .../vitorpamplona/quartz/utils/EventFactory.kt | 12 ++++++------ 19 files changed, 65 insertions(+), 65 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls/NIP-100.md => nipACWebRtcCalls/NIP-AC.md} (99%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/WebRtcCallFactory.kt (87%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallAnswerEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallHangupEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallIceCandidateEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallOfferEvent.kt (87%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallRejectEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/events/CallRenegotiateEvent.kt (93%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/CallIdTag.kt (96%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/CallTypeTag.kt (97%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/{nip100WebRtcCalls => nipACWebRtcCalls}/tags/TagArrayBuilderExt.kt (96%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1d1c0cfd4..ad1a43318 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,12 +96,6 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -222,6 +216,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 57c37ab78..a5e3637b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -25,10 +25,10 @@ import android.content.Intent import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.webrtc.IceCandidate diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 3aa9c8d77..337b16430 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -192,7 +192,7 @@ private fun CallInProgressUI( @Composable private fun IncomingCallUI( callerPubKey: String, - callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType, + callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType, accountViewModel: AccountViewModel, onAccept: () -> Unit, onReject: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 88a628fe9..b36bf9d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -28,12 +28,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -43,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 5580dc9f0..d2b6a9219 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -53,7 +53,7 @@ fun ChatroomScreen( onCallClick = { peerPubKey -> accountViewModel.callController?.initiateCall( peerPubKey, - com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType.VOICE, + com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VOICE, ) nav.nav( com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index e312e608f..7a63a5e76 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -23,14 +23,14 @@ package com.vitorpamplona.amethyst.commons.call import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip100WebRtcCalls.WebRtcCallFactory -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt index 4a7e90630..ea20a4667 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.call import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType @Immutable sealed interface CallState { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md similarity index 99% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index c239fbe2d..a0b8338f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -1,5 +1,5 @@ -NIP-100 -======= +NIP-AC +====== WebRTC Calls ------------ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index 858c5b591..74e278937 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -18,19 +18,19 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls +package com.vitorpamplona.quartz.nipACWebRtcCalls import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType class WebRtcCallFactory { data class Result( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt index b09c7da97..6f9baf445 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt index 648cb6489..46d6854ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt index 8a3d13087..bf4e2ab47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt similarity index 87% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt index e4cd9be14..f63a64c1f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,13 +26,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callType import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt index 84be6ad0f..6fb1c63bc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt similarity index 93% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt index f38898f70..8524d580d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events +package com.vitorpamplona.quartz.nipACWebRtcCalls.events import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,10 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId import com.vitorpamplona.quartz.utils.TimeUtils @Immutable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt index d70865550..371913dfd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt similarity index 97% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt index f0b055af9..0dc18bdf0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt similarity index 96% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt index 17631ee78..f58ba6177 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags +package com.vitorpamplona.quartz.nipACWebRtcCalls.tags import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 446615569..c4eb7d8d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,12 +50,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent @@ -244,6 +238,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent From 902f7d8c97c9c4a2c961f347220e9ec313259475 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:23:45 +0000 Subject: [PATCH 28/63] refactor: observe call events from LocalCache instead of EventProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ui/screen/loggedIn/AccountViewModel.kt | 27 ++++++++++++++++--- .../loggedIn/DecryptAndIndexProcessor.kt | 20 -------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 4d185e9f1..7a740b2af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -146,6 +146,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -219,10 +225,6 @@ class AccountViewModel( callController = controller } - init { - account.newNotesPreProcessor.callManager = callManager - } - val eventSync = EventSync( accountPubKey = account.signer.pubKey, @@ -1413,6 +1415,23 @@ class AccountViewModel( } } } + + viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + newNotes.forEach { note -> + val event = note.event ?: return@forEach + when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager.onSignalingEvent(event) + } + } + } + } } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index b36bf9d3d..a569b5ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn -import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -37,19 +36,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, - var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -76,22 +68,10 @@ class EventProcessor( publicNote: Note, ) { when (event) { - is CallOfferEvent, - is CallAnswerEvent, - is CallIceCandidateEvent, - is CallHangupEvent, - is CallRejectEvent, - is CallRenegotiateEvent, - -> callManager?.onSignalingEvent(event) - is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) - is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) - is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) - is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) - is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } From c12394be64190f315220bf2185f1d271c6ab3cb5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 1 Apr 2026 19:27:39 -0400 Subject: [PATCH 29/63] Fixes infinite loop --- .../vitorpamplona/amethyst/service/call/WebRtcCallSession.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index db832d665..aa9784449 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -86,7 +86,7 @@ class WebRtcCallSession( rtcConfig, object : PeerConnection.Observer { override fun onIceCandidate(candidate: IceCandidate?) { - candidate?.let { onIceCandidate(it) } + candidate?.let { this@WebRtcCallSession.onIceCandidate(it) } } override fun onIceCandidatesRemoved(candidates: Array?) {} From 01eb1dcd28e4c0d7fb89050431caf2dc2f2774ab Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 1 Apr 2026 19:48:01 -0400 Subject: [PATCH 30/63] Improves the language of the NIP --- .../quartz/nipACWebRtcCalls/NIP-AC.md | 70 +++++-------------- 1 file changed, 19 insertions(+), 51 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index a0b8338f6..0aab7141a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -6,11 +6,9 @@ WebRTC Calls `draft` `optional` -This NIP defines a protocol for establishing peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport. - -## Motivation - -Nostr users currently lack a way to make real-time voice or video calls without relying on centralized services. By using Nostr relays for WebRTC signaling and public STUN servers for NAT traversal, calls can be established in a fully decentralized manner — no custom server infrastructure is required. Once a WebRTC peer connection is established, the relay is no longer involved in the media stream. +This NIP defines a protocol for establishing private peer-to-peer voice and video calls between Nostr +users using WebRTC, with Nostr relays serving as the signaling transport and public STUN servers for +NAT traversal — no custom server infrastructure is required ## Overview @@ -23,7 +21,9 @@ The protocol works as follows: 5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal 6. A **direct WebRTC peer connection** is established for audio/video -All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random ephemeral key already hides the sender from relay operators. +All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. +Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's +random ephemeral key already hides the sender from relay operators. ## Event Kinds @@ -44,8 +44,6 @@ All signaling events MUST include: |---------------|-------------------------------------------------------|----------| | `p` | Hex pubkey of the recipient | YES | | `call-id` | UUID identifying the call session | YES | -| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be ~5 minutes from `created_at` | YES | -| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | Additional tags for **Call Offer** (kind 25050): @@ -59,21 +57,16 @@ Additional tags for **Call Offer** (kind 25050): The `content` field contains the SDP offer string. -```json +```yaml { "kind": 25050, - "pubkey": "", - "created_at": 1234567890, "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["call-type", "video"], - ["expiration", "1234568190"], - ["alt", "WebRTC call offer"] + ["call-type", "video"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -81,20 +74,15 @@ The `content` field contains the SDP offer string. The `content` field contains the SDP answer string. -```json +```yaml { "kind": 25051, - "pubkey": "", - "created_at": 1234567895, "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568195"], - ["alt", "WebRTC call answer"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -102,20 +90,15 @@ The `content` field contains the SDP answer string. The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. -```json +```yaml { "kind": 25052, - "pubkey": "", - "created_at": 1234567896, "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568196"], - ["alt", "WebRTC ICE candidate"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -123,20 +106,15 @@ The `content` field contains the ICE candidate as a JSON string with the fields The `content` field MAY contain a human-readable reason or be empty. -```json +```yaml { "kind": 25053, - "pubkey": "", - "created_at": 1234568000, "content": "", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568300"], - ["alt", "WebRTC call hangup"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -144,20 +122,15 @@ The `content` field MAY contain a human-readable reason or be empty. The `content` field MAY contain a reason or be empty. -```json +```yaml { "kind": 25054, - "pubkey": "", - "created_at": 1234567893, "content": "", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568193"], - ["alt", "WebRTC call rejection"] ], - "id": "", - "sig": "" + # other fields } ``` @@ -165,20 +138,15 @@ The `content` field MAY contain a reason or be empty. Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. -```json +```yaml { "kind": 25055, - "pubkey": "", - "created_at": 1234568100, "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568400"], - ["alt", "WebRTC call renegotiation"] ], - "id": "", - "sig": "" + # other fields } ``` From 71ef072c6dbd5264c256a4eb1fb5eac5e60fcd65 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 21:09:12 +0000 Subject: [PATCH 31/63] feat: add WebRTC voice/video call infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../nip100WebRtcCalls/WebRtcCallFactory.kt | 113 ++++++++++++++++++ .../events/CallAnswerEvent.kt | 67 +++++++++++ .../events/CallHangupEvent.kt | 67 +++++++++++ .../events/CallIceCandidateEvent.kt | 67 +++++++++++ .../events/CallOfferEvent.kt | 74 ++++++++++++ .../events/CallRejectEvent.kt | 67 +++++++++++ .../events/CallRenegotiateEvent.kt | 67 +++++++++++ .../nip100WebRtcCalls/tags/CallIdTag.kt | 39 ++++++ .../nip100WebRtcCalls/tags/CallTypeTag.kt | 55 +++++++++ .../tags/TagArrayBuilderExt.kt | 28 +++++ .../quartz/utils/EventFactory.kt | 6 + 11 files changed, 650 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt new file mode 100644 index 000000000..858c5b591 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +class WebRtcCallFactory { + data class Result( + val msg: Event, + val wrap: GiftWrapEvent, + ) + + suspend fun createCallOffer( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + callType: CallType, + signer: NostrSigner, + ): Result { + val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey) + return Result(signed, wrap) + } + + suspend fun createCallAnswer( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createIceCandidate( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createHangup( + peerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallHangupEvent.build(peerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } + + suspend fun createReject( + callerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): Result { + val template = CallRejectEvent.build(callerPubKey, callId, reason) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + return Result(signed, wrap) + } + + suspend fun createRenegotiate( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + return Result(signed, wrap) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt new file mode 100644 index 000000000..b09c7da97 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallAnswerEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpAnswer() = content + + companion object { + const val KIND = 25051 + const val ALT_DESCRIPTION = "WebRTC call answer" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpAnswer: String, + callerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpAnswer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt new file mode 100644 index 000000000..648cb6489 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallHangupEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25053 + const val ALT_DESCRIPTION = "WebRTC call hangup" + const val EXPIRATION_SECONDS = 300L + + fun build( + peerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt new file mode 100644 index 000000000..8a3d13087 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallIceCandidateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun candidateJson() = content + + companion object { + const val KIND = 25052 + const val ALT_DESCRIPTION = "WebRTC ICE candidate" + const val EXPIRATION_SECONDS = 300L + + fun build( + candidateJson: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, candidateJson, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt new file mode 100644 index 000000000..e4cd9be14 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25050 + const val ALT_DESCRIPTION = "WebRTC call offer" + const val EXPIRATION_SECONDS = 300L // 5 minutes + + fun build( + sdpOffer: String, + calleePubKey: HexKey, + callId: String, + type: CallType, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(calleePubKey) + callId(callId) + callType(type) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt new file mode 100644 index 000000000..84be6ad0f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRejectEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun reason() = content.ifEmpty { null } + + companion object { + const val KIND = 25054 + const val ALT_DESCRIPTION = "WebRTC call rejection" + const val EXPIRATION_SECONDS = 300L + + fun build( + callerPubKey: HexKey, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTag(callerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt new file mode 100644 index 000000000..f38898f70 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class CallRenegotiateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) + + fun sdpOffer() = content + + companion object { + const val KIND = 25055 + const val ALT_DESCRIPTION = "WebRTC call renegotiation" + const val EXPIRATION_SECONDS = 300L + + fun build( + sdpOffer: String, + peerPubKey: HexKey, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTag(peerPubKey) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt new file mode 100644 index 000000000..d70865550 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class CallIdTag { + companion object { + const val TAG_NAME = "call-id" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(callId: String) = arrayOf(TAG_NAME, callId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt new file mode 100644 index 000000000..f0b055af9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class CallType( + val value: String, +) { + VOICE("voice"), + VIDEO("video"), + ; + + companion object { + fun fromString(value: String): CallType? = + when (value) { + "voice" -> VOICE + "video" -> VIDEO + else -> null + } + } +} + +class CallTypeTag { + companion object { + const val TAG_NAME = "call-type" + + fun parse(tag: Array): CallType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return CallType.fromString(tag[1]) + } + + fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt new file mode 100644 index 000000000..17631ee78 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip100WebRtcCalls.tags + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.callId(callId: String) = addUnique(CallIdTag.assemble(callId)) + +fun TagArrayBuilder.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c4eb7d8d5..48f0d8f04 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,6 +50,12 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent From 4c4c21f6a41a2d3ab3407a6d0bd7640fc4bcb6c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:13:12 +0000 Subject: [PATCH 32/63] docs: add NIP-100 draft for WebRTC calls over Nostr 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 --- .../quartz/nip100WebRtcCalls/NIP-100.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md new file mode 100644 index 000000000..c239fbe2d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md @@ -0,0 +1,265 @@ +NIP-100 +======= + +WebRTC Calls +------------ + +`draft` `optional` + +This NIP defines a protocol for establishing peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport. + +## Motivation + +Nostr users currently lack a way to make real-time voice or video calls without relying on centralized services. By using Nostr relays for WebRTC signaling and public STUN servers for NAT traversal, calls can be established in a fully decentralized manner — no custom server infrastructure is required. Once a WebRTC peer connection is established, the relay is no longer involved in the media stream. + +## Overview + +The protocol works as follows: + +1. **Caller** creates a signed call offer event containing an SDP offer +2. The event is **gift-wrapped** ([NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md)) and published to relays +3. **Callee** unwraps the event, verifies the signature, and decides whether to accept +4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer +5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal +6. A **direct WebRTC peer connection** is established for audio/video + +All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random ephemeral key already hides the sender from relay operators. + +## Event Kinds + +| Kind | Name | Description | +|-------|---------------------|----------------------------------------------| +| 25050 | Call Offer | SDP offer initiating a call | +| 25051 | Call Answer | SDP answer accepting a call | +| 25052 | ICE Candidate | ICE candidate for NAT traversal | +| 25053 | Call Hangup | Terminates an active or pending call | +| 25054 | Call Reject | Rejects an incoming call | +| 25055 | Call Renegotiate | New SDP offer for mid-call changes | + +## Tags + +All signaling events MUST include: + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `p` | Hex pubkey of the recipient | YES | +| `call-id` | UUID identifying the call session | YES | +| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be ~5 minutes from `created_at` | YES | +| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | + +Additional tags for **Call Offer** (kind 25050): + +| Tag | Description | Required | +|---------------|-------------------------------------------------------|----------| +| `call-type` | `"voice"` or `"video"` | YES | + +## Event Structures + +### Call Offer (kind 25050) + +The `content` field contains the SDP offer string. + +```json +{ + "kind": 25050, + "pubkey": "", + "created_at": 1234567890, + "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["call-type", "video"], + ["expiration", "1234568190"], + ["alt", "WebRTC call offer"] + ], + "id": "", + "sig": "" +} +``` + +### Call Answer (kind 25051) + +The `content` field contains the SDP answer string. + +```json +{ + "kind": 25051, + "pubkey": "", + "created_at": 1234567895, + "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568195"], + ["alt", "WebRTC call answer"] + ], + "id": "", + "sig": "" +} +``` + +### ICE Candidate (kind 25052) + +The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. + +```json +{ + "kind": 25052, + "pubkey": "", + "created_at": 1234567896, + "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568196"], + ["alt", "WebRTC ICE candidate"] + ], + "id": "", + "sig": "" +} +``` + +### Call Hangup (kind 25053) + +The `content` field MAY contain a human-readable reason or be empty. + +```json +{ + "kind": 25053, + "pubkey": "", + "created_at": 1234568000, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568300"], + ["alt", "WebRTC call hangup"] + ], + "id": "", + "sig": "" +} +``` + +### Call Reject (kind 25054) + +The `content` field MAY contain a reason or be empty. + +```json +{ + "kind": 25054, + "pubkey": "", + "created_at": 1234567893, + "content": "", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568193"], + ["alt", "WebRTC call rejection"] + ], + "id": "", + "sig": "" +} +``` + +### Call Renegotiate (kind 25055) + +Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. + +```json +{ + "kind": 25055, + "pubkey": "", + "created_at": 1234568100, + "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", + "tags": [ + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568400"], + ["alt", "WebRTC call renegotiation"] + ], + "id": "", + "sig": "" +} +``` + +## Encryption and Delivery + +All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps: + +1. **Sign** the signaling event with the sender's key +2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption +3. **Publish** the gift wrap to the recipient's relay list + +The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides: + +- **NIP-44 encryption** — content is unreadable to relay operators +- **Random ephemeral pubkey** — the relay cannot identify the sender +- **`p` tag** — reveals only the recipient (necessary for delivery) + +Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message. + +## Protocol Flow + +### Initiating a Call + +``` +Caller Relay Callee + | | | + |-- GiftWrap(CallOffer) ------->| | + | |-- GiftWrap(CallOffer) ------->| + | | | + | | [Callee unwraps, verifies signature] + | | [Checks: is caller followed?] + | | [YES → ring / NO → ignore] + | | | + |<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------| + | | | + |<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->| + | | | + |============= WebRTC P2P Connection Established ===============| + | (relay no longer involved) | +``` + +### Ending a Call + +Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it. + +### Rejecting a Call + +The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state. + +## Spam Prevention + +Clients SHOULD implement call filtering: + +- **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored. +- **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window. +- **Expiration enforcement**: Clients MUST check the `expiration` tag and discard signaling events that have expired. + +## NAT Traversal + +This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: + +- Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`) +- Allow users to configure custom TURN servers for restrictive network environments +- Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer + +## Implementation Notes + +- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. +- Events SHOULD have short expiration times (~5 minutes) since signaling data is ephemeral and has no long-term value. +- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. +- Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. +- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. +- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. + +## References + +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure +- [NIP-31: Alt Tag](https://github.com/nostr-protocol/nips/blob/master/31.md) — Human-readable event descriptions +- [NIP-40: Expiration](https://github.com/nostr-protocol/nips/blob/master/40.md) — Event expiration timestamps +- [NIP-44: Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) — XChaCha20-Poly1305 encryption +- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md) — Encrypted event delivery +- [WebRTC Specification](https://www.w3.org/TR/webrtc/) — Peer-to-peer real-time communication +- [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) — Interactive Connectivity Establishment +- [nostr-protocol/nips#771](https://github.com/nostr-protocol/nips/issues/771) — WebRTC signaling discussion From d27137c5efe3c1c8852d3c172060f0be22eeba10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 22:40:13 +0000 Subject: [PATCH 33/63] feat: wire WebRTC call signaling end-to-end 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 --- .../loggedIn/DecryptAndIndexProcessor.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a569b5ac9..88a628fe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -27,6 +28,12 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -42,6 +49,7 @@ import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, + var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -68,10 +76,22 @@ class EventProcessor( publicNote: Note, ) { when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager?.onSignalingEvent(event) + is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) + is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } From 0683a9b61215d35ebd9652698b622396c0c6924e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:06:13 +0000 Subject: [PATCH 34/63] fix: register call event kinds in LocalCache consumer 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 --- .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ad1a43318..0b7038ed2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,6 +96,12 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent From 76ddeeaa3af1ce45133dba458fcb4a6c15c5f0c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:18:50 +0000 Subject: [PATCH 35/63] refactor: rename NIP-100 to NIP-AC for WebRTC calls Rename package nip100WebRtcCalls -> nipACWebRtcCalls and NIP-100.md -> NIP-AC.md across all modules. https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../amethyst/model/LocalCache.kt | 6 - .../loggedIn/DecryptAndIndexProcessor.kt | 12 +- .../quartz/nip100WebRtcCalls/NIP-100.md | 265 ------------------ .../nip100WebRtcCalls/WebRtcCallFactory.kt | 113 -------- .../events/CallAnswerEvent.kt | 67 ----- .../events/CallHangupEvent.kt | 67 ----- .../events/CallIceCandidateEvent.kt | 67 ----- .../events/CallOfferEvent.kt | 74 ----- .../events/CallRejectEvent.kt | 67 ----- .../events/CallRenegotiateEvent.kt | 67 ----- .../nip100WebRtcCalls/tags/CallIdTag.kt | 39 --- .../nip100WebRtcCalls/tags/CallTypeTag.kt | 55 ---- .../tags/TagArrayBuilderExt.kt | 28 -- .../quartz/utils/EventFactory.kt | 6 - 14 files changed, 6 insertions(+), 927 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 0b7038ed2..ad1a43318 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -96,12 +96,6 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 88a628fe9..b36bf9d3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -28,12 +28,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.IEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -43,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md deleted file mode 100644 index c239fbe2d..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/NIP-100.md +++ /dev/null @@ -1,265 +0,0 @@ -NIP-100 -======= - -WebRTC Calls ------------- - -`draft` `optional` - -This NIP defines a protocol for establishing peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport. - -## Motivation - -Nostr users currently lack a way to make real-time voice or video calls without relying on centralized services. By using Nostr relays for WebRTC signaling and public STUN servers for NAT traversal, calls can be established in a fully decentralized manner — no custom server infrastructure is required. Once a WebRTC peer connection is established, the relay is no longer involved in the media stream. - -## Overview - -The protocol works as follows: - -1. **Caller** creates a signed call offer event containing an SDP offer -2. The event is **gift-wrapped** ([NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md)) and published to relays -3. **Callee** unwraps the event, verifies the signature, and decides whether to accept -4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer -5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal -6. A **direct WebRTC peer connection** is established for audio/video - -All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy. Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's random ephemeral key already hides the sender from relay operators. - -## Event Kinds - -| Kind | Name | Description | -|-------|---------------------|----------------------------------------------| -| 25050 | Call Offer | SDP offer initiating a call | -| 25051 | Call Answer | SDP answer accepting a call | -| 25052 | ICE Candidate | ICE candidate for NAT traversal | -| 25053 | Call Hangup | Terminates an active or pending call | -| 25054 | Call Reject | Rejects an incoming call | -| 25055 | Call Renegotiate | New SDP offer for mid-call changes | - -## Tags - -All signaling events MUST include: - -| Tag | Description | Required | -|---------------|-------------------------------------------------------|----------| -| `p` | Hex pubkey of the recipient | YES | -| `call-id` | UUID identifying the call session | YES | -| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be ~5 minutes from `created_at` | YES | -| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | - -Additional tags for **Call Offer** (kind 25050): - -| Tag | Description | Required | -|---------------|-------------------------------------------------------|----------| -| `call-type` | `"voice"` or `"video"` | YES | - -## Event Structures - -### Call Offer (kind 25050) - -The `content` field contains the SDP offer string. - -```json -{ - "kind": 25050, - "pubkey": "", - "created_at": 1234567890, - "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", - "tags": [ - ["p", ""], - ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["call-type", "video"], - ["expiration", "1234568190"], - ["alt", "WebRTC call offer"] - ], - "id": "", - "sig": "" -} -``` - -### Call Answer (kind 25051) - -The `content` field contains the SDP answer string. - -```json -{ - "kind": 25051, - "pubkey": "", - "created_at": 1234567895, - "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", - "tags": [ - ["p", ""], - ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568195"], - ["alt", "WebRTC call answer"] - ], - "id": "", - "sig": "" -} -``` - -### ICE Candidate (kind 25052) - -The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. - -```json -{ - "kind": 25052, - "pubkey": "", - "created_at": 1234567896, - "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", - "tags": [ - ["p", ""], - ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568196"], - ["alt", "WebRTC ICE candidate"] - ], - "id": "", - "sig": "" -} -``` - -### Call Hangup (kind 25053) - -The `content` field MAY contain a human-readable reason or be empty. - -```json -{ - "kind": 25053, - "pubkey": "", - "created_at": 1234568000, - "content": "", - "tags": [ - ["p", ""], - ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568300"], - ["alt", "WebRTC call hangup"] - ], - "id": "", - "sig": "" -} -``` - -### Call Reject (kind 25054) - -The `content` field MAY contain a reason or be empty. - -```json -{ - "kind": 25054, - "pubkey": "", - "created_at": 1234567893, - "content": "", - "tags": [ - ["p", ""], - ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568193"], - ["alt", "WebRTC call rejection"] - ], - "id": "", - "sig": "" -} -``` - -### Call Renegotiate (kind 25055) - -Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. - -```json -{ - "kind": 25055, - "pubkey": "", - "created_at": 1234568100, - "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", - "tags": [ - ["p", ""], - ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["expiration", "1234568400"], - ["alt", "WebRTC call renegotiation"] - ], - "id": "", - "sig": "" -} -``` - -## Encryption and Delivery - -All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps: - -1. **Sign** the signaling event with the sender's key -2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption -3. **Publish** the gift wrap to the recipient's relay list - -The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides: - -- **NIP-44 encryption** — content is unreadable to relay operators -- **Random ephemeral pubkey** — the relay cannot identify the sender -- **`p` tag** — reveals only the recipient (necessary for delivery) - -Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message. - -## Protocol Flow - -### Initiating a Call - -``` -Caller Relay Callee - | | | - |-- GiftWrap(CallOffer) ------->| | - | |-- GiftWrap(CallOffer) ------->| - | | | - | | [Callee unwraps, verifies signature] - | | [Checks: is caller followed?] - | | [YES → ring / NO → ignore] - | | | - |<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------| - | | | - |<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->| - | | | - |============= WebRTC P2P Connection Established ===============| - | (relay no longer involved) | -``` - -### Ending a Call - -Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it. - -### Rejecting a Call - -The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state. - -## Spam Prevention - -Clients SHOULD implement call filtering: - -- **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored. -- **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window. -- **Expiration enforcement**: Clients MUST check the `expiration` tag and discard signaling events that have expired. - -## NAT Traversal - -This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: - -- Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`) -- Allow users to configure custom TURN servers for restrictive network environments -- Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer - -## Implementation Notes - -- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. -- Events SHOULD have short expiration times (~5 minutes) since signaling data is ephemeral and has no long-term value. -- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. -- Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. -- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. -- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. - -## References - -- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure -- [NIP-31: Alt Tag](https://github.com/nostr-protocol/nips/blob/master/31.md) — Human-readable event descriptions -- [NIP-40: Expiration](https://github.com/nostr-protocol/nips/blob/master/40.md) — Event expiration timestamps -- [NIP-44: Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) — XChaCha20-Poly1305 encryption -- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md) — Encrypted event delivery -- [WebRTC Specification](https://www.w3.org/TR/webrtc/) — Peer-to-peer real-time communication -- [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) — Interactive Connectivity Establishment -- [nostr-protocol/nips#771](https://github.com/nostr-protocol/nips/issues/771) — WebRTC signaling discussion diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt deleted file mode 100644 index 858c5b591..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/WebRtcCallFactory.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent - -class WebRtcCallFactory { - data class Result( - val msg: Event, - val wrap: GiftWrapEvent, - ) - - suspend fun createCallOffer( - sdpOffer: String, - calleePubKey: HexKey, - callId: String, - callType: CallType, - signer: NostrSigner, - ): Result { - val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType) - val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey) - return Result(signed, wrap) - } - - suspend fun createCallAnswer( - sdpAnswer: String, - callerPubKey: HexKey, - callId: String, - signer: NostrSigner, - ): Result { - val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId) - val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) - return Result(signed, wrap) - } - - suspend fun createIceCandidate( - candidateJson: String, - peerPubKey: HexKey, - callId: String, - signer: NostrSigner, - ): Result { - val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId) - val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) - return Result(signed, wrap) - } - - suspend fun createHangup( - peerPubKey: HexKey, - callId: String, - reason: String = "", - signer: NostrSigner, - ): Result { - val template = CallHangupEvent.build(peerPubKey, callId, reason) - val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) - return Result(signed, wrap) - } - - suspend fun createReject( - callerPubKey: HexKey, - callId: String, - reason: String = "", - signer: NostrSigner, - ): Result { - val template = CallRejectEvent.build(callerPubKey, callId, reason) - val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) - return Result(signed, wrap) - } - - suspend fun createRenegotiate( - sdpOffer: String, - peerPubKey: HexKey, - callId: String, - signer: NostrSigner, - ): Result { - val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId) - val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) - return Result(signed, wrap) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt deleted file mode 100644 index b09c7da97..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallAnswerEvent.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.expiration -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class CallAnswerEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) - - fun sdpAnswer() = content - - companion object { - const val KIND = 25051 - const val ALT_DESCRIPTION = "WebRTC call answer" - const val EXPIRATION_SECONDS = 300L - - fun build( - sdpAnswer: String, - callerPubKey: HexKey, - callId: String, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, sdpAnswer, createdAt) { - alt(ALT_DESCRIPTION) - pTag(callerPubKey) - callId(callId) - expiration(createdAt + EXPIRATION_SECONDS) - initializer() - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt deleted file mode 100644 index 648cb6489..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallHangupEvent.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.expiration -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class CallHangupEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) - - fun reason() = content.ifEmpty { null } - - companion object { - const val KIND = 25053 - const val ALT_DESCRIPTION = "WebRTC call hangup" - const val EXPIRATION_SECONDS = 300L - - fun build( - peerPubKey: HexKey, - callId: String, - reason: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, reason, createdAt) { - alt(ALT_DESCRIPTION) - pTag(peerPubKey) - callId(callId) - expiration(createdAt + EXPIRATION_SECONDS) - initializer() - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt deleted file mode 100644 index 8a3d13087..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallIceCandidateEvent.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.expiration -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class CallIceCandidateEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) - - fun candidateJson() = content - - companion object { - const val KIND = 25052 - const val ALT_DESCRIPTION = "WebRTC ICE candidate" - const val EXPIRATION_SECONDS = 300L - - fun build( - candidateJson: String, - peerPubKey: HexKey, - callId: String, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, candidateJson, createdAt) { - alt(ALT_DESCRIPTION) - pTag(peerPubKey) - callId(callId) - expiration(createdAt + EXPIRATION_SECONDS) - initializer() - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt deleted file mode 100644 index e4cd9be14..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallOfferEvent.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.expiration -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class CallOfferEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) - - fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse) - - fun sdpOffer() = content - - companion object { - const val KIND = 25050 - const val ALT_DESCRIPTION = "WebRTC call offer" - const val EXPIRATION_SECONDS = 300L // 5 minutes - - fun build( - sdpOffer: String, - calleePubKey: HexKey, - callId: String, - type: CallType, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, sdpOffer, createdAt) { - alt(ALT_DESCRIPTION) - pTag(calleePubKey) - callId(callId) - callType(type) - expiration(createdAt + EXPIRATION_SECONDS) - initializer() - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt deleted file mode 100644 index 84be6ad0f..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRejectEvent.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.expiration -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class CallRejectEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) - - fun reason() = content.ifEmpty { null } - - companion object { - const val KIND = 25054 - const val ALT_DESCRIPTION = "WebRTC call rejection" - const val EXPIRATION_SECONDS = 300L - - fun build( - callerPubKey: HexKey, - callId: String, - reason: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, reason, createdAt) { - alt(ALT_DESCRIPTION) - pTag(callerPubKey) - callId(callId) - expiration(createdAt + EXPIRATION_SECONDS) - initializer() - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt deleted file mode 100644 index f38898f70..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/events/CallRenegotiateEvent.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.events - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallIdTag -import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.nip40Expiration.expiration -import com.vitorpamplona.quartz.utils.TimeUtils - -@Immutable -class CallRenegotiateEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { - fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse) - - fun sdpOffer() = content - - companion object { - const val KIND = 25055 - const val ALT_DESCRIPTION = "WebRTC call renegotiation" - const val EXPIRATION_SECONDS = 300L - - fun build( - sdpOffer: String, - peerPubKey: HexKey, - callId: String, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, sdpOffer, createdAt) { - alt(ALT_DESCRIPTION) - pTag(peerPubKey) - callId(callId) - expiration(createdAt + EXPIRATION_SECONDS) - initializer() - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt deleted file mode 100644 index d70865550..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallIdTag.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags - -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -class CallIdTag { - companion object { - const val TAG_NAME = "call-id" - - fun parse(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - return tag[1] - } - - fun assemble(callId: String) = arrayOf(TAG_NAME, callId) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt deleted file mode 100644 index f0b055af9..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/CallTypeTag.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags - -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.ensure - -enum class CallType( - val value: String, -) { - VOICE("voice"), - VIDEO("video"), - ; - - companion object { - fun fromString(value: String): CallType? = - when (value) { - "voice" -> VOICE - "video" -> VIDEO - else -> null - } - } -} - -class CallTypeTag { - companion object { - const val TAG_NAME = "call-type" - - fun parse(tag: Array): CallType? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - return CallType.fromString(tag[1]) - } - - fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt deleted file mode 100644 index 17631ee78..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip100WebRtcCalls/tags/TagArrayBuilderExt.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip100WebRtcCalls.tags - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder - -fun TagArrayBuilder.callId(callId: String) = addUnique(CallIdTag.assemble(callId)) - -fun TagArrayBuilder.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 48f0d8f04..c4eb7d8d5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -50,12 +50,6 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent From 0c43e11d4653e5584dc6c469f695366bb72d8228 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:23:45 +0000 Subject: [PATCH 36/63] refactor: observe call events from LocalCache instead of EventProcessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../loggedIn/DecryptAndIndexProcessor.kt | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index b36bf9d3d..a569b5ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn -import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -37,19 +36,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException class EventProcessor( private val account: Account, private val cache: LocalCache, - var callManager: CallManager? = null, ) { private val chatHandler = ChatHandler(account.chatroomList) private val draftHandler = DraftEventHandler(account, cache) @@ -76,22 +68,10 @@ class EventProcessor( publicNote: Note, ) { when (event) { - is CallOfferEvent, - is CallAnswerEvent, - is CallIceCandidateEvent, - is CallHangupEvent, - is CallRejectEvent, - is CallRenegotiateEvent, - -> callManager?.onSignalingEvent(event) - is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) - is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) - is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) - is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) - is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } From 43776c5bdc1a73f533b4aa304139b996d23e7655 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 23:47:35 +0000 Subject: [PATCH 37/63] feat: wire incoming call navigation and accept button - 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 --- .../amethyst/ui/call/CallScreen.kt | 4 +++- .../amethyst/ui/navigation/AppNavigation.kt | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 337b16430..b3f1e687e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -66,6 +67,7 @@ import kotlinx.coroutines.launch @Composable fun CallScreen( callManager: CallManager, + callController: CallController?, accountViewModel: AccountViewModel, onCallEnded: () -> Unit, ) { @@ -91,7 +93,7 @@ fun CallScreen( callerPubKey = state.callerPubKey, callType = state.callType, accountViewModel = accountViewModel, - onAccept = { /* handled by caller */ }, + onAccept = { callController?.acceptIncomingCall(state.sdpOffer) }, onReject = { scope.launch { callManager.rejectCall() } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index a7827efc0..c78c9c0c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -28,6 +28,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +40,7 @@ import androidx.core.util.Consumer import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen @@ -48,6 +50,7 @@ import com.vitorpamplona.amethyst.ui.call.CallScreen import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd +import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -162,6 +165,23 @@ fun AppNavigation( DisplayNotifyMessages(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) + + ObserveIncomingCalls(accountViewModel, nav) +} + +@Composable +private fun ObserveIncomingCalls( + accountViewModel: AccountViewModel, + nav: INav, +) { + val callState by accountViewModel.callManager.state.collectAsState() + + LaunchedEffect(callState) { + val state = callState + if (state is CallState.IncomingCall) { + nav.nav(Route.ActiveCall(state.callId, state.callerPubKey)) + } + } } @Composable @@ -263,6 +283,7 @@ fun BuildNavigation( composableFromEndArgs { CallScreen( callManager = accountViewModel.callManager, + callController = accountViewModel.callController, accountViewModel = accountViewModel, onCallEnded = { nav.popBack() }, ) From 8ddec3a0d748bebafa394427e9f9109e5735e4e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:06:35 +0000 Subject: [PATCH 38/63] fix: auto-reset CallManager state after call ends 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 --- .../amethyst/service/call/CallController.kt | 10 ++++++ .../amethyst/ui/call/CallScreen.kt | 5 --- .../amethyst/commons/call/CallManager.kt | 35 ++++++++++++++----- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index a5e3637b6..a91ff58aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -48,6 +48,16 @@ class CallController( private var currentCallId: String? = null private var currentPeerPubKey: HexKey? = null + init { + scope.launch { + callManager.state.collect { state -> + if (state is CallState.Ended && webRtcSession != null) { + cleanup() + } + } + } + } + fun initiateCall( peerPubKey: HexKey, callType: CallType, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index b3f1e687e..dadd3b2f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -119,11 +119,6 @@ fun CallScreen( } is CallState.Ended -> { - LaunchedEffect(Unit) { - delay(2000) - callManager.reset() - onCallEnded() - } CallInProgressUI( peerPubKey = state.peerPubKey, statusText = "Call ended", diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 7a63a5e76..6527c80d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -55,9 +55,11 @@ class CallManager( var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null private var timeoutJob: Job? = null + private var resetJob: Job? = null companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting } suspend fun initiateCall( @@ -106,8 +108,7 @@ class CallManager( if (current !is CallState.IncomingCall) return val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) - _state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED) - cancelTimeout() + transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED) publishEvent(result.wrap) } @@ -126,8 +127,7 @@ class CallManager( if (current !is CallState.Offering) return if (event.callId() != current.callId) return - _state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) - cancelTimeout() + transitionToEnded(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) } fun onIceCandidate(event: CallIceCandidateEvent) { @@ -172,8 +172,7 @@ class CallManager( } val result = factory.createHangup(peerPubKey, callId, signer = signer) - _state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP) - cancelTimeout() + transitionToEnded(callId, peerPubKey, EndReason.HANGUP) publishEvent(result.wrap) } @@ -191,8 +190,7 @@ class CallManager( if (callId != currentCallId) return val peerPubKey = event.pubKey - _state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP) - cancelTimeout() + transitionToEnded(callId, peerPubKey, EndReason.PEER_HANGUP) } fun onSignalingEvent(event: Event) { @@ -229,6 +227,25 @@ class CallManager( fun reset() { _state.value = CallState.Idle cancelTimeout() + resetJob?.cancel() + resetJob = null + } + + private fun transitionToEnded( + callId: String, + peerPubKey: HexKey, + reason: EndReason, + ) { + _state.value = CallState.Ended(callId, peerPubKey, reason) + cancelTimeout() + resetJob?.cancel() + resetJob = + scope.launch { + delay(ENDED_DISPLAY_MS) + if (_state.value is CallState.Ended) { + _state.value = CallState.Idle + } + } } private fun startTimeout(callId: String) { @@ -250,7 +267,7 @@ class CallManager( is CallState.IncomingCall -> current.callerPubKey else -> return@launch } - _state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT) + transitionToEnded(callId, peerPubKey, EndReason.TIMEOUT) } } } From 0688a46604d8e8c9c7211d73f132ec2b180c10c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:22:41 +0000 Subject: [PATCH 39/63] fix: buffer ICE candidates and discard stale signaling events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/service/call/CallController.kt | 26 +++++++++++++++++-- .../amethyst/commons/call/CallManager.kt | 5 ++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index a91ff58aa..9280010ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -35,6 +35,7 @@ import org.webrtc.IceCandidate import org.webrtc.MediaStream import org.webrtc.SessionDescription import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList class CallController( private val context: Context, @@ -47,6 +48,8 @@ class CallController( private val callFactory = WebRtcCallFactory() private var currentCallId: String? = null private var currentPeerPubKey: HexKey? = null + private var remoteDescriptionSet = false + private val pendingIceCandidates = CopyOnWriteArrayList() init { scope.launch { @@ -65,6 +68,8 @@ class CallController( val callId = UUID.randomUUID().toString() currentCallId = callId currentPeerPubKey = peerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() createWebRtcSession() webRtcSession?.addAudioTrack() @@ -85,6 +90,8 @@ class CallController( currentCallId = state.callId currentPeerPubKey = state.callerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() createWebRtcSession() webRtcSession?.addAudioTrack() @@ -95,6 +102,7 @@ class CallController( webRtcSession?.setRemoteDescription( SessionDescription(SessionDescription.Type.OFFER, sdpOffer), ) + flushPendingIceCandidates() webRtcSession?.createAnswer { sdp -> scope.launch { @@ -107,18 +115,31 @@ class CallController( webRtcSession?.setRemoteDescription( SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), ) + flushPendingIceCandidates() } fun onIceCandidateReceived(event: CallIceCandidateEvent) { val json = event.candidateJson() try { val candidate = parseIceCandidate(json) - webRtcSession?.addIceCandidate(candidate) + if (webRtcSession != null && remoteDescriptionSet) { + webRtcSession?.addIceCandidate(candidate) + } else { + pendingIceCandidates.add(candidate) + } } catch (_: Exception) { // Ignore malformed ICE candidates } } + private fun flushPendingIceCandidates() { + remoteDescriptionSet = true + val session = webRtcSession ?: return + val candidates = pendingIceCandidates.toList() + pendingIceCandidates.clear() + candidates.forEach { session.addIceCandidate(it) } + } + fun hangup() { scope.launch { callManager.hangup() } cleanup() @@ -130,6 +151,8 @@ class CallController( webRtcSession = null currentCallId = null currentPeerPubKey = null + remoteDescriptionSet = false + pendingIceCandidates.clear() } private fun createWebRtcSession() { @@ -147,7 +170,6 @@ class CallController( onRemoteStream = { _: MediaStream -> }, onDisconnected = { scope.launch { callManager.hangup() } - cleanup() }, ) webRtcSession?.initialize() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 6527c80d9..0ad79e1f3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -60,8 +60,11 @@ class CallManager( companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting + const val MAX_EVENT_AGE_SECONDS = 30L // discard signaling events older than this } + private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS + suspend fun initiateCall( calleePubKey: HexKey, callType: CallType, @@ -194,6 +197,8 @@ class CallManager( } fun onSignalingEvent(event: Event) { + if (isEventTooOld(event)) return + when (event) { is CallOfferEvent -> onIncomingCallEvent(event) is CallAnswerEvent -> onCallAnswered(event) From 8114739166514a839f7d8b74c6720cddfd1a2d7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:35:24 +0000 Subject: [PATCH 40/63] feat: add runtime permissions, audio routing, and call notifications 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 --- .../amethyst/service/call/CallController.kt | 16 +++++ .../EventNotificationConsumer.kt | 25 +++++++ .../notifications/NotificationUtils.kt | 68 +++++++++++++++++++ .../amethyst/ui/call/CallPermissions.kt | 63 +++++++++++++++++ .../amethyst/ui/call/CallScreen.kt | 22 ++++-- .../chats/privateDM/ChatroomScreen.kt | 25 +++---- 6 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 9280010ef..9726c458a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent +import android.media.AudioManager import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory @@ -140,6 +142,19 @@ class CallController( candidates.forEach { session.addIceCandidate(it) } } + fun setAudioMuted(muted: Boolean) { + webRtcSession?.setAudioEnabled(!muted) + } + + fun setVideoEnabled(enabled: Boolean) { + webRtcSession?.setVideoEnabled(enabled) + } + + fun setSpeakerOn(on: Boolean) { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.isSpeakerphoneOn = on + } + fun hangup() { scope.launch { callManager.hangup() } cleanup() @@ -147,6 +162,7 @@ class CallController( fun cleanup() { stopForegroundService() + NotificationUtils.cancelCallNotification(context) webRtcSession?.dispose() webRtcSession = null currentCallId = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 567b4ac44..7ae2310ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import java.math.BigDecimal @@ -138,6 +139,10 @@ class EventNotificationConsumer( is LiveChessMoveEvent -> { notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn) } + + is CallOfferEvent -> { + notifyIncomingCall(innerEvent, account) + } } } } @@ -620,6 +625,26 @@ class EventNotificationConsumer( } } + private fun notifyIncomingCall( + event: CallOfferEvent, + account: Account, + ) { + if (!account.isFollowing(event.pubKey)) return + + if (TimeUtils.now() - event.createdAt > 30) return + + val callerUser = LocalCache.getUserIfExists(event.pubKey) + val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..." + + NotificationUtils + .sendCallNotification( + callerName = callerName, + callerBitmap = null, + uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}", + applicationContext = applicationContext, + ) + } + fun notificationManager(): NotificationManager = ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index ed5e76ac9..2dd661bf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -47,11 +47,14 @@ object NotificationUtils { private var zapChannel: NotificationChannel? = null private var reactionChannel: NotificationChannel? = null private var chessChannel: NotificationChannel? = null + private var callChannel: NotificationChannel? = null private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION" private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION" + private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL" + private const val CALL_NOTIFICATION_ID = 0x50000 const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" @@ -510,6 +513,71 @@ object NotificationUtils { notify(summaryId, summaryBuilder.build()) } + fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel { + if (callChannel != null) return callChannel!! + + callChannel = + NotificationChannel( + CALL_CHANNEL_ID, + "Incoming calls", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Notifications for incoming voice and video calls" + } + + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(callChannel!!) + + return callChannel!! + } + + fun sendCallNotification( + callerName: String, + callerBitmap: Bitmap?, + uri: String, + applicationContext: Context, + ) { + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val channel = getOrCreateCallChannel(applicationContext) + + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } + + val contentPendingIntent = + PendingIntent.getActivity( + applicationContext, + CALL_NOTIFICATION_ID, + contentIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val builder = + NotificationCompat + .Builder(applicationContext, channel.id) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle("Incoming call") + .setContentText(callerName) + .setLargeIcon(callerBitmap) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setAutoCancel(true) + .setOngoing(true) + .setTimeoutAfter(60_000) + + notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) + } + + fun cancelCallNotification(applicationContext: Context) { + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel("call", CALL_NOTIFICATION_ID) + } + private fun NotificationManager.isDuplicate(notId: Int): Boolean { val notifications: Array = activeNotifications for (notification in notifications) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt new file mode 100644 index 000000000..41f2acbea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.call + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat + +@Composable +fun rememberCallPermissionLauncher(onGranted: () -> Unit) = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onGranted() + } + +fun hasAudioPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + +@Composable +fun rememberCallWithPermission( + context: android.content.Context, + onCall: () -> Unit, +): () -> Unit { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onCall() + } + + return remember(onCall) { + { + if (hasAudioPermission(context)) { + onCall() + } else { + launcher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index dadd3b2f6..0e531a8df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -73,6 +73,7 @@ fun CallScreen( ) { val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current when (val state = callState) { is CallState.Idle -> { @@ -89,11 +90,15 @@ fun CallScreen( } is CallState.IncomingCall -> { + val acceptWithPermission = + rememberCallWithPermission(context) { + callController?.acceptIncomingCall(state.sdpOffer) + } IncomingCallUI( callerPubKey = state.callerPubKey, callType = state.callType, accountViewModel = accountViewModel, - onAccept = { callController?.acceptIncomingCall(state.sdpOffer) }, + onAccept = acceptWithPermission, onReject = { scope.launch { callManager.rejectCall() } }, ) } @@ -112,9 +117,18 @@ fun CallScreen( state = state, accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, - onToggleMute = { callManager.toggleAudioMute() }, - onToggleVideo = { callManager.toggleVideo() }, - onToggleSpeaker = { callManager.toggleSpeaker() }, + onToggleMute = { + callManager.toggleAudioMute() + callController?.setAudioMuted(!state.isAudioMuted) + }, + onToggleVideo = { + callManager.toggleVideo() + callController?.setVideoEnabled(!state.isVideoEnabled) + }, + onToggleSpeaker = { + callManager.toggleSpeaker() + callController?.setSpeakerOn(!state.isSpeakerOn) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index d2b6a9219..e338e5747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -26,12 +26,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType @Composable fun ChatroomScreen( @@ -43,6 +47,14 @@ fun ChatroomScreen( accountViewModel: AccountViewModel, nav: INav, ) { + val context = LocalContext.current + val startCall = + rememberCallWithPermission(context) { + val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) + nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) + } + DisappearingScaffold( isInvertedLayout = true, topBar = { @@ -50,18 +62,7 @@ fun ChatroomScreen( room = roomId, accountViewModel = accountViewModel, nav = nav, - onCallClick = { peerPubKey -> - accountViewModel.callController?.initiateCall( - peerPubKey, - com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VOICE, - ) - nav.nav( - com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( - callId = "", - peerPubKey = peerPubKey, - ), - ) - }, + onCallClick = { _ -> startCall() }, ) }, accountViewModel = accountViewModel, From 3f0d1aa60f1fd184b2a32442b5fd507d7aa43551 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:46:44 +0000 Subject: [PATCH 41/63] fix: add event dedup, back button handling, and wake lock for calls - 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 --- .../amethyst/ui/call/CallScreen.kt | 24 ++++++++++++++++++- .../amethyst/commons/call/CallManager.kt | 3 +++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 0e531a8df..4f7ef31bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.call +import android.view.WindowManager +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,6 +42,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -50,6 +53,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -73,7 +77,13 @@ fun CallScreen( ) { val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current + + BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) { + scope.launch { callManager.hangup() } + } + + KeepScreenOn() when (val state = callState) { is CallState.Idle -> { @@ -363,3 +373,15 @@ private fun formatDuration(seconds: Long): String { val secs = seconds % 60 return "%02d:%02d".format(mins, secs) } + +@Composable +private fun KeepScreenOn() { + val context = LocalContext.current + DisposableEffect(Unit) { + val window = (context as? android.app.Activity)?.window + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 0ad79e1f3..fd77b4d99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -56,6 +56,7 @@ class CallManager( private var timeoutJob: Job? = null private var resetJob: Job? = null + private val processedEventIds = mutableSetOf() companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout @@ -198,6 +199,7 @@ class CallManager( fun onSignalingEvent(event: Event) { if (isEventTooOld(event)) return + if (!processedEventIds.add(event.id)) return when (event) { is CallOfferEvent -> onIncomingCallEvent(event) @@ -234,6 +236,7 @@ class CallManager( cancelTimeout() resetJob?.cancel() resetJob = null + processedEventIds.clear() } private fun transitionToEnded( From b0f3943e69216248352bc438e852f61807c840e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:50:25 +0000 Subject: [PATCH 42/63] feat: add ringtone, vibration, proximity sensor, camera, and lock screen call UI 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 --- amethyst/src/main/AndroidManifest.xml | 1 + .../amethyst/service/call/CallAudioManager.kt | 119 ++++++++++++++++++ .../amethyst/service/call/CallController.kt | 23 +++- .../service/call/WebRtcCallSession.kt | 29 +++++ .../notifications/NotificationUtils.kt | 16 +++ 5 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 9df886f66..47d037c35 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt new file mode 100644 index 000000000..efbce5687 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import android.content.Context +import android.media.AudioAttributes +import android.media.Ringtone +import android.media.RingtoneManager +import android.os.Build +import android.os.PowerManager +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager + +class CallAudioManager( + private val context: Context, +) { + private var ringtone: Ringtone? = null + private var vibrator: Vibrator? = null + private var proximityWakeLock: PowerManager.WakeLock? = null + + fun startRinging() { + startRingtone() + startVibration() + } + + fun stopRinging() { + stopRingtone() + stopVibration() + } + + fun acquireProximityWakeLock() { + if (proximityWakeLock != null) return + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + proximityWakeLock = + powerManager.newWakeLock( + PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, + "amethyst:call_proximity", + ) + proximityWakeLock?.acquire(60 * 60 * 1000L) // 1 hour max + } + + fun releaseProximityWakeLock() { + proximityWakeLock?.let { + if (it.isHeld) it.release() + } + proximityWakeLock = null + } + + fun release() { + stopRinging() + releaseProximityWakeLock() + } + + private fun startRingtone() { + try { + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + ringtone = + RingtoneManager.getRingtone(context, ringtoneUri)?.apply { + audioAttributes = + AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + isLooping = true + play() + } + } catch (_: Exception) { + // Ringtone may not be available + } + } + + private fun stopRingtone() { + ringtone?.stop() + ringtone = null + } + + private fun startVibration() { + try { + vibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + } + + val pattern = longArrayOf(0, 1000, 1000) // vibrate 1s, pause 1s, repeat + vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0)) + } catch (_: Exception) { + // Vibrator may not be available + } + } + + private fun stopVibration() { + vibrator?.cancel() + vibrator = null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 9726c458a..bf1293b42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -52,12 +52,30 @@ class CallController( private var currentPeerPubKey: HexKey? = null private var remoteDescriptionSet = false private val pendingIceCandidates = CopyOnWriteArrayList() + val audioManager = CallAudioManager(context) init { scope.launch { callManager.state.collect { state -> - if (state is CallState.Ended && webRtcSession != null) { - cleanup() + when (state) { + is CallState.IncomingCall -> { + audioManager.startRinging() + } + + is CallState.Connecting -> { + audioManager.stopRinging() + audioManager.acquireProximityWakeLock() + } + + is CallState.Connected -> { + audioManager.acquireProximityWakeLock() + } + + is CallState.Ended -> { + cleanup() + } + + else -> {} } } } @@ -161,6 +179,7 @@ class CallController( } fun cleanup() { + audioManager.release() stopForegroundService() NotificationUtils.cancelCallNotification(context) webRtcSession?.dispose() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index aa9784449..b877c044a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -24,6 +24,8 @@ import android.content.Context import com.vitorpamplona.quartz.utils.Log import org.webrtc.AudioSource import org.webrtc.AudioTrack +import org.webrtc.Camera2Enumerator +import org.webrtc.CameraVideoCapturer import org.webrtc.DataChannel import org.webrtc.DefaultVideoDecoderFactory import org.webrtc.DefaultVideoEncoderFactory @@ -36,6 +38,7 @@ import org.webrtc.PeerConnectionFactory import org.webrtc.RtpReceiver import org.webrtc.SdpObserver import org.webrtc.SessionDescription +import org.webrtc.SurfaceTextureHelper import org.webrtc.VideoSource import org.webrtc.VideoTrack @@ -55,6 +58,7 @@ class WebRtcCallSession( private var localVideoTrack: VideoTrack? = null private var audioSource: AudioSource? = null private var videoSource: VideoSource? = null + private var cameraCapturer: CameraVideoCapturer? = null val eglBase: EglBase = EglBase.create() @@ -147,6 +151,30 @@ class WebRtcCallSession( peerConnectionFactory?.createVideoTrack("video0", videoSource).also { peerConnection?.addTrack(it) } + startCamera() + } + + private fun startCamera() { + val source = videoSource ?: return + val enumerator = Camera2Enumerator(context) + val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) } + val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return + + cameraCapturer = + enumerator.createCapturer(camera, null)?.also { + it.initialize( + SurfaceTextureHelper.create("CaptureThread", eglBase.eglBaseContext), + context, + source.capturerObserver, + ) + it.startCapture(640, 480, 30) + } + } + + fun stopCamera() { + cameraCapturer?.stopCapture() + cameraCapturer?.dispose() + cameraCapturer = null } fun getLocalVideoSource(): VideoSource? = videoSource @@ -226,6 +254,7 @@ class WebRtcCallSession( } fun dispose() { + stopCamera() localAudioTrack?.dispose() localVideoTrack?.dispose() audioSource?.dispose() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 2dd661bf6..ee6325639 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -555,6 +555,20 @@ object NotificationUtils { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + val fullScreenIntent = + Intent(applicationContext, MainActivity::class.java).apply { + data = uri.toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + + val fullScreenPendingIntent = + PendingIntent.getActivity( + applicationContext, + CALL_NOTIFICATION_ID + 1, + fullScreenIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val builder = NotificationCompat .Builder(applicationContext, channel.id) @@ -563,11 +577,13 @@ object NotificationUtils { .setContentText(callerName) .setLargeIcon(callerBitmap) .setContentIntent(contentPendingIntent) + .setFullScreenIntent(fullScreenPendingIntent, true) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_CALL) .setAutoCancel(true) .setOngoing(true) .setTimeoutAfter(60_000) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) } From d93b384f72ee28b5be96705662fb99961f43b31d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 01:12:04 +0000 Subject: [PATCH 43/63] fix: route call events via EventProcessor and add video call button 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 --- .../ui/screen/loggedIn/AccountViewModel.kt | 24 +------------------ .../loggedIn/DecryptAndIndexProcessor.kt | 21 ++++++++++++++++ .../chats/privateDM/ChatroomScreen.kt | 11 +++++++-- .../privateDM/header/RenderRoomTopBar.kt | 16 +++++++++++++ 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7a740b2af..acdea1d56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -146,12 +146,6 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent -import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -222,6 +216,7 @@ class AccountViewModel( ) callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) } callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } + account.newNotesPreProcessor.callManager = callManager callController = controller } @@ -1415,23 +1410,6 @@ class AccountViewModel( } } } - - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - newNotes.forEach { note -> - val event = note.event ?: return@forEach - when (event) { - is CallOfferEvent, - is CallAnswerEvent, - is CallIceCandidateEvent, - is CallHangupEvent, - is CallRejectEvent, - is CallRenegotiateEvent, - -> callManager.onSignalingEvent(event) - } - } - } - } } override fun onCleared() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a569b5ac9..8b6c15a4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -36,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException @@ -52,6 +59,8 @@ class EventProcessor( private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache) private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache) + var callManager: CallManager? = null + suspend fun consume(note: Note) { note.event?.let { event -> try { @@ -68,10 +77,22 @@ class EventProcessor( publicNote: Note, ) { when (event) { + is CallOfferEvent, + is CallAnswerEvent, + is CallIceCandidateEvent, + is CallHangupEvent, + is CallRejectEvent, + is CallRenegotiateEvent, + -> callManager?.onSignalingEvent(event) + is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote) + is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote) + is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote) + is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote) + is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index e338e5747..d41e37ee8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -48,12 +48,18 @@ fun ChatroomScreen( nav: INav, ) { val context = LocalContext.current - val startCall = + val startVoiceCall = rememberCallWithPermission(context) { val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) } + val startVideoCall = + rememberCallWithPermission(context) { + val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) + nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) + } DisappearingScaffold( isInvertedLayout = true, @@ -62,7 +68,8 @@ fun ChatroomScreen( room = roomId, accountViewModel = accountViewModel, nav = nav, - onCallClick = { _ -> startCall() }, + onCallClick = { _ -> startVoiceCall() }, + onVideoCallClick = { _ -> startVideoCall() }, ) }, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 8ec60cc3f..739338e76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.Videocam import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -74,6 +75,7 @@ fun RenderRoomTopBar( accountViewModel: AccountViewModel, nav: INav, onCallClick: ((String) -> Unit)? = null, + onVideoCallClick: ((String) -> Unit)? = null, ) { if (room.users.size == 1) { TopBarExtensibleWithBackButton( @@ -90,6 +92,20 @@ fun RenderRoomTopBar( UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel) + if (onVideoCallClick != null) { + IconButton( + onClick = { onVideoCallClick(baseUser.pubkeyHex) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Videocam, + contentDescription = "Video call", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + if (onCallClick != null) { IconButton( onClick = { onCallClick(baseUser.pubkeyHex) }, From 1016e46d5094ecdc89a59dbf7a1b8093b3f04f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 01:28:00 +0000 Subject: [PATCH 44/63] fix: reduce event expiration to 20s and fix foreground service crash - 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 --- amethyst/src/main/AndroidManifest.xml | 4 ++-- .../amethyst/service/call/CallForegroundService.kt | 2 +- .../com/vitorpamplona/amethyst/commons/call/CallManager.kt | 2 +- .../com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md | 2 +- .../quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt | 2 +- .../quartz/nipACWebRtcCalls/events/CallHangupEvent.kt | 2 +- .../quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt | 2 +- .../quartz/nipACWebRtcCalls/events/CallOfferEvent.kt | 2 +- .../quartz/nipACWebRtcCalls/events/CallRejectEvent.kt | 2 +- .../quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 47d037c35..36a10ab64 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -39,7 +39,7 @@ - + @@ -225,7 +225,7 @@ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 7c58e9648..448b3fad3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -62,7 +62,7 @@ class CallForegroundService : Service() { NOTIFICATION_ID, notification, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE } else { 0 }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index fd77b4d99..8a9dd32db 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -61,7 +61,7 @@ class CallManager( companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting - const val MAX_EVENT_AGE_SECONDS = 30L // discard signaling events older than this + const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this } private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index 0aab7141a..6592d41ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -215,7 +215,7 @@ This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: ## Implementation Notes - The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. -- Events SHOULD have short expiration times (~5 minutes) since signaling data is ephemeral and has no long-term value. +- Events SHOULD have short expiration times (~20 seconds) since signaling data is ephemeral and has no long-term value. - Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. - Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. - The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt index 6f9baf445..e36619b45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt @@ -48,7 +48,7 @@ class CallAnswerEvent( companion object { const val KIND = 25051 const val ALT_DESCRIPTION = "WebRTC call answer" - const val EXPIRATION_SECONDS = 300L + const val EXPIRATION_SECONDS = 20L fun build( sdpAnswer: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt index 46d6854ba..3251cbc5f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt @@ -48,7 +48,7 @@ class CallHangupEvent( companion object { const val KIND = 25053 const val ALT_DESCRIPTION = "WebRTC call hangup" - const val EXPIRATION_SECONDS = 300L + const val EXPIRATION_SECONDS = 20L fun build( peerPubKey: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt index bf4e2ab47..4948110cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt @@ -48,7 +48,7 @@ class CallIceCandidateEvent( companion object { const val KIND = 25052 const val ALT_DESCRIPTION = "WebRTC ICE candidate" - const val EXPIRATION_SECONDS = 300L + const val EXPIRATION_SECONDS = 20L fun build( candidateJson: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt index f63a64c1f..71a4d94fb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt @@ -53,7 +53,7 @@ class CallOfferEvent( companion object { const val KIND = 25050 const val ALT_DESCRIPTION = "WebRTC call offer" - const val EXPIRATION_SECONDS = 300L // 5 minutes + const val EXPIRATION_SECONDS = 20L fun build( sdpOffer: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt index 6fb1c63bc..f4e4a62cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt @@ -48,7 +48,7 @@ class CallRejectEvent( companion object { const val KIND = 25054 const val ALT_DESCRIPTION = "WebRTC call rejection" - const val EXPIRATION_SECONDS = 300L + const val EXPIRATION_SECONDS = 20L fun build( callerPubKey: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt index 8524d580d..d4df637ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt @@ -48,7 +48,7 @@ class CallRenegotiateEvent( companion object { const val KIND = 25055 const val ALT_DESCRIPTION = "WebRTC call renegotiation" - const val EXPIRATION_SECONDS = 300L + const val EXPIRATION_SECONDS = 20L fun build( sdpOffer: String, From b01ddc89582011328495dfd30a85efe58c5b441f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 01:58:21 +0000 Subject: [PATCH 45/63] fix: use microphone foreground service type instead of phoneCall 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 --- amethyst/src/main/AndroidManifest.xml | 4 ++-- .../amethyst/service/call/CallForegroundService.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index e605b4576..dc94b6ed5 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -39,7 +39,7 @@ - + @@ -228,7 +228,7 @@ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 7c58e9648..448b3fad3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -62,7 +62,7 @@ class CallForegroundService : Service() { NOTIFICATION_ID, notification, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE } else { 0 }, From ca21a8489f0eb8fc1125cbc72b3e6f75567659ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 02:08:56 +0000 Subject: [PATCH 46/63] feat: complete call UI with video, audio routing, error handling 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 --- .../amethyst/service/call/CallAudioManager.kt | 35 ++ .../amethyst/service/call/CallController.kt | 184 ++++++++--- .../service/call/WebRtcCallSession.kt | 12 +- .../notifications/NotificationUtils.kt | 2 + .../amethyst/ui/call/CallScreen.kt | 312 +++++++++++++----- 5 files changed, 410 insertions(+), 135 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index efbce5687..d5168878b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.media.AudioAttributes +import android.media.AudioManager import android.media.Ringtone import android.media.RingtoneManager +import android.media.ToneGenerator import android.os.Build import android.os.PowerManager import android.os.VibrationEffect @@ -36,6 +38,9 @@ class CallAudioManager( private var ringtone: Ringtone? = null private var vibrator: Vibrator? = null private var proximityWakeLock: PowerManager.WakeLock? = null + private var ringbackTone: ToneGenerator? = null + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var previousAudioMode: Int = AudioManager.MODE_NORMAL fun startRinging() { startRingtone() @@ -47,6 +52,34 @@ class CallAudioManager( stopVibration() } + fun startRingbackTone() { + try { + ringbackTone = + ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80).also { + it.startTone(ToneGenerator.TONE_SUP_RINGTONE) + } + } catch (_: Exception) { + // ToneGenerator may not be available + } + } + + fun stopRingbackTone() { + ringbackTone?.stopTone() + ringbackTone?.release() + ringbackTone = null + } + + fun switchToCallAudioMode() { + previousAudioMode = audioManager.mode + audioManager.mode = AudioManager.MODE_IN_COMMUNICATION + audioManager.isSpeakerphoneOn = false + } + + fun restoreAudioMode() { + audioManager.mode = previousAudioMode + audioManager.isSpeakerphoneOn = false + } + fun acquireProximityWakeLock() { if (proximityWakeLock != null) return val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -67,6 +100,8 @@ class CallAudioManager( fun release() { stopRinging() + stopRingbackTone() + restoreAudioMode() releaseProximityWakeLock() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index bf1293b42..728f6d1ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -23,6 +23,10 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils @@ -31,14 +35,21 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.webrtc.IceCandidate import org.webrtc.MediaStream import org.webrtc.SessionDescription +import org.webrtc.VideoTrack import java.util.UUID import java.util.concurrent.CopyOnWriteArrayList +private const val TAG = "CallController" + class CallController( private val context: Context, private val callManager: CallManager, @@ -54,6 +65,17 @@ class CallController( private val pendingIceCandidates = CopyOnWriteArrayList() val audioManager = CallAudioManager(context) + private val _remoteVideoTrack = MutableStateFlow(null) + val remoteVideoTrack: StateFlow = _remoteVideoTrack.asStateFlow() + + private val _localVideoTrack = MutableStateFlow(null) + val localVideoTrack: StateFlow = _localVideoTrack.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private var networkCallback: ConnectivityManager.NetworkCallback? = null + init { scope.launch { callManager.state.collect { state -> @@ -62,9 +84,16 @@ class CallController( audioManager.startRinging() } + is CallState.Offering -> { + audioManager.startRingbackTone() + } + is CallState.Connecting -> { audioManager.stopRinging() + audioManager.stopRingbackTone() + audioManager.switchToCallAudioMode() audioManager.acquireProximityWakeLock() + registerNetworkCallback() } is CallState.Connected -> { @@ -85,49 +114,65 @@ class CallController( peerPubKey: HexKey, callType: CallType, ) { - val callId = UUID.randomUUID().toString() - currentCallId = callId - currentPeerPubKey = peerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() + try { + val callId = UUID.randomUUID().toString() + currentCallId = callId + currentPeerPubKey = peerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() + _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - } - - webRtcSession?.createOffer { sdp -> - scope.launch { - callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() } + + webRtcSession?.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initiate call", e) + _errorMessage.value = "Failed to start call: ${e.message}" + cleanup() } } fun acceptIncomingCall(sdpOffer: String) { - val state = callManager.state.value - if (state !is CallState.IncomingCall) return + try { + val state = callManager.state.value + if (state !is CallState.IncomingCall) return - currentCallId = state.callId - currentPeerPubKey = state.callerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() + currentCallId = state.callId + currentPeerPubKey = state.callerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() + _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (state.callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - } - - webRtcSession?.setRemoteDescription( - SessionDescription(SessionDescription.Type.OFFER, sdpOffer), - ) - flushPendingIceCandidates() - - webRtcSession?.createAnswer { sdp -> - scope.launch { - callManager.acceptCall(sdp.description) + createWebRtcSession() + webRtcSession?.addAudioTrack() + if (state.callType == CallType.VIDEO) { + webRtcSession?.addVideoTrack() + _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() } + + webRtcSession?.setRemoteDescription( + SessionDescription(SessionDescription.Type.OFFER, sdpOffer), + ) + flushPendingIceCandidates() + + webRtcSession?.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to accept call", e) + _errorMessage.value = "Failed to accept call: ${e.message}" + cleanup() } } @@ -169,19 +214,28 @@ class CallController( } fun setSpeakerOn(on: Boolean) { - val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - audioManager.isSpeakerphoneOn = on + val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + am.isSpeakerphoneOn = on } + fun getEglBase() = webRtcSession?.eglBase + fun hangup() { scope.launch { callManager.hangup() } cleanup() } + fun clearError() { + _errorMessage.value = null + } + fun cleanup() { audioManager.release() + unregisterNetworkCallback() stopForegroundService() NotificationUtils.cancelCallNotification(context) + _remoteVideoTrack.value = null + _localVideoTrack.value = null webRtcSession?.dispose() webRtcSession = null currentCallId = null @@ -202,15 +256,58 @@ class CallController( callManager.onPeerConnected() startForegroundService() }, - onRemoteStream = { _: MediaStream -> }, + onRemoteStream = { stream: MediaStream -> + stream.videoTracks?.firstOrNull()?.let { + _remoteVideoTrack.value = it + } + }, onDisconnected = { scope.launch { callManager.hangup() } }, + onError = { error -> + _errorMessage.value = error + }, ) webRtcSession?.initialize() webRtcSession?.createPeerConnection() } + private fun registerNetworkCallback() { + try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val request = + NetworkRequest + .Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + Log.d(TAG) { "Network available, ICE restart may be needed" } + } + + override fun onLost(network: Network) { + Log.d(TAG) { "Network lost during call" } + } + } + connectivityManager.registerNetworkCallback(request, callback) + networkCallback = callback + } catch (e: Exception) { + Log.e(TAG, "Failed to register network callback", e) + } + } + + private fun unregisterNetworkCallback() { + try { + networkCallback?.let { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + connectivityManager.unregisterNetworkCallback(it) + } + } catch (_: Exception) { + } + networkCallback = null + } + private fun onLocalIceCandidate(candidate: IceCandidate) { val callId = currentCallId ?: return val peerPubKey = currentPeerPubKey ?: return @@ -233,11 +330,14 @@ class CallController( } private fun stopForegroundService() { - val intent = - Intent(context, CallForegroundService::class.java).apply { - action = CallForegroundService.ACTION_STOP - } - context.startService(intent) + try { + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_STOP + } + context.startService(intent) + } catch (_: Exception) { + } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index b877c044a..7fdc043fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -51,6 +51,7 @@ class WebRtcCallSession( private val onPeerConnected: () -> Unit, private val onRemoteStream: (MediaStream) -> Unit, private val onDisconnected: () -> Unit, + private val onError: (String) -> Unit = {}, ) { private var peerConnectionFactory: PeerConnectionFactory? = null private var peerConnection: PeerConnection? = null @@ -104,9 +105,12 @@ class WebRtcCallSession( onPeerConnected() } - PeerConnection.IceConnectionState.DISCONNECTED, - PeerConnection.IceConnectionState.FAILED, - -> { + PeerConnection.IceConnectionState.FAILED -> { + onError("Connection failed - check network") + onDisconnected() + } + + PeerConnection.IceConnectionState.DISCONNECTED -> { onDisconnected() } @@ -278,12 +282,14 @@ class WebRtcCallSession( override fun onCreateFailure(error: String?) { Log.e(TAG, "SDP operation failed: $error") + error?.let { onError("SDP error: $it") } } override fun onSetSuccess() {} override fun onSetFailure(error: String?) { Log.e(TAG, "SDP set failed: $error") + error?.let { onError("SDP error: $it") } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index ee6325639..08d00f9f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -584,6 +584,8 @@ object NotificationUtils { .setOngoing(true) .setTimeoutAfter(60_000) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addAction(R.drawable.amethyst, "Reject", contentPendingIntent) + .addAction(R.drawable.amethyst, "Accept", fullScreenPendingIntent) notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 4f7ef31bd..1d4e5c432 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -31,15 +31,23 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -57,6 +65,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.call.CallController @@ -67,6 +76,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.webrtc.RendererCommon +import org.webrtc.SurfaceViewRenderer +import org.webrtc.VideoTrack @Composable fun CallScreen( @@ -78,6 +90,7 @@ fun CallScreen( val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() val context = LocalContext.current + val errorMessage by (callController?.errorMessage ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) { scope.launch { callManager.hangup() } @@ -85,70 +98,89 @@ fun CallScreen( KeepScreenOn() - when (val state = callState) { - is CallState.Idle -> { - LaunchedEffect(Unit) { onCallEnded() } + Box(modifier = Modifier.fillMaxSize()) { + when (val state = callState) { + is CallState.Idle -> { + LaunchedEffect(Unit) { onCallEnded() } + } + + is CallState.Offering -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Calling...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.IncomingCall -> { + val acceptWithPermission = + rememberCallWithPermission(context) { + callController?.acceptIncomingCall(state.sdpOffer) + } + IncomingCallUI( + callerPubKey = state.callerPubKey, + callType = state.callType, + accountViewModel = accountViewModel, + onAccept = acceptWithPermission, + onReject = { scope.launch { callManager.rejectCall() } }, + ) + } + + is CallState.Connecting -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Connecting...", + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + ) + } + + is CallState.Connected -> { + ConnectedCallUI( + state = state, + callController = callController, + accountViewModel = accountViewModel, + onHangup = { scope.launch { callManager.hangup() } }, + onToggleMute = { + callManager.toggleAudioMute() + callController?.setAudioMuted(!state.isAudioMuted) + }, + onToggleVideo = { + callManager.toggleVideo() + callController?.setVideoEnabled(!state.isVideoEnabled) + }, + onToggleSpeaker = { + callManager.toggleSpeaker() + callController?.setSpeakerOn(!state.isSpeakerOn) + }, + ) + } + + is CallState.Ended -> { + CallInProgressUI( + peerPubKey = state.peerPubKey, + statusText = "Call ended", + accountViewModel = accountViewModel, + onHangup = { onCallEnded() }, + ) + } } - is CallState.Offering -> { - CallInProgressUI( - peerPubKey = state.peerPubKey, - statusText = "Calling...", - accountViewModel = accountViewModel, - onHangup = { scope.launch { callManager.hangup() } }, - ) - } - - is CallState.IncomingCall -> { - val acceptWithPermission = - rememberCallWithPermission(context) { - callController?.acceptIncomingCall(state.sdpOffer) - } - IncomingCallUI( - callerPubKey = state.callerPubKey, - callType = state.callType, - accountViewModel = accountViewModel, - onAccept = acceptWithPermission, - onReject = { scope.launch { callManager.rejectCall() } }, - ) - } - - is CallState.Connecting -> { - CallInProgressUI( - peerPubKey = state.peerPubKey, - statusText = "Connecting...", - accountViewModel = accountViewModel, - onHangup = { scope.launch { callManager.hangup() } }, - ) - } - - is CallState.Connected -> { - ConnectedCallUI( - state = state, - accountViewModel = accountViewModel, - onHangup = { scope.launch { callManager.hangup() } }, - onToggleMute = { - callManager.toggleAudioMute() - callController?.setAudioMuted(!state.isAudioMuted) + errorMessage?.let { error -> + Snackbar( + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + action = { + Text( + "Dismiss", + modifier = + Modifier.padding(8.dp), + color = MaterialTheme.colorScheme.inversePrimary, + ) }, - onToggleVideo = { - callManager.toggleVideo() - callController?.setVideoEnabled(!state.isVideoEnabled) - }, - onToggleSpeaker = { - callManager.toggleSpeaker() - callController?.setSpeakerOn(!state.isSpeakerOn) - }, - ) - } - - is CallState.Ended -> { - CallInProgressUI( - peerPubKey = state.peerPubKey, - statusText = "Call ended", - accountViewModel = accountViewModel, - onHangup = { onCallEnded() }, - ) + ) { + Text(error) + } } } } @@ -288,6 +320,7 @@ private fun IncomingCallUI( @Composable private fun ConnectedCallUI( state: CallState.Connected, + callController: CallController?, accountViewModel: AccountViewModel, onHangup: () -> Unit, onToggleMute: () -> Unit, @@ -303,51 +336,126 @@ private fun ConnectedCallUI( } } + val remoteVideoTrack by (callController?.remoteVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() + val localVideoTrack by (callController?.localVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() + Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), - contentAlignment = Alignment.Center, + .background(Color.Black), ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> - if (user != null) { - ClickableUserPicture( - baseUser = user, - size = 120.dp, - accountViewModel = accountViewModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - UsernameDisplay( - baseUser = user, - accountViewModel = accountViewModel, - fontWeight = FontWeight.Bold, - ) + // Remote video (full screen background) + remoteVideoTrack?.let { track -> + VideoRenderer( + videoTrack = track, + eglBase = callController?.getEglBase(), + modifier = Modifier.fillMaxSize(), + mirror = false, + ) + } + + // Local video (small pip in corner) + localVideoTrack?.let { track -> + VideoRenderer( + videoTrack = track, + eglBase = callController?.getEglBase(), + modifier = + Modifier + .size(120.dp, 160.dp) + .align(Alignment.TopEnd) + .padding(16.dp), + mirror = true, + ) + } + + // If no video, show avatar + if (remoteVideoTrack == null) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + textColor = Color.White, + ) + } } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = formatDuration(elapsed), + color = Color.White.copy(alpha = 0.7f), + fontSize = 16.sp, + ) } - Spacer(modifier = Modifier.height(8.dp)) + } else { + // Timer overlay Text( text = formatDuration(elapsed), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp), ) - Spacer(modifier = Modifier.height(48.dp)) + } + + // Controls at bottom + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, ) { - IconButton(onClick = onToggleMute) { - Text(if (state.isAudioMuted) "Unmute" else "Mute") + IconButton( + onClick = onToggleMute, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = if (state.isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = if (state.isAudioMuted) "Unmute" else "Mute", + tint = if (state.isAudioMuted) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) } - IconButton(onClick = onToggleVideo) { - Text(if (state.isVideoEnabled) "Cam Off" else "Cam On") + IconButton( + onClick = onToggleVideo, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = if (state.isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, + contentDescription = if (state.isVideoEnabled) "Camera off" else "Camera on", + tint = if (!state.isVideoEnabled) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) } - IconButton(onClick = onToggleSpeaker) { - Text(if (state.isSpeakerOn) "Earpiece" else "Speaker") + IconButton( + onClick = onToggleSpeaker, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, + contentDescription = if (state.isSpeakerOn) "Earpiece" else "Speaker", + tint = if (state.isSpeakerOn) Color.Cyan else Color.White, + modifier = Modifier.size(28.dp), + ) } } Spacer(modifier = Modifier.height(24.dp)) @@ -368,6 +476,30 @@ private fun ConnectedCallUI( } } +@Composable +private fun VideoRenderer( + videoTrack: VideoTrack, + eglBase: org.webrtc.EglBase?, + modifier: Modifier = Modifier, + mirror: Boolean = false, +) { + AndroidView( + modifier = modifier, + factory = { ctx -> + SurfaceViewRenderer(ctx).apply { + setMirror(mirror) + setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) + eglBase?.eglBaseContext?.let { init(it, null) } + videoTrack.addSink(this) + } + }, + onRelease = { renderer -> + videoTrack.removeSink(renderer) + renderer.release() + }, + ) +} + private fun formatDuration(seconds: Long): String { val mins = seconds / 60 val secs = seconds % 60 From 8354bcd6163bebfa66c9ebb2056da32a187169fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 02:12:37 +0000 Subject: [PATCH 47/63] debug: add logging to trace call signaling flow 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 --- .../amethyst/service/call/CallController.kt | 9 +++++++-- .../vitorpamplona/amethyst/commons/call/CallManager.kt | 8 +++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 728f6d1ba..bb2072cb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -177,6 +177,7 @@ class CallController( } fun onCallAnswerReceived(sdpAnswer: String) { + Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, session=${webRtcSession != null}" } webRtcSession?.setRemoteDescription( SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), ) @@ -188,12 +189,14 @@ class CallController( try { val candidate = parseIceCandidate(json) if (webRtcSession != null && remoteDescriptionSet) { + Log.d(TAG) { "Adding ICE candidate directly: ${candidate.sdp.take(50)}" } webRtcSession?.addIceCandidate(candidate) } else { + Log.d(TAG) { "Buffering ICE candidate (session=${webRtcSession != null}, remoteSet=$remoteDescriptionSet)" } pendingIceCandidates.add(candidate) } - } catch (_: Exception) { - // Ignore malformed ICE candidates + } catch (e: Exception) { + Log.e(TAG, "Failed to parse ICE candidate: $json", e) } } @@ -201,6 +204,7 @@ class CallController( remoteDescriptionSet = true val session = webRtcSession ?: return val candidates = pendingIceCandidates.toList() + Log.d(TAG) { "Flushing ${candidates.size} buffered ICE candidates" } pendingIceCandidates.clear() candidates.forEach { session.addIceCandidate(it) } } @@ -309,6 +313,7 @@ class CallController( } private fun onLocalIceCandidate(candidate: IceCandidate) { + Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" } val callId = currentCallId ?: return val peerPubKey = currentPeerPubKey ?: return val candidateJson = serializeIceCandidate(candidate) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 8a9dd32db..83660deca 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -198,9 +199,14 @@ class CallManager( } fun onSignalingEvent(event: Event) { - if (isEventTooOld(event)) return + if (isEventTooOld(event)) { + Log.d("CallManager") { "Discarding old event kind=${event.kind} age=${TimeUtils.now() - event.createdAt}s" } + return + } if (!processedEventIds.add(event.id)) return + Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" } + when (event) { is CallOfferEvent -> onIncomingCallEvent(event) is CallAnswerEvent -> onCallAnswered(event) From f344f00e9daca9f753e2d83d75f2b4c6bab42094 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 02:27:23 +0000 Subject: [PATCH 48/63] test: add previews and unit tests for call feature Previews (CallScreenPreviews.kt): - PreviewCallingScreen: outgoing call "Calling..." state - PreviewConnectingScreen: WebRTC connecting state - PreviewCallEndedScreen: call ended state - PreviewIncomingCallScreen: incoming call with accept/reject - PreviewConnectedCallScreen: active call with controls - PreviewConnectedCallMuted: muted mic + speaker on state All use ThemeComparisonColumn for dark/light side-by-side Unit tests - quartz (CallTagsTest.kt): - CallIdTag parse/assemble/roundtrip, edge cases (empty, wrong name) - CallTypeTag parse voice/video, unknown types, roundtrip - CallType.fromString validation Unit tests - quartz (CallEventsTest.kt): - All 6 event kinds have correct constants (25050-25055) - CallOfferEvent.build includes call-id, call-type, p, expiration tags - Expiration is 20 seconds - Answer/ICE/Hangup/Reject/Renegotiate template content verification Unit tests - amethyst (IceCandidateSerializationTest.kt): - Parse ICE candidate JSON with all fields - Parse with different sdpMLineIndex - Parse with missing fields (defaults) - Serialize produces valid JSON - Roundtrip serialization preserves all fields https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../amethyst/ui/call/CallScreenPreviews.kt | 362 ++++++++++++++++++ .../call/IceCandidateSerializationTest.kt | 78 ++++ .../quartz/nipACWebRtcCalls/CallEventsTest.kt | 191 +++++++++ .../quartz/nipACWebRtcCalls/CallTagsTest.kt | 131 +++++++ 4 files changed, 762 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt new file mode 100644 index 000000000..85d832c63 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.call + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff +import androidx.compose.material.icons.filled.VolumeOff +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Composable +private fun PreviewCallInProgress(statusText: String) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Alice", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = statusText, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewCallingScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Calling...") + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectingScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Connecting...") + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewCallEndedScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Call ended") + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Bob", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Incoming voice call...", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 16.sp, + ) + Spacer(modifier = Modifier.height(48.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Reject", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + FloatingActionButton( + onClick = {}, + containerColor = Color(0xFF4CAF50), + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.Call, + contentDescription = "Accept", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = Color.White.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Alice", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = Color.White, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "02:45", + color = Color.White.copy(alpha = 0.7f), + fontSize = 16.sp, + ) + } + + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton( + onClick = {}, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = "Mute", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton( + onClick = {}, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.VideocamOff, + contentDescription = "Camera off", + tint = Color.Red, + modifier = Modifier.size(28.dp), + ) + } + IconButton( + onClick = {}, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.VolumeOff, + contentDescription = "Speaker", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon( + Icons.Default.CallEnd, + contentDescription = "Hang up", + tint = Color.White, + modifier = Modifier.size(32.dp), + ) + } + } + } + } +} + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedCallMuted() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = Color.White.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("05:12", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + } + + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon(Icons.Default.MicOff, "Unmute", tint = Color.Red, modifier = Modifier.size(28.dp)) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon(Icons.Default.Videocam, "Camera on", tint = Color.White, modifier = Modifier.size(28.dp)) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon(Icons.Default.VolumeUp, "Earpiece", tint = Color.Cyan, modifier = Modifier.size(28.dp)) + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt new file mode 100644 index 000000000..f073a8e02 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.call + +import org.junit.Assert.assertEquals +import org.junit.Test + +class IceCandidateSerializationTest { + @Test + fun parseIceCandidateFromJson() { + val json = """{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx","sdpMid":"0","sdpMLineIndex":0}""" + val candidate = CallController.parseIceCandidate(json) + + assertEquals("candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx", candidate.sdp) + assertEquals("0", candidate.sdpMid) + assertEquals(0, candidate.sdpMLineIndex) + } + + @Test + fun parseIceCandidateWithDifferentIndex() { + val json = """{"candidate":"candidate:1 1 tcp 1234","sdpMid":"audio","sdpMLineIndex":1}""" + val candidate = CallController.parseIceCandidate(json) + + assertEquals("candidate:1 1 tcp 1234", candidate.sdp) + assertEquals("audio", candidate.sdpMid) + assertEquals(1, candidate.sdpMLineIndex) + } + + @Test + fun parseIceCandidateHandlesMissingFields() { + val json = """{"candidate":"test"}""" + val candidate = CallController.parseIceCandidate(json) + + assertEquals("test", candidate.sdp) + assertEquals("0", candidate.sdpMid) // default + assertEquals(0, candidate.sdpMLineIndex) // default + } + + @Test + fun serializeIceCandidateProducesValidJson() { + val candidate = org.webrtc.IceCandidate("audio", 1, "candidate:123 1 udp 456") + val json = CallController.serializeIceCandidate(candidate) + + // Verify it contains the expected fields + assert(json.contains(""""candidate":"candidate:123 1 udp 456"""")) + assert(json.contains(""""sdpMid":"audio"""")) + assert(json.contains(""""sdpMLineIndex":1""")) + } + + @Test + fun roundTripIceCandidate() { + val original = org.webrtc.IceCandidate("video", 2, "candidate:999 1 udp 789 10.0.0.1 5000 typ host") + val json = CallController.serializeIceCandidate(original) + val parsed = CallController.parseIceCandidate(json) + + assertEquals(original.sdp, parsed.sdp) + assertEquals(original.sdpMid, parsed.sdpMid) + assertEquals(original.sdpMLineIndex, parsed.sdpMLineIndex) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt new file mode 100644 index 000000000..ca69124e1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipACWebRtcCalls + +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import kotlin.test.Test +import kotlin.test.assertEquals + +class CallEventsTest { + @Test + fun callOfferEventHasCorrectKind() { + assertEquals(25050, CallOfferEvent.KIND) + } + + @Test + fun callAnswerEventHasCorrectKind() { + assertEquals(25051, CallAnswerEvent.KIND) + } + + @Test + fun callIceCandidateEventHasCorrectKind() { + assertEquals(25052, CallIceCandidateEvent.KIND) + } + + @Test + fun callHangupEventHasCorrectKind() { + assertEquals(25053, CallHangupEvent.KIND) + } + + @Test + fun callRejectEventHasCorrectKind() { + assertEquals(25054, CallRejectEvent.KIND) + } + + @Test + fun callRenegotiateEventHasCorrectKind() { + assertEquals(25055, CallRenegotiateEvent.KIND) + } + + @Test + fun callOfferBuildIncludesCallIdTag() { + val template = + CallOfferEvent.build( + sdpOffer = "v=0\r\n...", + calleePubKey = "abc123", + callId = "test-call-id", + type = CallType.VOICE, + ) + assertEquals(CallOfferEvent.KIND, template.kind) + assertEquals("v=0\r\n...", template.content) + + val callIdTag = template.tags.firstOrNull { it[0] == "call-id" } + assertEquals("test-call-id", callIdTag?.get(1)) + } + + @Test + fun callOfferBuildIncludesCallTypeTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKey = "abc123", + callId = "id", + type = CallType.VIDEO, + ) + val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" } + assertEquals("video", callTypeTag?.get(1)) + } + + @Test + fun callOfferBuildIncludesPTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKey = "recipient-hex-key", + callId = "id", + type = CallType.VOICE, + ) + val pTag = template.tags.firstOrNull { it[0] == "p" } + assertEquals("recipient-hex-key", pTag?.get(1)) + } + + @Test + fun callOfferBuildIncludesExpirationTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKey = "abc", + callId = "id", + type = CallType.VOICE, + createdAt = 1000L, + ) + val expirationTag = template.tags.firstOrNull { it[0] == "expiration" } + assertEquals((1000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1)) + } + + @Test + fun callOfferExpirationIs20Seconds() { + assertEquals(20L, CallOfferEvent.EXPIRATION_SECONDS) + } + + @Test + fun callAnswerBuildContainsSdp() { + val template = + CallAnswerEvent.build( + sdpAnswer = "answer-sdp", + callerPubKey = "caller", + callId = "call-1", + ) + assertEquals("answer-sdp", template.content) + assertEquals(CallAnswerEvent.KIND, template.kind) + } + + @Test + fun callIceCandidateBuildContainsJson() { + val candidateJson = """{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}""" + val template = + CallIceCandidateEvent.build( + candidateJson = candidateJson, + peerPubKey = "peer", + callId = "call-1", + ) + assertEquals(candidateJson, template.content) + } + + @Test + fun callHangupBuildAllowsEmptyReason() { + val template = + CallHangupEvent.build( + peerPubKey = "peer", + callId = "call-1", + ) + assertEquals("", template.content) + } + + @Test + fun callHangupBuildAllowsCustomReason() { + val template = + CallHangupEvent.build( + peerPubKey = "peer", + callId = "call-1", + reason = "busy", + ) + assertEquals("busy", template.content) + } + + @Test + fun callRejectBuildHasCorrectKind() { + val template = + CallRejectEvent.build( + callerPubKey = "caller", + callId = "call-1", + ) + assertEquals(CallRejectEvent.KIND, template.kind) + } + + @Test + fun callRenegotiateBuildContainsSdp() { + val template = + CallRenegotiateEvent.build( + sdpOffer = "new-sdp-offer", + peerPubKey = "peer", + callId = "call-1", + ) + assertEquals("new-sdp-offer", template.content) + assertEquals(CallRenegotiateEvent.KIND, template.kind) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt new file mode 100644 index 000000000..1d5f90109 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipACWebRtcCalls + +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class CallTagsTest { + @Test + fun parseCallIdTag() { + val tag = arrayOf("call-id", "550e8400-e29b-41d4-a716-446655440000") + val result = CallIdTag.parse(tag) + assertNotNull(result) + assertEquals("550e8400-e29b-41d4-a716-446655440000", result) + } + + @Test + fun parseCallIdTagRejectsEmpty() { + val tag = arrayOf("call-id", "") + assertNull(CallIdTag.parse(tag)) + } + + @Test + fun parseCallIdTagRejectsMissingValue() { + val tag = arrayOf("call-id") + assertNull(CallIdTag.parse(tag)) + } + + @Test + fun parseCallIdTagRejectsWrongName() { + val tag = arrayOf("other", "some-id") + assertNull(CallIdTag.parse(tag)) + } + + @Test + fun assembleCallIdTag() { + val tag = CallIdTag.assemble("test-id-123") + assertEquals("call-id", tag[0]) + assertEquals("test-id-123", tag[1]) + } + + @Test + fun roundTripCallIdTag() { + val original = "550e8400-e29b-41d4-a716-446655440000" + val assembled = CallIdTag.assemble(original) + val parsed = CallIdTag.parse(assembled) + assertEquals(original, parsed) + } + + @Test + fun parseCallTypeVoice() { + val tag = arrayOf("call-type", "voice") + val result = CallTypeTag.parse(tag) + assertNotNull(result) + assertEquals(CallType.VOICE, result) + } + + @Test + fun parseCallTypeVideo() { + val tag = arrayOf("call-type", "video") + val result = CallTypeTag.parse(tag) + assertNotNull(result) + assertEquals(CallType.VIDEO, result) + } + + @Test + fun parseCallTypeRejectsUnknown() { + val tag = arrayOf("call-type", "screenshare") + assertNull(CallTypeTag.parse(tag)) + } + + @Test + fun parseCallTypeRejectsMissing() { + val tag = arrayOf("call-type") + assertNull(CallTypeTag.parse(tag)) + } + + @Test + fun assembleCallTypeVoice() { + val tag = CallTypeTag.assemble(CallType.VOICE) + assertEquals("call-type", tag[0]) + assertEquals("voice", tag[1]) + } + + @Test + fun assembleCallTypeVideo() { + val tag = CallTypeTag.assemble(CallType.VIDEO) + assertEquals("call-type", tag[0]) + assertEquals("video", tag[1]) + } + + @Test + fun roundTripCallType() { + for (type in CallType.entries) { + val assembled = CallTypeTag.assemble(type) + val parsed = CallTypeTag.parse(assembled) + assertEquals(type, parsed) + } + } + + @Test + fun callTypeFromString() { + assertEquals(CallType.VOICE, CallType.fromString("voice")) + assertEquals(CallType.VIDEO, CallType.fromString("video")) + assertNull(CallType.fromString("audio")) + assertNull(CallType.fromString("")) + } +} From 44d6345e16c2e3701e63c37d79c7a7c97fb8add5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 02:39:41 +0000 Subject: [PATCH 49/63] refactor: improve separation of concerns in call feature 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 --- .../amethyst/service/call/CallController.kt | 52 ++++++------ .../amethyst/ui/call/CallScreen.kt | 36 ++++---- .../chats/privateDM/ChatroomScreen.kt | 6 +- .../call/IceCandidateSerializationTest.kt | 82 +++++++++---------- .../amethyst/commons/call/CallManager.kt | 31 ++++--- .../amethyst/commons/call/CallState.kt | 3 - .../events/CallIceCandidateEvent.kt | 21 +++++ 7 files changed, 118 insertions(+), 113 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index bb2072cb8..062a94484 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -74,6 +74,15 @@ class CallController( private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + private val _isAudioMuted = MutableStateFlow(false) + val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() + + private val _isVideoEnabled = MutableStateFlow(true) + val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() + + private val _isSpeakerOn = MutableStateFlow(false) + val isSpeakerOn: StateFlow = _isSpeakerOn.asStateFlow() + private var networkCallback: ConnectivityManager.NetworkCallback? = null init { @@ -185,9 +194,8 @@ class CallController( } fun onIceCandidateReceived(event: CallIceCandidateEvent) { - val json = event.candidateJson() try { - val candidate = parseIceCandidate(json) + val candidate = IceCandidate(event.sdpMid(), event.sdpMLineIndex(), event.candidateSdp()) if (webRtcSession != null && remoteDescriptionSet) { Log.d(TAG) { "Adding ICE candidate directly: ${candidate.sdp.take(50)}" } webRtcSession?.addIceCandidate(candidate) @@ -196,7 +204,7 @@ class CallController( pendingIceCandidates.add(candidate) } } catch (e: Exception) { - Log.e(TAG, "Failed to parse ICE candidate: $json", e) + Log.e(TAG, "Failed to parse ICE candidate", e) } } @@ -209,15 +217,21 @@ class CallController( candidates.forEach { session.addIceCandidate(it) } } - fun setAudioMuted(muted: Boolean) { + fun toggleAudioMute() { + val muted = !_isAudioMuted.value + _isAudioMuted.value = muted webRtcSession?.setAudioEnabled(!muted) } - fun setVideoEnabled(enabled: Boolean) { + fun toggleVideo() { + val enabled = !_isVideoEnabled.value + _isVideoEnabled.value = enabled webRtcSession?.setVideoEnabled(enabled) } - fun setSpeakerOn(on: Boolean) { + fun toggleSpeaker() { + val on = !_isSpeakerOn.value + _isSpeakerOn.value = on val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager am.isSpeakerphoneOn = on } @@ -240,6 +254,9 @@ class CallController( NotificationUtils.cancelCallNotification(context) _remoteVideoTrack.value = null _localVideoTrack.value = null + _isAudioMuted.value = false + _isVideoEnabled.value = true + _isSpeakerOn.value = false webRtcSession?.dispose() webRtcSession = null currentCallId = null @@ -316,7 +333,7 @@ class CallController( Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" } val callId = currentCallId ?: return val peerPubKey = currentPeerPubKey ?: return - val candidateJson = serializeIceCandidate(candidate) + val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex) scope.launch { val signer = signerProvider() @@ -344,25 +361,4 @@ class CallController( } catch (_: Exception) { } } - - companion object { - fun serializeIceCandidate(candidate: IceCandidate): String = """{"candidate":"${candidate.sdp}","sdpMid":"${candidate.sdpMid}","sdpMLineIndex":${candidate.sdpMLineIndex}}""" - - fun parseIceCandidate(json: String): IceCandidate { - val candidateRegex = """"candidate"\s*:\s*"([^"]*)"""".toRegex() - val sdpMidRegex = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex() - val sdpMLineIndexRegex = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex() - - val sdp = candidateRegex.find(json)?.groupValues?.get(1) ?: "" - val sdpMid = sdpMidRegex.find(json)?.groupValues?.get(1) ?: "0" - val sdpMLineIndex = - sdpMLineIndexRegex - .find(json) - ?.groupValues - ?.get(1) - ?.toIntOrNull() ?: 0 - - return IceCandidate(sdpMid, sdpMLineIndex, sdp) - } - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 1d4e5c432..7334722a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -142,18 +142,9 @@ fun CallScreen( callController = callController, accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, - onToggleMute = { - callManager.toggleAudioMute() - callController?.setAudioMuted(!state.isAudioMuted) - }, - onToggleVideo = { - callManager.toggleVideo() - callController?.setVideoEnabled(!state.isVideoEnabled) - }, - onToggleSpeaker = { - callManager.toggleSpeaker() - callController?.setSpeakerOn(!state.isSpeakerOn) - }, + onToggleMute = { callController?.toggleAudioMute() }, + onToggleVideo = { callController?.toggleVideo() }, + onToggleSpeaker = { callController?.toggleSpeaker() }, ) } @@ -338,6 +329,9 @@ private fun ConnectedCallUI( val remoteVideoTrack by (callController?.remoteVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() val localVideoTrack by (callController?.localVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() + val isAudioMuted by (callController?.isAudioMuted ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState() + val isVideoEnabled by (callController?.isVideoEnabled ?: kotlinx.coroutines.flow.MutableStateFlow(true)).collectAsState() + val isSpeakerOn by (callController?.isSpeakerOn ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState() Box( modifier = @@ -429,9 +423,9 @@ private fun ConnectedCallUI( modifier = Modifier.size(56.dp), ) { Icon( - imageVector = if (state.isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic, - contentDescription = if (state.isAudioMuted) "Unmute" else "Mute", - tint = if (state.isAudioMuted) Color.Red else Color.White, + imageVector = if (isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = if (isAudioMuted) "Unmute" else "Mute", + tint = if (isAudioMuted) Color.Red else Color.White, modifier = Modifier.size(28.dp), ) } @@ -440,9 +434,9 @@ private fun ConnectedCallUI( modifier = Modifier.size(56.dp), ) { Icon( - imageVector = if (state.isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, - contentDescription = if (state.isVideoEnabled) "Camera off" else "Camera on", - tint = if (!state.isVideoEnabled) Color.Red else Color.White, + imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, + contentDescription = if (isVideoEnabled) "Camera off" else "Camera on", + tint = if (!isVideoEnabled) Color.Red else Color.White, modifier = Modifier.size(28.dp), ) } @@ -451,9 +445,9 @@ private fun ConnectedCallUI( modifier = Modifier.size(56.dp), ) { Icon( - imageVector = if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, - contentDescription = if (state.isSpeakerOn) "Earpiece" else "Speaker", - tint = if (state.isSpeakerOn) Color.Cyan else Color.White, + imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, + contentDescription = if (isSpeakerOn) "Earpiece" else "Speaker", + tint = if (isSpeakerOn) Color.Cyan else Color.White, modifier = Modifier.size(28.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index d41e37ee8..5922cd306 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -52,13 +52,15 @@ fun ChatroomScreen( rememberCallWithPermission(context) { val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) - nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) + val callId = accountViewModel.callManager.currentCallId() ?: "" + nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey)) } val startVideoCall = rememberCallWithPermission(context) { val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) - nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) + val callId = accountViewModel.callManager.currentCallId() ?: "" + nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey)) } DisappearingScaffold( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt index f073a8e02..760fb1f29 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt @@ -20,59 +20,57 @@ */ package com.vitorpamplona.amethyst.service.call +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import org.junit.Assert.assertEquals import org.junit.Test class IceCandidateSerializationTest { @Test - fun parseIceCandidateFromJson() { - val json = """{"candidate":"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx","sdpMid":"0","sdpMLineIndex":0}""" - val candidate = CallController.parseIceCandidate(json) - - assertEquals("candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx", candidate.sdp) - assertEquals("0", candidate.sdpMid) - assertEquals(0, candidate.sdpMLineIndex) - } - - @Test - fun parseIceCandidateWithDifferentIndex() { - val json = """{"candidate":"candidate:1 1 tcp 1234","sdpMid":"audio","sdpMLineIndex":1}""" - val candidate = CallController.parseIceCandidate(json) - - assertEquals("candidate:1 1 tcp 1234", candidate.sdp) - assertEquals("audio", candidate.sdpMid) - assertEquals(1, candidate.sdpMLineIndex) - } - - @Test - fun parseIceCandidateHandlesMissingFields() { - val json = """{"candidate":"test"}""" - val candidate = CallController.parseIceCandidate(json) - - assertEquals("test", candidate.sdp) - assertEquals("0", candidate.sdpMid) // default - assertEquals(0, candidate.sdpMLineIndex) // default - } - - @Test - fun serializeIceCandidateProducesValidJson() { - val candidate = org.webrtc.IceCandidate("audio", 1, "candidate:123 1 udp 456") - val json = CallController.serializeIceCandidate(candidate) - - // Verify it contains the expected fields + fun serializeCandidateProducesValidJson() { + val json = CallIceCandidateEvent.serializeCandidate("candidate:123 1 udp 456", "audio", 1) assert(json.contains(""""candidate":"candidate:123 1 udp 456"""")) assert(json.contains(""""sdpMid":"audio"""")) assert(json.contains(""""sdpMLineIndex":1""")) } @Test - fun roundTripIceCandidate() { - val original = org.webrtc.IceCandidate("video", 2, "candidate:999 1 udp 789 10.0.0.1 5000 typ host") - val json = CallController.serializeIceCandidate(original) - val parsed = CallController.parseIceCandidate(json) + fun serializeAndParseRoundTrip() { + val sdp = "candidate:999 1 udp 789 10.0.0.1 5000 typ host" + val sdpMid = "video" + val sdpMLineIndex = 2 - assertEquals(original.sdp, parsed.sdp) - assertEquals(original.sdpMid, parsed.sdpMid) - assertEquals(original.sdpMLineIndex, parsed.sdpMLineIndex) + val json = CallIceCandidateEvent.serializeCandidate(sdp, sdpMid, sdpMLineIndex) + + // Create a minimal event to test parsing + val event = + CallIceCandidateEvent( + id = "test", + pubKey = "test", + createdAt = 0, + tags = emptyArray(), + content = json, + sig = "test", + ) + + assertEquals(sdp, event.candidateSdp()) + assertEquals(sdpMid, event.sdpMid()) + assertEquals(sdpMLineIndex, event.sdpMLineIndex()) + } + + @Test + fun parseCandidateHandlesMissingFields() { + val event = + CallIceCandidateEvent( + id = "test", + pubKey = "test", + createdAt = 0, + tags = emptyArray(), + content = """{"candidate":"test"}""", + sig = "test", + ) + + assertEquals("test", event.candidateSdp()) + assertEquals("0", event.sdpMid()) // default + assertEquals(0, event.sdpMLineIndex()) // default } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 83660deca..de2cddd8d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -216,26 +216,23 @@ class CallManager( } } - fun toggleAudioMute() { - val current = _state.value - if (current is CallState.Connected) { - _state.value = current.copy(isAudioMuted = !current.isAudioMuted) + fun currentCallId(): String? = + when (val s = _state.value) { + is CallState.Offering -> s.callId + is CallState.IncomingCall -> s.callId + is CallState.Connecting -> s.callId + is CallState.Connected -> s.callId + else -> null } - } - fun toggleVideo() { - val current = _state.value - if (current is CallState.Connected) { - _state.value = current.copy(isVideoEnabled = !current.isVideoEnabled) + fun currentPeerPubKey(): HexKey? = + when (val s = _state.value) { + is CallState.Offering -> s.peerPubKey + is CallState.IncomingCall -> s.callerPubKey + is CallState.Connecting -> s.peerPubKey + is CallState.Connected -> s.peerPubKey + else -> null } - } - - fun toggleSpeaker() { - val current = _state.value - if (current is CallState.Connected) { - _state.value = current.copy(isSpeakerOn = !current.isSpeakerOn) - } - } fun reset() { _state.value = CallState.Idle diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt index ea20a4667..79334a527 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -52,9 +52,6 @@ sealed interface CallState { val peerPubKey: HexKey, val callType: CallType, val startedAtEpoch: Long, - val isAudioMuted: Boolean = false, - val isVideoEnabled: Boolean = true, - val isSpeakerOn: Boolean = false, ) : CallState data class Ended( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt index 4948110cd..4c3734e6c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt @@ -45,11 +45,32 @@ class CallIceCandidateEvent( fun candidateJson() = content + fun candidateSdp(): String = CANDIDATE_REGEX.find(content)?.groupValues?.get(1) ?: "" + + fun sdpMid(): String = SDP_MID_REGEX.find(content)?.groupValues?.get(1) ?: "0" + + fun sdpMLineIndex(): Int = + SDP_MLINE_INDEX_REGEX + .find(content) + ?.groupValues + ?.get(1) + ?.toIntOrNull() ?: 0 + companion object { const val KIND = 25052 const val ALT_DESCRIPTION = "WebRTC ICE candidate" const val EXPIRATION_SECONDS = 20L + private val CANDIDATE_REGEX = """"candidate"\s*:\s*"([^"]*)"""".toRegex() + private val SDP_MID_REGEX = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex() + private val SDP_MLINE_INDEX_REGEX = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex() + + fun serializeCandidate( + sdp: String, + sdpMid: String, + sdpMLineIndex: Int, + ): String = """{"candidate":"$sdp","sdpMid":"$sdpMid","sdpMLineIndex":$sdpMLineIndex}""" + fun build( candidateJson: String, peerPubKey: HexKey, From 24d7fb1a924f7b6e0174d5e8b72b8af56a2a5f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 02:45:36 +0000 Subject: [PATCH 50/63] refactor: fix race conditions, null safety, and dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/service/call/CallController.kt | 201 +++++++----------- .../amethyst/ui/call/CallPermissions.kt | 8 - .../amethyst/ui/call/CallScreen.kt | 16 +- .../ui/screen/loggedIn/AccountViewModel.kt | 8 +- 4 files changed, 95 insertions(+), 138 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 062a94484..84bd340b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -23,14 +23,9 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent import android.media.AudioManager -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent @@ -52,39 +47,35 @@ private const val TAG = "CallController" class CallController( private val context: Context, - private val callManager: CallManager, + val callManager: CallManager, private val scope: CoroutineScope, private val publishWrap: suspend (GiftWrapEvent) -> Unit, private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, ) { private var webRtcSession: WebRtcCallSession? = null private val callFactory = WebRtcCallFactory() - private var currentCallId: String? = null - private var currentPeerPubKey: HexKey? = null private var remoteDescriptionSet = false private val pendingIceCandidates = CopyOnWriteArrayList() val audioManager = CallAudioManager(context) + // Video tracks exposed to UI private val _remoteVideoTrack = MutableStateFlow(null) val remoteVideoTrack: StateFlow = _remoteVideoTrack.asStateFlow() - private val _localVideoTrack = MutableStateFlow(null) val localVideoTrack: StateFlow = _localVideoTrack.asStateFlow() + // Error state exposed to UI private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + // Audio/video toggle state (UI concerns, not domain state) private val _isAudioMuted = MutableStateFlow(false) val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() - private val _isVideoEnabled = MutableStateFlow(true) val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() - private val _isSpeakerOn = MutableStateFlow(false) val isSpeakerOn: StateFlow = _isSpeakerOn.asStateFlow() - private var networkCallback: ConnectivityManager.NetworkCallback? = null - init { scope.launch { callManager.state.collect { state -> @@ -102,7 +93,6 @@ class CallController( audioManager.stopRingbackTone() audioManager.switchToCallAudioMode() audioManager.acquireProximityWakeLock() - registerNetworkCallback() } is CallState.Connected -> { @@ -120,76 +110,82 @@ class CallController( } fun initiateCall( - peerPubKey: HexKey, + peerPubKey: String, callType: CallType, ) { + val callId = UUID.randomUUID().toString() + _errorMessage.value = null + remoteDescriptionSet = false + pendingIceCandidates.clear() + try { - val callId = UUID.randomUUID().toString() - currentCallId = callId - currentPeerPubKey = peerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() - _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() + } catch (e: Exception) { + Log.e(TAG, "Failed to create WebRTC session", e) + _errorMessage.value = "Failed to start call: ${e.message}" + return + } + + val session = + webRtcSession ?: run { + _errorMessage.value = "Failed to create WebRTC session" + return } - webRtcSession?.createOffer { sdp -> - scope.launch { - callManager.initiateCall(peerPubKey, callType, callId, sdp.description) - } + session.addAudioTrack() + if (callType == CallType.VIDEO) { + session.addVideoTrack() + _localVideoTrack.value = session.getLocalVideoTrack() + } + + session.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) } - } catch (e: Exception) { - Log.e(TAG, "Failed to initiate call", e) - _errorMessage.value = "Failed to start call: ${e.message}" - cleanup() } } fun acceptIncomingCall(sdpOffer: String) { + val state = callManager.state.value + if (state !is CallState.IncomingCall) return + + _errorMessage.value = null + remoteDescriptionSet = false + pendingIceCandidates.clear() + try { - val state = callManager.state.value - if (state !is CallState.IncomingCall) return - - currentCallId = state.callId - currentPeerPubKey = state.callerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() - _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (state.callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() - } - - webRtcSession?.setRemoteDescription( - SessionDescription(SessionDescription.Type.OFFER, sdpOffer), - ) - flushPendingIceCandidates() - - webRtcSession?.createAnswer { sdp -> - scope.launch { - callManager.acceptCall(sdp.description) - } - } } catch (e: Exception) { - Log.e(TAG, "Failed to accept call", e) + Log.e(TAG, "Failed to create WebRTC session", e) _errorMessage.value = "Failed to accept call: ${e.message}" - cleanup() + return + } + + val session = + webRtcSession ?: run { + _errorMessage.value = "Failed to create WebRTC session" + return + } + + session.addAudioTrack() + if (state.callType == CallType.VIDEO) { + session.addVideoTrack() + _localVideoTrack.value = session.getLocalVideoTrack() + } + + session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) + flushPendingIceCandidates() + + session.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } } } fun onCallAnswerReceived(sdpAnswer: String) { Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, session=${webRtcSession != null}" } - webRtcSession?.setRemoteDescription( - SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), - ) + webRtcSession?.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer)) flushPendingIceCandidates() } @@ -217,6 +213,7 @@ class CallController( candidates.forEach { session.addIceCandidate(it) } } + // UI toggle controls fun toggleAudioMute() { val muted = !_isAudioMuted.value _isAudioMuted.value = muted @@ -249,7 +246,6 @@ class CallController( fun cleanup() { audioManager.release() - unregisterNetworkCallback() stopForegroundService() NotificationUtils.cancelCallNotification(context) _remoteVideoTrack.value = null @@ -259,80 +255,34 @@ class CallController( _isSpeakerOn.value = false webRtcSession?.dispose() webRtcSession = null - currentCallId = null - currentPeerPubKey = null remoteDescriptionSet = false pendingIceCandidates.clear() } private fun createWebRtcSession() { - val iceServers = IceServerConfig.buildIceServers() - webRtcSession = WebRtcCallSession( context = context, - iceServers = iceServers, + iceServers = IceServerConfig.buildIceServers(), onIceCandidate = { candidate -> onLocalIceCandidate(candidate) }, onPeerConnected = { callManager.onPeerConnected() startForegroundService() }, onRemoteStream = { stream: MediaStream -> - stream.videoTracks?.firstOrNull()?.let { - _remoteVideoTrack.value = it - } - }, - onDisconnected = { - scope.launch { callManager.hangup() } - }, - onError = { error -> - _errorMessage.value = error + stream.videoTracks?.firstOrNull()?.let { _remoteVideoTrack.value = it } }, + onDisconnected = { scope.launch { callManager.hangup() } }, + onError = { error -> _errorMessage.value = error }, ) webRtcSession?.initialize() webRtcSession?.createPeerConnection() } - private fun registerNetworkCallback() { - try { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val request = - NetworkRequest - .Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - val callback = - object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - Log.d(TAG) { "Network available, ICE restart may be needed" } - } - - override fun onLost(network: Network) { - Log.d(TAG) { "Network lost during call" } - } - } - connectivityManager.registerNetworkCallback(request, callback) - networkCallback = callback - } catch (e: Exception) { - Log.e(TAG, "Failed to register network callback", e) - } - } - - private fun unregisterNetworkCallback() { - try { - networkCallback?.let { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - connectivityManager.unregisterNetworkCallback(it) - } - } catch (_: Exception) { - } - networkCallback = null - } - private fun onLocalIceCandidate(candidate: IceCandidate) { Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" } - val callId = currentCallId ?: return - val peerPubKey = currentPeerPubKey ?: return + val callId = callManager.currentCallId() ?: return + val peerPubKey = callManager.currentPeerPubKey() ?: return val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex) scope.launch { @@ -343,12 +293,17 @@ class CallController( } private fun startForegroundService() { - val intent = - Intent(context, CallForegroundService::class.java).apply { - action = CallForegroundService.ACTION_START - putExtra(CallForegroundService.EXTRA_PEER_NAME, currentPeerPubKey ?: "") - } - context.startForegroundService(intent) + try { + val peerName = callManager.currentPeerPubKey() ?: "" + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_START + putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName) + } + context.startForegroundService(intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service", e) + } } private fun stopForegroundService() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt index 41f2acbea..5d37e975b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -29,14 +29,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.core.content.ContextCompat -@Composable -fun rememberCallPermissionLauncher(onGranted: () -> Unit) = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - if (granted) onGranted() - } - fun hasAudioPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 7334722a7..a53ecd5f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -90,7 +90,8 @@ fun CallScreen( val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() val context = LocalContext.current - val errorMessage by (callController?.errorMessage ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() + val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(null) } + val errorMessage by (callController?.errorMessage ?: emptyStringFlow).collectAsState() BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) { scope.launch { callManager.hangup() } @@ -327,11 +328,14 @@ private fun ConnectedCallUI( } } - val remoteVideoTrack by (callController?.remoteVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() - val localVideoTrack by (callController?.localVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() - val isAudioMuted by (callController?.isAudioMuted ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState() - val isVideoEnabled by (callController?.isVideoEnabled ?: kotlinx.coroutines.flow.MutableStateFlow(true)).collectAsState() - val isSpeakerOn by (callController?.isSpeakerOn ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState() + val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(null) } + val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).collectAsState() + val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() + val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } + val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } + val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState() + val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() + val isSpeakerOn by (callController?.isSpeakerOn ?: defaultFalse).collectAsState() Box( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index acdea1d56..1d45da3d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -204,8 +204,13 @@ class AccountViewModel( var callController: CallController? = null private set + @Synchronized fun initCallController(context: Context) { if (callController != null) return + + // Wire EventProcessor before creating CallController so events aren't dropped + account.newNotesPreProcessor.callManager = callManager + val controller = CallController( context = context.applicationContext, @@ -214,9 +219,10 @@ class AccountViewModel( publishWrap = { wrap -> account.publishCallSignaling(wrap) }, signerProvider = { account.signer }, ) + + // Set callbacks before exposing controller to avoid timing races callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) } callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } - account.newNotesPreProcessor.callManager = callManager callController = controller } From 0bd26bcacd78009e0e7d31f7e98c3a611921e525 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 02:57:15 +0000 Subject: [PATCH 51/63] feat: localized strings, PiP support, and ICE candidate fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- amethyst/src/main/AndroidManifest.xml | 3 +- .../amethyst/service/call/CallController.kt | 104 +++++++++--------- .../service/call/CallForegroundService.kt | 6 +- .../notifications/NotificationUtils.kt | 10 +- .../amethyst/ui/call/CallScreen.kt | 67 +++++++++-- .../privateDM/header/RenderRoomTopBar.kt | 4 +- amethyst/src/main/res/values/strings.xml | 28 +++++ 7 files changed, 150 insertions(+), 72 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index dc94b6ed5..1009e17b9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -80,7 +80,8 @@ android:exported="true" android:launchMode="singleInstance" android:windowSoftInputMode="adjustResize" - android:configChanges="orientation|screenSize|screenLayout" + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize" + android:supportsPictureInPicture="true" android:theme="@style/Theme.Amethyst"> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 84bd340b8..12b5f4236 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -32,10 +32,12 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.webrtc.IceCandidate import org.webrtc.MediaStream import org.webrtc.SessionDescription @@ -113,34 +115,36 @@ class CallController( peerPubKey: String, callType: CallType, ) { - val callId = UUID.randomUUID().toString() - _errorMessage.value = null - remoteDescriptionSet = false - pendingIceCandidates.clear() + scope.launch { + val callId = UUID.randomUUID().toString() + _errorMessage.value = null + remoteDescriptionSet = false + pendingIceCandidates.clear() - try { - createWebRtcSession() - } catch (e: Exception) { - Log.e(TAG, "Failed to create WebRTC session", e) - _errorMessage.value = "Failed to start call: ${e.message}" - return - } - - val session = - webRtcSession ?: run { - _errorMessage.value = "Failed to create WebRTC session" - return + try { + withContext(Dispatchers.IO) { createWebRtcSession() } + } catch (e: Exception) { + Log.e(TAG, "Failed to create WebRTC session", e) + _errorMessage.value = "Failed to start call: ${e.message}" + return@launch } - session.addAudioTrack() - if (callType == CallType.VIDEO) { - session.addVideoTrack() - _localVideoTrack.value = session.getLocalVideoTrack() - } + val session = + webRtcSession ?: run { + _errorMessage.value = "Failed to create WebRTC session" + return@launch + } - session.createOffer { sdp -> - scope.launch { - callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + session.addAudioTrack() + if (callType == CallType.VIDEO) { + session.addVideoTrack() + _localVideoTrack.value = session.getLocalVideoTrack() + } + + session.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + } } } } @@ -149,36 +153,38 @@ class CallController( val state = callManager.state.value if (state !is CallState.IncomingCall) return - _errorMessage.value = null - remoteDescriptionSet = false - pendingIceCandidates.clear() + scope.launch { + _errorMessage.value = null + remoteDescriptionSet = false + // Don't clear pendingIceCandidates here — they were buffered while ringing - try { - createWebRtcSession() - } catch (e: Exception) { - Log.e(TAG, "Failed to create WebRTC session", e) - _errorMessage.value = "Failed to accept call: ${e.message}" - return - } - - val session = - webRtcSession ?: run { - _errorMessage.value = "Failed to create WebRTC session" - return + try { + withContext(Dispatchers.IO) { createWebRtcSession() } + } catch (e: Exception) { + Log.e(TAG, "Failed to create WebRTC session", e) + _errorMessage.value = "Failed to accept call: ${e.message}" + return@launch } - session.addAudioTrack() - if (state.callType == CallType.VIDEO) { - session.addVideoTrack() - _localVideoTrack.value = session.getLocalVideoTrack() - } + val session = + webRtcSession ?: run { + _errorMessage.value = "Failed to create WebRTC session" + return@launch + } - session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) - flushPendingIceCandidates() + session.addAudioTrack() + if (state.callType == CallType.VIDEO) { + session.addVideoTrack() + _localVideoTrack.value = session.getLocalVideoTrack() + } - session.createAnswer { sdp -> - scope.launch { - callManager.acceptCall(sdp.description) + session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) + flushPendingIceCandidates() + + session.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 448b3fad3..96944b69e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -81,10 +81,10 @@ class CallForegroundService : Service() { val channel = NotificationChannel( CHANNEL_ID, - "Calls", + getString(R.string.call_ongoing), NotificationManager.IMPORTANCE_LOW, ).apply { - description = "Ongoing call notification" + description = getString(R.string.call_ongoing_description) } val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) @@ -94,7 +94,7 @@ class CallForegroundService : Service() { NotificationCompat .Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) - .setContentText("Call with $peerName") + .setContentText(getString(R.string.call_with, peerName)) .setSmallIcon(R.drawable.amethyst) .setOngoing(true) .build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 08d00f9f7..f59f44776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -519,10 +519,10 @@ object NotificationUtils { callChannel = NotificationChannel( CALL_CHANNEL_ID, - "Incoming calls", + stringRes(applicationContext, R.string.app_notification_calls_channel_name), NotificationManager.IMPORTANCE_HIGH, ).apply { - description = "Notifications for incoming voice and video calls" + description = stringRes(applicationContext, R.string.app_notification_calls_channel_description) } val notificationManager: NotificationManager = @@ -573,7 +573,7 @@ object NotificationUtils { NotificationCompat .Builder(applicationContext, channel.id) .setSmallIcon(R.drawable.amethyst) - .setContentTitle("Incoming call") + .setContentTitle(stringRes(applicationContext, R.string.call_incoming)) .setContentText(callerName) .setLargeIcon(callerBitmap) .setContentIntent(contentPendingIntent) @@ -584,8 +584,8 @@ object NotificationUtils { .setOngoing(true) .setTimeoutAfter(60_000) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .addAction(R.drawable.amethyst, "Reject", contentPendingIntent) - .addAction(R.drawable.amethyst, "Accept", fullScreenPendingIntent) + .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), contentPendingIntent) + .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), fullScreenPendingIntent) notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index a53ecd5f4..db5642629 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.call.CallController @@ -73,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -98,6 +100,7 @@ fun CallScreen( } KeepScreenOn() + EnterPipOnLeave(callState) Box(modifier = Modifier.fillMaxSize()) { when (val state = callState) { @@ -108,7 +111,7 @@ fun CallScreen( is CallState.Offering -> { CallInProgressUI( peerPubKey = state.peerPubKey, - statusText = "Calling...", + statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, ) @@ -131,7 +134,7 @@ fun CallScreen( is CallState.Connecting -> { CallInProgressUI( peerPubKey = state.peerPubKey, - statusText = "Connecting...", + statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, ) @@ -152,7 +155,7 @@ fun CallScreen( is CallState.Ended -> { CallInProgressUI( peerPubKey = state.peerPubKey, - statusText = "Call ended", + statusText = stringRes(R.string.call_ended), accountViewModel = accountViewModel, onHangup = { onCallEnded() }, ) @@ -164,7 +167,7 @@ fun CallScreen( modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), action = { Text( - "Dismiss", + stringRes(R.string.call_dismiss), modifier = Modifier.padding(8.dp), color = MaterialTheme.colorScheme.inversePrimary, @@ -225,7 +228,7 @@ private fun CallInProgressUI( ) { Icon( Icons.Default.CallEnd, - contentDescription = "Hang up", + contentDescription = stringRes(R.string.call_hangup), tint = Color.White, modifier = Modifier.size(32.dp), ) @@ -270,7 +273,14 @@ private fun IncomingCallUI( } Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Incoming ${callType.value} call...", + text = + stringRes( + if (callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO) { + R.string.call_incoming_video + } else { + R.string.call_incoming_voice + }, + ), color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp, ) @@ -286,7 +296,7 @@ private fun IncomingCallUI( ) { Icon( Icons.Default.CallEnd, - contentDescription = "Reject", + contentDescription = stringRes(R.string.call_reject), tint = Color.White, modifier = Modifier.size(32.dp), ) @@ -299,7 +309,7 @@ private fun IncomingCallUI( ) { Icon( Icons.Default.Call, - contentDescription = "Accept", + contentDescription = stringRes(R.string.call_accept), tint = Color.White, modifier = Modifier.size(32.dp), ) @@ -428,7 +438,7 @@ private fun ConnectedCallUI( ) { Icon( imageVector = if (isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic, - contentDescription = if (isAudioMuted) "Unmute" else "Mute", + contentDescription = stringRes(if (isAudioMuted) R.string.call_unmute else R.string.call_mute), tint = if (isAudioMuted) Color.Red else Color.White, modifier = Modifier.size(28.dp), ) @@ -439,7 +449,7 @@ private fun ConnectedCallUI( ) { Icon( imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, - contentDescription = if (isVideoEnabled) "Camera off" else "Camera on", + contentDescription = stringRes(if (isVideoEnabled) R.string.call_camera_off else R.string.call_camera_on), tint = if (!isVideoEnabled) Color.Red else Color.White, modifier = Modifier.size(28.dp), ) @@ -450,7 +460,7 @@ private fun ConnectedCallUI( ) { Icon( imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, - contentDescription = if (isSpeakerOn) "Earpiece" else "Speaker", + contentDescription = stringRes(if (isSpeakerOn) R.string.call_earpiece else R.string.call_speaker), tint = if (isSpeakerOn) Color.Cyan else Color.White, modifier = Modifier.size(28.dp), ) @@ -465,7 +475,7 @@ private fun ConnectedCallUI( ) { Icon( Icons.Default.CallEnd, - contentDescription = "Hang up", + contentDescription = stringRes(R.string.call_hangup), tint = Color.White, modifier = Modifier.size(32.dp), ) @@ -515,3 +525,36 @@ private fun KeepScreenOn() { } } } + +@Composable +private fun EnterPipOnLeave(callState: CallState) { + val context = LocalContext.current + val activity = context as? android.app.Activity ?: return + val isActiveCall = + callState is CallState.Connected || + callState is CallState.Connecting || + callState is CallState.Offering + + if (isActiveCall && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = + object : androidx.lifecycle.DefaultLifecycleObserver { + override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { + try { + val params = + android.app.PictureInPictureParams + .Builder() + .setAspectRatio(android.util.Rational(9, 16)) + .build() + activity.enterPictureInPictureMode(params) + } catch (_: Exception) { + // PiP not supported or activity not in correct state + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index 739338e76..d97d7ee4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -99,7 +99,7 @@ fun RenderRoomTopBar( ) { Icon( imageVector = Icons.Default.Videocam, - contentDescription = "Video call", + contentDescription = stringRes(R.string.call_video), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) @@ -113,7 +113,7 @@ fun RenderRoomTopBar( ) { Icon( imageVector = Icons.Default.Call, - contentDescription = "Voice call", + contentDescription = stringRes(R.string.call_voice), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 866522906..0e84ddb15 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -771,6 +771,34 @@ %1$s moved — your turn Chess updates + + Incoming calls + Notifications for incoming voice and video calls + Incoming call + Incoming voice call\u2026 + Incoming video call\u2026 + Calling\u2026 + Connecting\u2026 + Call ended + Call with %1$s + Calls + Ongoing call notification + Hang up + Accept + Reject + Dismiss + Mute + Unmute + Camera on + Camera off + Speaker + Earpiece + Voice call + Video call + Failed to start call + Failed to accept call + Failed to create call session + Notify: Join Conversation From 6e92e8153d293b747d7a55d7bc8c769ec45447a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 03:08:17 +0000 Subject: [PATCH 52/63] feat: add default TURN servers for same-network call connectivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/service/call/IceServerConfig.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt index b83598961..402a1bf7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt @@ -30,8 +30,27 @@ object IceServerConfig { PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(), ) + val defaultTurnServers = + listOf( + PeerConnection.IceServer + .builder("turn:openrelay.metered.ca:80") + .setUsername("openrelayproject") + .setPassword("openrelayproject") + .createIceServer(), + PeerConnection.IceServer + .builder("turn:openrelay.metered.ca:443") + .setUsername("openrelayproject") + .setPassword("openrelayproject") + .createIceServer(), + PeerConnection.IceServer + .builder("turn:openrelay.metered.ca:443?transport=tcp") + .setUsername("openrelayproject") + .setPassword("openrelayproject") + .createIceServer(), + ) + fun buildIceServers(userTurnServers: List = emptyList()): List { - val servers = defaultStunServers.toMutableList() + val servers = (defaultStunServers + defaultTurnServers).toMutableList() userTurnServers.forEach { turn -> servers.add( PeerConnection.IceServer From 0e31afbb25d4dc922446989430e1fb8521c07c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 03:15:22 +0000 Subject: [PATCH 53/63] feat: add Bluetooth headset audio routing for calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- amethyst/src/main/AndroidManifest.xml | 1 + .../amethyst/service/call/CallAudioManager.kt | 157 ++++++++++++++++-- .../amethyst/service/call/CallController.kt | 13 +- .../amethyst/ui/call/CallScreen.kt | 34 +++- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 180 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 1009e17b9..f5e39a388 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -44,6 +44,7 @@ + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index d5168878b..d703c22d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -20,8 +20,12 @@ */ package com.vitorpamplona.amethyst.service.call +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.media.AudioAttributes +import android.media.AudioDeviceInfo import android.media.AudioManager import android.media.Ringtone import android.media.RingtoneManager @@ -31,6 +35,15 @@ import android.os.PowerManager import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class AudioRoute { + EARPIECE, + SPEAKER, + BLUETOOTH, +} class CallAudioManager( private val context: Context, @@ -41,6 +54,13 @@ class CallAudioManager( private var ringbackTone: ToneGenerator? = null private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager private var previousAudioMode: Int = AudioManager.MODE_NORMAL + private var scoReceiver: BroadcastReceiver? = null + + private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE) + val audioRoute: StateFlow = _audioRoute.asStateFlow() + + private val _isBluetoothAvailable = MutableStateFlow(false) + val isBluetoothAvailable: StateFlow = _isBluetoothAvailable.asStateFlow() fun startRinging() { startRingtone() @@ -59,7 +79,6 @@ class CallAudioManager( it.startTone(ToneGenerator.TONE_SUP_RINGTONE) } } catch (_: Exception) { - // ToneGenerator may not be available } } @@ -72,14 +91,43 @@ class CallAudioManager( fun switchToCallAudioMode() { previousAudioMode = audioManager.mode audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - audioManager.isSpeakerphoneOn = false + + _isBluetoothAvailable.value = hasBluetoothDevice() + if (_isBluetoothAvailable.value) { + startBluetoothSco() + } else { + routeToEarpiece() + } + registerBluetoothScoReceiver() } fun restoreAudioMode() { + stopBluetoothSco() + unregisterBluetoothScoReceiver() audioManager.mode = previousAudioMode audioManager.isSpeakerphoneOn = false } + fun cycleAudioRoute() { + val hasBt = _isBluetoothAvailable.value + val next = + when (_audioRoute.value) { + AudioRoute.EARPIECE -> if (hasBt) AudioRoute.BLUETOOTH else AudioRoute.SPEAKER + AudioRoute.BLUETOOTH -> AudioRoute.SPEAKER + AudioRoute.SPEAKER -> AudioRoute.EARPIECE + } + setAudioRoute(next) + } + + fun setAudioRoute(route: AudioRoute) { + _audioRoute.value = route + when (route) { + AudioRoute.EARPIECE -> routeToEarpiece() + AudioRoute.SPEAKER -> routeToSpeaker() + AudioRoute.BLUETOOTH -> routeToBluetooth() + } + } + fun acquireProximityWakeLock() { if (proximityWakeLock != null) return val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager @@ -88,13 +136,11 @@ class CallAudioManager( PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "amethyst:call_proximity", ) - proximityWakeLock?.acquire(60 * 60 * 1000L) // 1 hour max + proximityWakeLock?.acquire(60 * 60 * 1000L) } fun releaseProximityWakeLock() { - proximityWakeLock?.let { - if (it.isHeld) it.release() - } + proximityWakeLock?.let { if (it.isHeld) it.release() } proximityWakeLock = null } @@ -105,6 +151,100 @@ class CallAudioManager( releaseProximityWakeLock() } + private fun routeToEarpiece() { + stopBluetoothSco() + audioManager.isSpeakerphoneOn = false + } + + private fun routeToSpeaker() { + stopBluetoothSco() + audioManager.isSpeakerphoneOn = true + } + + private fun routeToBluetooth() { + audioManager.isSpeakerphoneOn = false + startBluetoothSco() + } + + private fun hasBluetoothDevice(): Boolean = + try { + audioManager + .getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .any { + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO || + it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + it.type == AudioDeviceInfo.TYPE_BLE_HEADSET + } + } catch (_: Exception) { + false + } + + @Suppress("DEPRECATION") + private fun startBluetoothSco() { + try { + if (!audioManager.isBluetoothScoOn) { + audioManager.startBluetoothSco() + audioManager.isBluetoothScoOn = true + } + } catch (_: Exception) { + } + } + + @Suppress("DEPRECATION") + private fun stopBluetoothSco() { + try { + if (audioManager.isBluetoothScoOn) { + audioManager.isBluetoothScoOn = false + audioManager.stopBluetoothSco() + } + } catch (_: Exception) { + } + } + + private fun registerBluetoothScoReceiver() { + if (scoReceiver != null) return + scoReceiver = + object : BroadcastReceiver() { + override fun onReceive( + ctx: Context?, + intent: Intent?, + ) { + val state = intent?.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1) + when (state) { + AudioManager.SCO_AUDIO_STATE_CONNECTED -> { + _isBluetoothAvailable.value = true + if (_audioRoute.value == AudioRoute.BLUETOOTH) { + audioManager.isBluetoothScoOn = true + } + } + + AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> { + _isBluetoothAvailable.value = hasBluetoothDevice() + if (_audioRoute.value == AudioRoute.BLUETOOTH) { + _audioRoute.value = AudioRoute.EARPIECE + routeToEarpiece() + } + } + } + } + } + @Suppress("DEPRECATION") + context.registerReceiver( + scoReceiver, + IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED), + ) + } + + private fun unregisterBluetoothScoReceiver() { + scoReceiver?.let { + try { + context.unregisterReceiver(it) + } catch (_: Exception) { + } + } + scoReceiver = null + } + private fun startRingtone() { try { val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) @@ -120,7 +260,6 @@ class CallAudioManager( play() } } catch (_: Exception) { - // Ringtone may not be available } } @@ -139,11 +278,9 @@ class CallAudioManager( @Suppress("DEPRECATION") context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } - - val pattern = longArrayOf(0, 1000, 1000) // vibrate 1s, pause 1s, repeat + val pattern = longArrayOf(0, 1000, 1000) vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0)) } catch (_: Exception) { - // Vibrator may not be available } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 12b5f4236..7ba71f51f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent -import android.media.AudioManager import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils @@ -75,8 +74,8 @@ class CallController( val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() private val _isVideoEnabled = MutableStateFlow(true) val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() - private val _isSpeakerOn = MutableStateFlow(false) - val isSpeakerOn: StateFlow = _isSpeakerOn.asStateFlow() + val audioRoute: StateFlow = audioManager.audioRoute + val isBluetoothAvailable: StateFlow = audioManager.isBluetoothAvailable init { scope.launch { @@ -232,11 +231,8 @@ class CallController( webRtcSession?.setVideoEnabled(enabled) } - fun toggleSpeaker() { - val on = !_isSpeakerOn.value - _isSpeakerOn.value = on - val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - am.isSpeakerphoneOn = on + fun cycleAudioRoute() { + audioManager.cycleAudioRoute() } fun getEglBase() = webRtcSession?.eglBase @@ -258,7 +254,6 @@ class CallController( _localVideoTrack.value = null _isAudioMuted.value = false _isVideoEnabled.value = true - _isSpeakerOn.value = false webRtcSession?.dispose() webRtcSession = null remoteDescriptionSet = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index db5642629..b6385b91a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BluetoothAudio import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.Mic @@ -69,6 +70,7 @@ import androidx.compose.ui.viewinterop.AndroidView import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.service.call.AudioRoute import com.vitorpamplona.amethyst.service.call.CallController import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay @@ -148,7 +150,7 @@ fun CallScreen( onHangup = { scope.launch { callManager.hangup() } }, onToggleMute = { callController?.toggleAudioMute() }, onToggleVideo = { callController?.toggleVideo() }, - onToggleSpeaker = { callController?.toggleSpeaker() }, + onCycleAudioRoute = { callController?.cycleAudioRoute() }, ) } @@ -327,7 +329,7 @@ private fun ConnectedCallUI( onHangup: () -> Unit, onToggleMute: () -> Unit, onToggleVideo: () -> Unit, - onToggleSpeaker: () -> Unit, + onCycleAudioRoute: () -> Unit, ) { var elapsed by remember { mutableLongStateOf(0L) } @@ -343,9 +345,10 @@ private fun ConnectedCallUI( val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } + val defaultRoute = remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) } val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState() val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() - val isSpeakerOn by (callController?.isSpeakerOn ?: defaultFalse).collectAsState() + val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState() Box( modifier = @@ -455,13 +458,30 @@ private fun ConnectedCallUI( ) } IconButton( - onClick = onToggleSpeaker, + onClick = onCycleAudioRoute, modifier = Modifier.size(56.dp), ) { Icon( - imageVector = if (isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff, - contentDescription = stringRes(if (isSpeakerOn) R.string.call_earpiece else R.string.call_speaker), - tint = if (isSpeakerOn) Color.Cyan else Color.White, + imageVector = + when (currentAudioRoute) { + AudioRoute.EARPIECE -> Icons.Default.VolumeOff + AudioRoute.SPEAKER -> Icons.Default.VolumeUp + AudioRoute.BLUETOOTH -> Icons.Default.BluetoothAudio + }, + contentDescription = + stringRes( + when (currentAudioRoute) { + AudioRoute.EARPIECE -> R.string.call_earpiece + AudioRoute.SPEAKER -> R.string.call_speaker + AudioRoute.BLUETOOTH -> R.string.call_bluetooth + }, + ), + tint = + when (currentAudioRoute) { + AudioRoute.EARPIECE -> Color.White + AudioRoute.SPEAKER -> Color.Cyan + AudioRoute.BLUETOOTH -> Color(0xFF2196F3) + }, modifier = Modifier.size(28.dp), ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0e84ddb15..13ae35a7f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -793,6 +793,7 @@ Camera off Speaker Earpiece + Bluetooth Voice call Video call Failed to start call From 3f82bb5ff6a9fb2ebcef3e72b877c6eb47ce5cb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 03:25:11 +0000 Subject: [PATCH 54/63] fix: address critical audit findings #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 --- .../amethyst/service/call/CallController.kt | 34 ++++++++++++------- .../service/call/WebRtcCallSession.kt | 2 ++ .../EventNotificationConsumer.kt | 2 +- .../events/CallIceCandidateEvent.kt | 6 +++- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 7ba71f51f..4f71d5dc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -55,7 +55,9 @@ class CallController( ) { private var webRtcSession: WebRtcCallSession? = null private val callFactory = WebRtcCallFactory() - private var remoteDescriptionSet = false + private val remoteDescriptionSet = + java.util.concurrent.atomic + .AtomicBoolean(false) private val pendingIceCandidates = CopyOnWriteArrayList() val audioManager = CallAudioManager(context) @@ -117,7 +119,7 @@ class CallController( scope.launch { val callId = UUID.randomUUID().toString() _errorMessage.value = null - remoteDescriptionSet = false + remoteDescriptionSet.set(false) pendingIceCandidates.clear() try { @@ -154,7 +156,7 @@ class CallController( scope.launch { _errorMessage.value = null - remoteDescriptionSet = false + remoteDescriptionSet.set(false) // Don't clear pendingIceCandidates here — they were buffered while ringing try { @@ -197,11 +199,11 @@ class CallController( fun onIceCandidateReceived(event: CallIceCandidateEvent) { try { val candidate = IceCandidate(event.sdpMid(), event.sdpMLineIndex(), event.candidateSdp()) - if (webRtcSession != null && remoteDescriptionSet) { + if (webRtcSession != null && remoteDescriptionSet.get()) { Log.d(TAG) { "Adding ICE candidate directly: ${candidate.sdp.take(50)}" } webRtcSession?.addIceCandidate(candidate) } else { - Log.d(TAG) { "Buffering ICE candidate (session=${webRtcSession != null}, remoteSet=$remoteDescriptionSet)" } + Log.d(TAG) { "Buffering ICE candidate (session=${webRtcSession != null}, remoteSet=${remoteDescriptionSet.get()})" } pendingIceCandidates.add(candidate) } } catch (e: Exception) { @@ -210,7 +212,7 @@ class CallController( } private fun flushPendingIceCandidates() { - remoteDescriptionSet = true + remoteDescriptionSet.set(true) val session = webRtcSession ?: return val candidates = pendingIceCandidates.toList() Log.d(TAG) { "Flushing ${candidates.size} buffered ICE candidates" } @@ -238,8 +240,10 @@ class CallController( fun getEglBase() = webRtcSession?.eglBase fun hangup() { - scope.launch { callManager.hangup() } - cleanup() + scope.launch { + callManager.hangup() + // cleanup is triggered by the state collector when Ended is reached + } } fun clearError() { @@ -256,12 +260,12 @@ class CallController( _isVideoEnabled.value = true webRtcSession?.dispose() webRtcSession = null - remoteDescriptionSet = false + remoteDescriptionSet.set(false) pendingIceCandidates.clear() } private fun createWebRtcSession() { - webRtcSession = + val session = WebRtcCallSession( context = context, iceServers = IceServerConfig.buildIceServers(), @@ -276,8 +280,14 @@ class CallController( onDisconnected = { scope.launch { callManager.hangup() } }, onError = { error -> _errorMessage.value = error }, ) - webRtcSession?.initialize() - webRtcSession?.createPeerConnection() + try { + session.initialize() + session.createPeerConnection() + webRtcSession = session + } catch (e: Exception) { + session.dispose() + throw e + } } private fun onLocalIceCandidate(candidate: IceCandidate) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index 7fdc043fe..5c01e144c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -203,6 +203,7 @@ class WebRtcCallSession( override fun onCreateFailure(error: String?) { Log.e(TAG, "Create offer failed: $error") + error?.let { onError("Create offer failed: $it") } } override fun onSetSuccess() {} @@ -231,6 +232,7 @@ class WebRtcCallSession( override fun onCreateFailure(error: String?) { Log.e(TAG, "Create answer failed: $error") + error?.let { onError("Create answer failed: $it") } } override fun onSetSuccess() {} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 7ae2310ae..d47e1058c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -631,7 +631,7 @@ class EventNotificationConsumer( ) { if (!account.isFollowing(event.pubKey)) return - if (TimeUtils.now() - event.createdAt > 30) return + if (TimeUtils.now() - event.createdAt > CallOfferEvent.EXPIRATION_SECONDS) return val callerUser = LocalCache.getUserIfExists(event.pubKey) val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..." diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt index 4c3734e6c..1ef209b36 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt @@ -69,7 +69,11 @@ class CallIceCandidateEvent( sdp: String, sdpMid: String, sdpMLineIndex: Int, - ): String = """{"candidate":"$sdp","sdpMid":"$sdpMid","sdpMLineIndex":$sdpMLineIndex}""" + ): String { + val escapedSdp = sdp.replace("\\", "\\\\").replace("\"", "\\\"") + val escapedMid = sdpMid.replace("\\", "\\\\").replace("\"", "\\\"") + return """{"candidate":"$escapedSdp","sdpMid":"$escapedMid","sdpMLineIndex":$sdpMLineIndex}""" + } fun build( candidateJson: String, From b891bc7bb989875b1670cfe407a6c247d7f6feb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 12:45:48 +0000 Subject: [PATCH 55/63] feat: add 20s expiration to call signaling gift wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quartz/nipACWebRtcCalls/WebRtcCallFactory.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index 74e278937..34e02aa5e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -38,6 +38,10 @@ class WebRtcCallFactory { val wrap: GiftWrapEvent, ) + companion object { + const val WRAP_EXPIRATION_SECONDS = 20L + } + suspend fun createCallOffer( sdpOffer: String, calleePubKey: HexKey, @@ -47,7 +51,7 @@ class WebRtcCallFactory { ): Result { val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType) val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) return Result(signed, wrap) } @@ -59,7 +63,7 @@ class WebRtcCallFactory { ): Result { val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId) val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) return Result(signed, wrap) } @@ -71,7 +75,7 @@ class WebRtcCallFactory { ): Result { val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId) val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) return Result(signed, wrap) } @@ -83,7 +87,7 @@ class WebRtcCallFactory { ): Result { val template = CallHangupEvent.build(peerPubKey, callId, reason) val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) return Result(signed, wrap) } @@ -95,7 +99,7 @@ class WebRtcCallFactory { ): Result { val template = CallRejectEvent.build(callerPubKey, callId, reason) val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) return Result(signed, wrap) } @@ -107,7 +111,7 @@ class WebRtcCallFactory { ): Result { val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId) val signed = signer.sign(template) - val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) return Result(signed, wrap) } } From 117cb68b9d231452ced0e11b2c07f05276ba6e97 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 12:59:59 +0000 Subject: [PATCH 56/63] docs: update NIP-AC with full implementation guidance 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 --- .../quartz/nipACWebRtcCalls/NIP-AC.md | 106 ++++++++++++++---- 1 file changed, 87 insertions(+), 19 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index 6592d41ea..92fffd552 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -8,7 +8,7 @@ WebRTC Calls This NIP defines a protocol for establishing private peer-to-peer voice and video calls between Nostr users using WebRTC, with Nostr relays serving as the signaling transport and public STUN servers for -NAT traversal — no custom server infrastructure is required +NAT traversal — no custom server infrastructure is required. ## Overview @@ -44,6 +44,8 @@ All signaling events MUST include: |---------------|-------------------------------------------------------|----------| | `p` | Hex pubkey of the recipient | YES | | `call-id` | UUID identifying the call session | YES | +| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be `created_at + 20` seconds | YES | +| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | Additional tags for **Call Offer** (kind 25050): @@ -57,16 +59,21 @@ Additional tags for **Call Offer** (kind 25050): The `content` field contains the SDP offer string. -```yaml +```json { "kind": 25050, + "pubkey": "", + "created_at": 1234567890, "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], - ["call-type", "video"] + ["call-type", "video"], + ["expiration", "1234567910"], + ["alt", "WebRTC call offer"] ], - # other fields + "id": "", + "sig": "" } ``` @@ -74,31 +81,41 @@ The `content` field contains the SDP offer string. The `content` field contains the SDP answer string. -```yaml +```json { "kind": 25051, + "pubkey": "", + "created_at": 1234567895, "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234567915"], + ["alt", "WebRTC call answer"] ], - # other fields + "id": "", + "sig": "" } ``` ### ICE Candidate (kind 25052) -The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. +The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. Special characters in the SDP string MUST be properly JSON-escaped. -```yaml +```json { "kind": 25052, + "pubkey": "", + "created_at": 1234567896, "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234567916"], + ["alt", "WebRTC ICE candidate"] ], - # other fields + "id": "", + "sig": "" } ``` @@ -106,15 +123,20 @@ The `content` field contains the ICE candidate as a JSON string with the fields The `content` field MAY contain a human-readable reason or be empty. -```yaml +```json { "kind": 25053, + "pubkey": "", + "created_at": 1234568000, "content": "", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568020"], + ["alt", "WebRTC call hangup"] ], - # other fields + "id": "", + "sig": "" } ``` @@ -122,15 +144,20 @@ The `content` field MAY contain a human-readable reason or be empty. The `content` field MAY contain a reason or be empty. -```yaml +```json { "kind": 25054, + "pubkey": "", + "created_at": 1234567893, "content": "", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234567913"], + ["alt", "WebRTC call rejection"] ], - # other fields + "id": "", + "sig": "" } ``` @@ -138,15 +165,20 @@ The `content` field MAY contain a reason or be empty. Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. -```yaml +```json { "kind": 25055, + "pubkey": "", + "created_at": 1234568100, "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...", "tags": [ ["p", ""], ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["expiration", "1234568120"], + ["alt", "WebRTC call renegotiation"] ], - # other fields + "id": "", + "sig": "" } ``` @@ -156,13 +188,15 @@ All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-p 1. **Sign** the signaling event with the sender's key 2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption -3. **Publish** the gift wrap to the recipient's relay list +3. **Set expiration on the gift wrap** — the outer gift wrap MUST include an `expiration` tag matching the inner event's expiration (adjusted for the randomized `created_at`). This allows relays to garbage-collect ephemeral signaling data. +4. **Publish** the gift wrap to the recipient's relay list The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides: - **NIP-44 encryption** — content is unreadable to relay operators - **Random ephemeral pubkey** — the relay cannot identify the sender - **`p` tag** — reveals only the recipient (necessary for delivery) +- **`expiration` tag** — allows relays to delete the wrap after the signaling window Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message. @@ -188,6 +222,10 @@ Caller Relay Callee | (relay no longer involved) | ``` +### ICE Candidate Buffering + +ICE candidates may arrive before the WebRTC peer connection is ready (e.g., the callee is still ringing). Clients MUST buffer incoming ICE candidates and apply them after `setRemoteDescription()` succeeds. Candidates buffered while ringing MUST NOT be cleared when accepting the call. + ### Ending a Call Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it. @@ -201,25 +239,55 @@ The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The c Clients SHOULD implement call filtering: - **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored. +- **Staleness check**: Clients MUST discard signaling events older than the expiration window (20 seconds). This prevents old cached events from triggering phantom calls on app restart or relay reconnect. +- **Deduplication**: Clients MUST track processed event IDs to prevent the same signaling event (delivered by multiple relays) from being processed twice. - **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window. -- **Expiration enforcement**: Clients MUST check the `expiration` tag and discard signaling events that have expired. ## NAT Traversal This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: - Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`) +- Ship with default TURN servers for relay fallback when direct P2P fails (~20% of cases, including devices on the same WiFi network where hairpin NAT is not supported) - Allow users to configure custom TURN servers for restrictive network environments - Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer +- Use `GATHER_CONTINUALLY` policy for ongoing ICE candidate discovery ## Implementation Notes +### Event Lifecycle + - The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. -- Events SHOULD have short expiration times (~20 seconds) since signaling data is ephemeral and has no long-term value. +- Both inner events and outer gift wraps SHOULD have short expiration times (~20 seconds) since signaling data is ephemeral and has no long-term value. This allows relays to garbage-collect call signaling events. - Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. -- Clients SHOULD use a foreground service or equivalent mechanism to keep calls active when the app is backgrounded. +- After a call ends, the call state SHOULD auto-reset to idle after a brief display period (e.g., 2 seconds) to ensure the client is ready for subsequent calls. + +### WebRTC Configuration + - The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics. - Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. +- ICE candidate JSON content MUST be properly escaped — SDP strings can contain quotes and backslashes that break naive string interpolation. + +### Audio and Media + +- Clients SHOULD switch `AudioManager` to `MODE_IN_COMMUNICATION` when a call connects and restore to `MODE_NORMAL` when the call ends. +- Clients SHOULD support audio routing between earpiece, speaker, and Bluetooth SCO headsets. If a Bluetooth headset disconnects mid-call, the client SHOULD fall back to earpiece automatically. +- Clients SHOULD play a ringback tone (e.g., `TONE_SUP_RINGTONE`) for the caller while waiting for the callee to answer. +- Clients SHOULD play the device's default ringtone and vibrate when an incoming call arrives from a followed user. + +### Platform Integration + +- Clients SHOULD use a foreground service (type `microphone`) to keep calls alive when the app is backgrounded. +- Clients SHOULD acquire a proximity wake lock during active calls to turn off the screen when held to the ear. +- Clients SHOULD keep the screen on during active calls. +- Clients MAY enter Picture-in-Picture mode when the user navigates away from the call screen during an active call. +- Clients SHOULD request `RECORD_AUDIO` permission (and `CAMERA` for video calls) at runtime before initiating or accepting a call. + +### Error Handling + +- If WebRTC session creation fails, the client SHOULD display an error to the user and transition to an ended state. +- If SDP offer/answer creation fails, the client SHOULD surface the error instead of hanging silently. +- Clients SHOULD handle `ICE_CONNECTION_FAILED` state by ending the call and notifying the user of a connection failure. ## References From ccbaeacbd93f15b655e4d2abde250d2bf8c863df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 13:02:15 +0000 Subject: [PATCH 57/63] fix: prevent call screen from closing before state transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../com/vitorpamplona/amethyst/ui/call/CallScreen.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index b6385b91a..ec962edb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -107,7 +107,14 @@ fun CallScreen( Box(modifier = Modifier.fillMaxSize()) { when (val state = callState) { is CallState.Idle -> { - LaunchedEffect(Unit) { onCallEnded() } + // Wait briefly — initiateCall runs async and state may not have + // transitioned yet when navigating to this screen + LaunchedEffect(Unit) { + delay(500) + if (callManager.state.value is CallState.Idle) { + onCallEnded() + } + } } is CallState.Offering -> { From 8ee71e94e8acd0e4b5e472e60200d06349ac682a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 13:18:57 +0000 Subject: [PATCH 58/63] fix: use onAddTrack instead of onAddStream for remote video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/service/call/CallController.kt | 5 +---- .../amethyst/service/call/WebRtcCallSession.kt | 13 ++++++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 4f71d5dc5..8ff3a6535 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -38,7 +38,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.webrtc.IceCandidate -import org.webrtc.MediaStream import org.webrtc.SessionDescription import org.webrtc.VideoTrack import java.util.UUID @@ -274,9 +273,7 @@ class CallController( callManager.onPeerConnected() startForegroundService() }, - onRemoteStream = { stream: MediaStream -> - stream.videoTracks?.firstOrNull()?.let { _remoteVideoTrack.value = it } - }, + onRemoteVideoTrack = { track -> _remoteVideoTrack.value = track }, onDisconnected = { scope.launch { callManager.hangup() } }, onError = { error -> _errorMessage.value = error }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index 5c01e144c..40de89585 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -49,7 +49,7 @@ class WebRtcCallSession( private val iceServers: List, private val onIceCandidate: (IceCandidate) -> Unit, private val onPeerConnected: () -> Unit, - private val onRemoteStream: (MediaStream) -> Unit, + private val onRemoteVideoTrack: (VideoTrack) -> Unit, private val onDisconnected: () -> Unit, private val onError: (String) -> Unit = {}, ) { @@ -123,7 +123,8 @@ class WebRtcCallSession( override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {} override fun onAddStream(stream: MediaStream?) { - stream?.let { onRemoteStream(it) } + // Fallback for Plan B SDP — Unified Plan uses onAddTrack + stream?.videoTracks?.firstOrNull()?.let { onRemoteVideoTrack(it) } } override fun onRemoveStream(stream: MediaStream?) {} @@ -135,7 +136,13 @@ class WebRtcCallSession( override fun onAddTrack( receiver: RtpReceiver?, streams: Array?, - ) {} + ) { + // Unified Plan: extract video track from receiver + val track = receiver?.track() + if (track is VideoTrack) { + onRemoteVideoTrack(track) + } + } }, ) } From 3fbbfd02240906286834441dd2ff917168212ff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 13:35:08 +0000 Subject: [PATCH 59/63] fix: request CAMERA permission for video calls 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 --- .../amethyst/ui/call/CallPermissions.kt | 41 ++++++++++++++----- .../amethyst/ui/call/CallScreen.kt | 3 +- .../chats/privateDM/ChatroomScreen.kt | 2 +- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt index 5d37e975b..841caf946 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -29,26 +29,47 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.core.content.ContextCompat -fun hasAudioPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED +fun hasPermission( + context: Context, + permission: String, +) = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + +fun hasCallPermissions( + context: Context, + isVideo: Boolean, +): Boolean { + if (!hasPermission(context, Manifest.permission.RECORD_AUDIO)) return false + if (isVideo && !hasPermission(context, Manifest.permission.CAMERA)) return false + return true +} @Composable fun rememberCallWithPermission( - context: android.content.Context, + context: Context, + isVideo: Boolean = false, onCall: () -> Unit, ): () -> Unit { - val launcher = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - if (granted) onCall() + val permissions = + if (isVideo) { + arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) + } else { + arrayOf(Manifest.permission.RECORD_AUDIO) } - return remember(onCall) { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { results -> + val allGranted = results.values.all { it } + if (allGranted) onCall() + } + + return remember(onCall, isVideo) { { - if (hasAudioPermission(context)) { + if (hasCallPermissions(context, isVideo)) { onCall() } else { - launcher.launch(Manifest.permission.RECORD_AUDIO) + launcher.launch(permissions) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index ec962edb2..1bead4544 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -127,8 +127,9 @@ fun CallScreen( } is CallState.IncomingCall -> { + val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO val acceptWithPermission = - rememberCallWithPermission(context) { + rememberCallWithPermission(context, isVideo = isVideoCall) { callController?.acceptIncomingCall(state.sdpOffer) } IncomingCallUI( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 5922cd306..221519c7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -56,7 +56,7 @@ fun ChatroomScreen( nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey)) } val startVideoCall = - rememberCallWithPermission(context) { + rememberCallWithPermission(context, isVideo = true) { val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) val callId = accountViewModel.callManager.currentCallId() ?: "" From 6e695f4801859fa3c33da7c9d0d1fc7d444d2d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 14:05:10 +0000 Subject: [PATCH 60/63] fix: pass Activity context to CallController for camera orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1d45da3d1..70818f512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -213,7 +213,7 @@ class AccountViewModel( val controller = CallController( - context = context.applicationContext, + context = context, callManager = callManager, scope = viewModelScope, publishWrap = { wrap -> account.publishCallSignaling(wrap) }, From 1f5b39a9e41ed5820e1cf64bf9c0d0e09a668b98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 14:24:13 +0000 Subject: [PATCH 61/63] fix: enable video toggle during voice calls 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 --- .../amethyst/service/call/CallController.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 8ff3a6535..1592fea0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -227,9 +227,20 @@ class CallController( } fun toggleVideo() { + val session = webRtcSession ?: return val enabled = !_isVideoEnabled.value _isVideoEnabled.value = enabled - webRtcSession?.setVideoEnabled(enabled) + + if (enabled && session.getLocalVideoTrack() == null) { + // Video track doesn't exist yet (voice call upgraded to video) + session.addVideoTrack() + _localVideoTrack.value = session.getLocalVideoTrack() + } else { + session.setVideoEnabled(enabled) + if (!enabled) { + session.stopCamera() + } + } } fun cycleAudioRoute() { From 31a3c065443c15f181ab1d57edd470e3256f4335 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 14:41:50 +0000 Subject: [PATCH 62/63] fix: complete overhaul of video toggle state and camera lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/service/call/CallController.kt | 32 ++++++++++++------- .../service/call/WebRtcCallSession.kt | 3 +- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 1592fea0b..0b761a6d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -73,7 +73,7 @@ class CallController( // Audio/video toggle state (UI concerns, not domain state) private val _isAudioMuted = MutableStateFlow(false) val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() - private val _isVideoEnabled = MutableStateFlow(true) + private val _isVideoEnabled = MutableStateFlow(false) val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() val audioRoute: StateFlow = audioManager.audioRoute val isBluetoothAvailable: StateFlow = audioManager.isBluetoothAvailable @@ -139,6 +139,7 @@ class CallController( if (callType == CallType.VIDEO) { session.addVideoTrack() _localVideoTrack.value = session.getLocalVideoTrack() + _isVideoEnabled.value = true } session.createOffer { sdp -> @@ -176,6 +177,7 @@ class CallController( if (state.callType == CallType.VIDEO) { session.addVideoTrack() _localVideoTrack.value = session.getLocalVideoTrack() + _isVideoEnabled.value = true } session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) @@ -228,18 +230,24 @@ class CallController( fun toggleVideo() { val session = webRtcSession ?: return - val enabled = !_isVideoEnabled.value - _isVideoEnabled.value = enabled + val enabling = !_isVideoEnabled.value - if (enabled && session.getLocalVideoTrack() == null) { - // Video track doesn't exist yet (voice call upgraded to video) - session.addVideoTrack() - _localVideoTrack.value = session.getLocalVideoTrack() - } else { - session.setVideoEnabled(enabled) - if (!enabled) { - session.stopCamera() + if (enabling) { + if (session.getLocalVideoTrack() == null) { + // No video track yet — create one (voice → video upgrade) + session.addVideoTrack() + } else { + // Track exists but camera was stopped — restart it + session.setVideoEnabled(true) + session.startCamera() } + _localVideoTrack.value = session.getLocalVideoTrack() + _isVideoEnabled.value = true + } else { + // Disable video: stop camera and disable track + session.setVideoEnabled(false) + session.stopCamera() + _isVideoEnabled.value = false } } @@ -267,7 +275,7 @@ class CallController( _remoteVideoTrack.value = null _localVideoTrack.value = null _isAudioMuted.value = false - _isVideoEnabled.value = true + _isVideoEnabled.value = false webRtcSession?.dispose() webRtcSession = null remoteDescriptionSet.set(false) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt index 40de89585..9981ec3c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt @@ -165,7 +165,8 @@ class WebRtcCallSession( startCamera() } - private fun startCamera() { + fun startCamera() { + if (cameraCapturer != null) return // Already running val source = videoSource ?: return val enumerator = Camera2Enumerator(context) val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) } From a1b2c785aa0240b36a20b36c4e33b1b700effe17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 14:55:36 +0000 Subject: [PATCH 63/63] fix: hide local video PiP when camera is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../amethyst/ui/call/CallScreen.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 1bead4544..1f47e69bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -374,18 +374,20 @@ private fun ConnectedCallUI( ) } - // Local video (small pip in corner) - localVideoTrack?.let { track -> - VideoRenderer( - videoTrack = track, - eglBase = callController?.getEglBase(), - modifier = - Modifier - .size(120.dp, 160.dp) - .align(Alignment.TopEnd) - .padding(16.dp), - mirror = true, - ) + // Local video (small pip in corner) — only when camera is active + if (isVideoEnabled) { + localVideoTrack?.let { track -> + VideoRenderer( + videoTrack = track, + eglBase = callController?.getEglBase(), + modifier = + Modifier + .size(120.dp, 160.dp) + .align(Alignment.TopEnd) + .padding(16.dp), + mirror = true, + ) + } } // If no video, show avatar