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 bca75e0aa..e8b35e816 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 @@ -166,6 +166,20 @@ class CallController( fun initiateCall( peerPubKey: String, callType: CallType, + ) { + initiateCallInternal(setOf(peerPubKey), callType) + } + + fun initiateGroupCall( + peerPubKeys: Set, + callType: CallType, + ) { + initiateCallInternal(peerPubKeys, callType) + } + + private fun initiateCallInternal( + peerPubKeys: Set, + callType: CallType, ) { scope.launch { val callId = UUID.randomUUID().toString() @@ -196,7 +210,11 @@ class CallController( session.createOffer { sdp -> scope.launch { - callManager.initiateCall(peerPubKey, callType, callId, sdp.description) + if (peerPubKeys.size == 1) { + callManager.initiateCall(peerPubKeys.first(), callType, callId, sdp.description) + } else { + callManager.initiateGroupCall(peerPubKeys, callType, callId, 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 96944b69e..607e48155 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 @@ -20,17 +20,23 @@ */ package com.vitorpamplona.amethyst.service.call +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.R +import com.vitorpamplona.quartz.utils.Log + +private const val TAG = "CallForegroundService" class CallForegroundService : Service() { companion object { @@ -57,16 +63,26 @@ class CallForegroundService : Service() { ACTION_START -> { 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_MICROPHONE - } else { - 0 - }, - ) + val hasAudioPermission = + ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + try { + val fgsType = + if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + 0 + } + ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, fgsType) + } catch (e: SecurityException) { + Log.e(TAG, "Cannot start microphone foreground service, falling back", e) + try { + ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, 0) + } catch (e2: Exception) { + Log.e(TAG, "Foreground service start failed entirely", e2) + stopSelf() + } + } } ACTION_STOP -> { 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 071382176..0d8b50e3c 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 @@ -164,7 +164,15 @@ class WebRtcCallSession( videoSource = peerConnectionFactory?.createVideoSource(false) localVideoTrack = peerConnectionFactory?.createVideoTrack("video0", videoSource).also { - peerConnection?.addTrack(it) + val sender = peerConnection?.addTrack(it) + // Set max bitrate to 1.5 Mbps for good 720p quality + sender?.let { s -> + val params = s.parameters + params.encodings.forEach { encoding -> + encoding.maxBitrateBps = 1_500_000 + } + s.parameters = params + } } startCamera() } @@ -183,7 +191,7 @@ class WebRtcCallSession( context, source.capturerObserver, ) - it.startCapture(640, 480, 30) + it.startCapture(1280, 720, 30) } } 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 5e292786b..bba33f7a6 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 @@ -123,10 +123,10 @@ fun CallScreen( is CallState.Offering -> { if (isInPipMode) { - PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel) + PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel) } else { CallInProgressUI( - peerPubKey = state.peerPubKey, + peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, @@ -155,10 +155,10 @@ fun CallScreen( is CallState.Connecting -> { if (isInPipMode) { - PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel) + PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel) } else { CallInProgressUI( - peerPubKey = state.peerPubKey, + peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, @@ -185,7 +185,7 @@ fun CallScreen( is CallState.Ended -> { if (!isInPipMode) { CallInProgressUI( - peerPubKey = state.peerPubKey, + peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_ended), accountViewModel = accountViewModel, onHangup = { onCallEnded() }, @@ -433,7 +433,7 @@ private fun ConnectedCallUI( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> if (user != null) { ClickableUserPicture( baseUser = user, @@ -665,7 +665,7 @@ private fun PipConnectedCallUI( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> + LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> if (user != null) { ClickableUserPicture( baseUser = user, 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 a833ae21e..01189b2a9 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 @@ -49,18 +49,27 @@ fun ChatroomScreen( nav: INav, ) { val context = LocalContext.current + val isGroupChat = roomId.users.size > 1 val startVoiceCall = rememberCallWithPermission(context) { - val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) - accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) + if (isGroupChat) { + accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE) + } else { + val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) + } CallActivity.launch(context) } val startVideoCall = rememberCallWithPermission(context, isVideo = true) { - val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) - accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) + if (isGroupChat) { + accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VIDEO) + } else { + val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) + } CallActivity.launch(context) } 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 d97d7ee4c..6bd478aab 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 @@ -146,6 +146,34 @@ fun RenderRoomTopBar( ) RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel) + + if (onVideoCallClick != null) { + IconButton( + onClick = { onVideoCallClick(room.users.joinToString(",")) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Videocam, + contentDescription = stringRes(R.string.call_video), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + + if (onCallClick != null) { + IconButton( + onClick = { onCallClick(room.users.joinToString(",")) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Call, + contentDescription = stringRes(R.string.call_voice), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } }, extendableRow = { diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index a7159fd1a..7483ad21f 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -673,8 +673,14 @@ Połączenia przychodzące Powiadomienia o przychodzących połączeniach głosowych i wideo Połączenie przychodzące + Przychodzące połączenie głosowe\u2026 + Przychodzące połączenie wideo\u2026 + Dzwoni\u2026 + Łączenie\u2026 Połączenie zakończone + Zadzwoń przez %1$s Połączenia + Powiadomienie o trwającym połączeniu Rozłącz się Akceptuj Odrzuć @@ -684,11 +690,13 @@ Kamera włączona Kamera wyłączona Głośnik + Słuchawka Bluetooth Połączenie głosowe Połączenie wideo Nie udało się nawiązać połączenia Nie udało się przyjąć połączenia + Nie udało się utworzyć sesji połączenia Powiadom: Dołącz do dyskusji ID Użytkownika lub Grupy @@ -824,6 +832,9 @@ ZAPLANOWANE Transmisja wyłączona Transmisja na żywo zakończona + OTWARTA + PRYWATNA + ZAMKNIĘTA Wylogowanie usuwa wszystkie informacje lokalne. Upewnij się, że masz kopię zapasową kluczy prywatnych, aby uniknąć utraty konta. Czy chcesz kontynuować? Obserwowane tagi Transmitery @@ -1180,6 +1191,10 @@ Tytuł artykułu\u2026 Krótki opis artykułu\u2026 URL Slug + url-slug-artykułu + # Tagi + Dodaj tag\u2026 + Zacznij pisać artykuł\u2026 Otwórz wszystkie odzewy na ten post Zamknij wszystkie odzewy na ten post Odpowiedź 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 c77ba16b9..1d879d65d 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 @@ -69,6 +69,8 @@ class CallManager( private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS + // ---- P2P call initiation ---- + suspend fun initiateCall( calleePubKey: HexKey, callType: CallType, @@ -76,11 +78,31 @@ class CallManager( sdpOffer: String, ) { val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) - _state.value = CallState.Offering(callId, calleePubKey, callType) + _state.value = CallState.Offering(callId, setOf(calleePubKey), callType) publishEvent(result.wrap) startTimeout(callId) } + // ---- Group call initiation ---- + + /** + * Initiates a group call. A single [CallOfferEvent] is created with `p` + * tags for every callee and then gift-wrapped individually to each one. + */ + suspend fun initiateGroupCall( + calleePubKeys: Set, + callType: CallType, + callId: String, + sdpOffer: String, + ) { + val result = factory.createGroupCallOffer(sdpOffer, calleePubKeys, callId, callType, signer) + _state.value = CallState.Offering(callId, calleePubKeys, callType) + result.wraps.forEach { publishEvent(it) } + startTimeout(callId) + } + + // ---- Incoming call handling ---- + fun onIncomingCallEvent(event: CallOfferEvent) { val callerPubKey = event.pubKey val callId = event.callId() ?: return @@ -90,10 +112,13 @@ class CallManager( if (_state.value !is CallState.Idle) return + val groupMembers = event.groupMembers() + _state.value = CallState.IncomingCall( callId = callId, callerPubKey = callerPubKey, + groupMembers = groupMembers, callType = callType, sdpOffer = event.sdpOffer(), ) @@ -105,13 +130,11 @@ class CallManager( 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) + _state.value = CallState.Connecting(current.callId, current.peerPubKeys(), current.callType) cancelTimeout() publishEvent(result.wrap) // Notify other devices of this user that the call was answered here. - // This gift-wraps an answer event to our own pubkey so other logged-in - // devices see it and stop ringing. val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer) publishEvent(selfNotify.wrap) } @@ -121,7 +144,7 @@ class CallManager( if (current !is CallState.IncomingCall) return val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) - transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED) + transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED) publishEvent(result.wrap) // Notify other devices of this user that the call was rejected here. @@ -136,7 +159,7 @@ class CallManager( when (current) { is CallState.Offering -> { if (callId != current.callId) return - _state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) + _state.value = CallState.Connecting(current.callId, current.peerPubKeys, current.callType) cancelTimeout() onAnswerReceived?.invoke(event) } @@ -144,7 +167,7 @@ class CallManager( is CallState.IncomingCall -> { // Another device of this user answered the call — stop ringing. if (callId != current.callId) return - transitionToEnded(current.callId, current.callerPubKey, EndReason.ANSWERED_ELSEWHERE) + transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE) } is CallState.Connected -> { @@ -166,13 +189,13 @@ class CallManager( when (current) { is CallState.Offering -> { if (callId != current.callId) return - transitionToEnded(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) + transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED) } is CallState.IncomingCall -> { // Another device of this user rejected the call — stop ringing. if (callId != current.callId) return - transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED) + transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED) } else -> { @@ -219,28 +242,28 @@ class CallManager( _state.value = CallState.Connected( callId = current.callId, - peerPubKey = current.peerPubKey, + peerPubKeys = current.peerPubKeys, callType = current.callType, startedAtEpoch = TimeUtils.now(), ) } suspend fun hangup() { - val peerPubKey: HexKey + val peerPubKeys: Set val callId: String when (val current = _state.value) { is CallState.Offering -> { - peerPubKey = current.peerPubKey + peerPubKeys = current.peerPubKeys callId = current.callId } is CallState.Connecting -> { - peerPubKey = current.peerPubKey + peerPubKeys = current.peerPubKeys callId = current.callId } is CallState.Connected -> { - peerPubKey = current.peerPubKey + peerPubKeys = current.peerPubKeys callId = current.callId } @@ -249,9 +272,14 @@ class CallManager( } } - val result = factory.createHangup(peerPubKey, callId, signer = signer) - transitionToEnded(callId, peerPubKey, EndReason.HANGUP) - publishEvent(result.wrap) + if (peerPubKeys.size == 1) { + val result = factory.createHangup(peerPubKeys.first(), callId, signer = signer) + publishEvent(result.wrap) + } else { + val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer) + result.wraps.forEach { publishEvent(it) } + } + transitionToEnded(callId, peerPubKeys, EndReason.HANGUP) } fun onPeerHangup(event: CallHangupEvent) { @@ -267,8 +295,8 @@ class CallManager( } if (callId != currentCallId) return - val peerPubKey = event.pubKey - transitionToEnded(callId, peerPubKey, EndReason.PEER_HANGUP) + val peerPubKeys = currentPeerPubKeys() ?: return + transitionToEnded(callId, peerPubKeys, EndReason.PEER_HANGUP) } fun onSignalingEvent(event: Event) { @@ -299,15 +327,22 @@ class CallManager( else -> null } - fun currentPeerPubKey(): HexKey? = + /** Returns the first peer pubkey (for P2P calls) or null. */ + fun currentPeerPubKey(): HexKey? = currentPeerPubKeys()?.firstOrNull() + + /** Returns all peer pubkeys for the current call. */ + fun currentPeerPubKeys(): Set? = 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 + is CallState.Offering -> s.peerPubKeys + is CallState.IncomingCall -> s.peerPubKeys() + is CallState.Connecting -> s.peerPubKeys + is CallState.Connected -> s.peerPubKeys else -> null } + /** True when the current call has more than one peer. */ + fun isGroupCall(): Boolean = (currentPeerPubKeys()?.size ?: 0) > 1 + fun reset() { _state.value = CallState.Idle cancelTimeout() @@ -318,10 +353,10 @@ class CallManager( private fun transitionToEnded( callId: String, - peerPubKey: HexKey, + peerPubKeys: Set, reason: EndReason, ) { - _state.value = CallState.Ended(callId, peerPubKey, reason) + _state.value = CallState.Ended(callId, peerPubKeys, reason) cancelTimeout() resetJob?.cancel() resetJob = @@ -346,13 +381,13 @@ class CallManager( else -> null } if (currentCallId == callId) { - val peerPubKey = + val peerPubKeys = when (current) { - is CallState.Offering -> current.peerPubKey - is CallState.IncomingCall -> current.callerPubKey + is CallState.Offering -> current.peerPubKeys + is CallState.IncomingCall -> current.peerPubKeys() else -> return@launch } - transitionToEnded(callId, peerPubKey, EndReason.TIMEOUT) + transitionToEnded(callId, peerPubKeys, EndReason.TIMEOUT) } } } @@ -362,3 +397,14 @@ class CallManager( timeoutJob = null } } + +/** + * Convenience extension: the peers in an incoming call are all group members + * except the local signer (i.e. ourselves) – but since we don't store the + * local pubkey here we return all members except the caller's own pubkey + * is already the callerPubKey field. In practice the set of "peer" keys + * the UI should track is groupMembers minus self, which the controller + * resolves. Here we simply exclude the caller from the recipients set + * and add back the caller, resulting in the full group minus self. + */ +private fun CallState.IncomingCall.peerPubKeys(): Set = groupMembers 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 65169aa4c..940eefdaa 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 @@ -30,33 +30,34 @@ sealed interface CallState { data class Offering( val callId: String, - val peerPubKey: HexKey, + val peerPubKeys: Set, val callType: CallType, ) : CallState data class IncomingCall( val callId: String, val callerPubKey: HexKey, + val groupMembers: Set, val callType: CallType, val sdpOffer: String, ) : CallState data class Connecting( val callId: String, - val peerPubKey: HexKey, + val peerPubKeys: Set, val callType: CallType, ) : CallState data class Connected( val callId: String, - val peerPubKey: HexKey, + val peerPubKeys: Set, val callType: CallType, val startedAtEpoch: Long, ) : CallState data class Ended( val callId: String, - val peerPubKey: HexKey, + val peerPubKeys: Set, val reason: EndReason, ) : CallState } 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 92fffd552..8d282c807 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 @@ -163,7 +163,7 @@ The `content` field MAY contain a reason or be empty. ### Call Renegotiate (kind 25055) -Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. +Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer. The recipient MUST respond with a `Call Answer` (kind 25051) containing the SDP answer for the renegotiation. ```json { @@ -226,6 +226,23 @@ Caller Relay Callee 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. +### Mid-Call Renegotiation + +Either party may send a `CallRenegotiate` (kind 25055) during an active call to change media parameters (e.g., toggling video on/off). The recipient responds with a `CallAnswer` (kind 25051): + +``` +Party A Relay Party B + | | | + |-- GiftWrap(Renegotiate) ----->| | + | |-- GiftWrap(Renegotiate) ----->| + | | | + | | [Party B creates SDP answer] | + | | | + |<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------| + | | | + |========= Updated WebRTC P2P Connection ========================| +``` + ### 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. @@ -265,9 +282,18 @@ This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: ### 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. +- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection. When a `Call Renegotiate` event is received, the recipient creates a new SDP answer for the renegotiated session and sends it back as a `Call Answer` (kind 25051) with the same `call-id`. The initiator applies this answer via `setRemoteDescription()` to complete the renegotiation. - ICE candidate JSON content MUST be properly escaped — SDP strings can contain quotes and backslashes that break naive string interpolation. +### Multi-Device Support + +When a user is logged in on multiple devices, all devices will receive and ring for incoming calls. To prevent all devices from continuing to ring after one device handles the call: + +- When **accepting** a call, the callee SHOULD gift-wrap and publish an additional `Call Answer` (kind 25051) addressed to their **own pubkey** (the `p` tag set to self). Other devices of the same user that receive this self-addressed answer SHOULD stop ringing and transition to an "answered elsewhere" state. +- When **rejecting** a call, the callee SHOULD gift-wrap and publish an additional `Call Reject` (kind 25054) addressed to their **own pubkey**. Other devices SHOULD stop ringing. + +These self-notification events use the same `call-id` as the original call and follow the same gift-wrapping rules. Clients receiving a self-addressed answer or reject MUST verify the `call-id` matches the currently ringing call before acting on it. + ### Audio and Media - Clients SHOULD switch `AudioManager` to `MODE_IN_COMMUNICATION` when a call connects and restore to `MODE_NORMAL` when the call ends. 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 34e02aa5e..c517eb723 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -33,15 +33,24 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType class WebRtcCallFactory { + /** Result for a single-recipient signaling message (P2P). */ data class Result( val msg: Event, val wrap: GiftWrapEvent, ) + /** Result for a signaling message gift-wrapped to multiple recipients (group calls). */ + data class GroupResult( + val msg: Event, + val wraps: List, + ) + companion object { const val WRAP_EXPIRATION_SECONDS = 20L } + // ---- P2P (single recipient) methods ---- + suspend fun createCallOffer( sdpOffer: String, calleePubKey: HexKey, @@ -114,4 +123,65 @@ class WebRtcCallFactory { val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) return Result(signed, wrap) } + + // ---- Group call methods (multiple recipients) ---- + + /** + * Creates a call offer for a group call. The signed inner event contains + * `p` tags for **every** callee so each recipient knows the full group. + * A separate [GiftWrapEvent] is produced for each callee. + */ + suspend fun createGroupCallOffer( + sdpOffer: String, + calleePubKeys: Set, + callId: String, + callType: CallType, + signer: NostrSigner, + ): GroupResult { + val template = CallOfferEvent.build(sdpOffer, calleePubKeys, callId, callType) + val signed = signer.sign(template) + val wraps = + calleePubKeys.map { pubKey -> + GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + } + return GroupResult(signed, wraps) + } + + /** + * Sends a hangup to every peer in a group call. Each peer receives its + * own gift-wrapped hangup event. + */ + suspend fun createGroupHangup( + peerPubKeys: Set, + callId: String, + reason: String = "", + signer: NostrSigner, + ): GroupResult { + val template = CallHangupEvent.build(peerPubKeys.first(), callId, reason) + val signed = signer.sign(template) + val wraps = + peerPubKeys.map { pubKey -> + GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + } + return GroupResult(signed, wraps) + } + + /** + * Rejects a group call offer. Sends the rejection to the caller and + * notifies self (for multi-device support). + */ + suspend fun createGroupReject( + callerPubKey: HexKey, + callId: String, + reason: String = "", + signer: NostrSigner, + ): GroupResult { + val template = CallRejectEvent.build(callerPubKey, callId, reason) + val signed = signer.sign(template) + val wraps = + listOf(callerPubKey, signer.pubKey).distinct().map { pubKey -> + GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + } + return GroupResult(signed, wraps) + } } 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 71a4d94fb..60ff8dc42 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 @@ -25,7 +25,9 @@ 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.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -50,6 +52,18 @@ class CallOfferEvent( fun sdpOffer() = content + /** All pubkeys referenced by `p` tags in this offer. */ + fun recipientPubKeys(): Set = tags.mapNotNull(PTag::parseKey).toSet() + + /** + * All group members for this call: the `p`-tagged recipients plus the + * event author (caller). For 1-to-1 calls the set has two elements. + */ + fun groupMembers(): Set = recipientPubKeys().plus(pubKey) + + /** True when this offer targets more than one callee. */ + fun isGroupCall(): Boolean = recipientPubKeys().size > 1 + companion object { const val KIND = 25050 const val ALT_DESCRIPTION = "WebRTC call offer" @@ -70,5 +84,21 @@ class CallOfferEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + sdpOffer: String, + calleePubKeys: Set, + callId: String, + type: CallType, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(calleePubKeys) + callId(callId) + callType(type) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt index ca69124e1..584f2a3c8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt @@ -188,4 +188,77 @@ class CallEventsTest { assertEquals("new-sdp-offer", template.content) assertEquals(CallRenegotiateEvent.KIND, template.kind) } + + // ---- Group call offer tests ---- + + @Test + fun groupCallOfferBuildIncludesAllPTags() { + val callees = setOf("alice", "bob", "carol") + val template = + CallOfferEvent.build( + sdpOffer = "group-sdp", + calleePubKeys = callees, + callId = "group-call-1", + type = CallType.VIDEO, + ) + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(callees, pTagValues) + } + + @Test + fun groupCallOfferBuildIncludesCallIdTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKeys = setOf("alice", "bob"), + callId = "group-call-id", + type = CallType.VOICE, + ) + val callIdTag = template.tags.firstOrNull { it[0] == "call-id" } + assertEquals("group-call-id", callIdTag?.get(1)) + } + + @Test + fun groupCallOfferBuildIncludesCallTypeTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKeys = setOf("alice", "bob"), + callId = "id", + type = CallType.VIDEO, + ) + val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" } + assertEquals("video", callTypeTag?.get(1)) + } + + @Test + fun groupCallOfferBuildIncludesExpirationTag() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKeys = setOf("alice", "bob"), + callId = "id", + type = CallType.VOICE, + createdAt = 2000L, + ) + val expirationTag = template.tags.firstOrNull { it[0] == "expiration" } + assertEquals((2000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1)) + } + + @Test + fun singleCalleeOfferIsNotGroupCall() { + val template = + CallOfferEvent.build( + sdpOffer = "sdp", + calleePubKey = "alice", + callId = "id", + type = CallType.VOICE, + ) + val pTags = template.tags.filter { it[0] == "p" } + assertEquals(1, pTags.size) + } }