This commit is contained in:
Vitor Pamplona
2026-04-02 20:36:56 -04:00
13 changed files with 400 additions and 60 deletions
@@ -166,6 +166,20 @@ class CallController(
fun initiateCall(
peerPubKey: String,
callType: CallType,
) {
initiateCallInternal(setOf(peerPubKey), callType)
}
fun initiateGroupCall(
peerPubKeys: Set<String>,
callType: CallType,
) {
initiateCallInternal(peerPubKeys, callType)
}
private fun initiateCallInternal(
peerPubKeys: Set<String>,
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)
}
}
}
}
@@ -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 -> {
@@ -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)
}
}
@@ -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,
@@ -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)
}
@@ -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 = {
@@ -673,8 +673,14 @@
<string name="app_notification_calls_channel_name">Połączenia przychodzące</string>
<string name="app_notification_calls_channel_description">Powiadomienia o przychodzących połączeniach głosowych i wideo</string>
<string name="call_incoming">Połączenie przychodzące</string>
<string name="call_incoming_voice">Przychodzące połączenie głosowe\u2026</string>
<string name="call_incoming_video">Przychodzące połączenie wideo\u2026</string>
<string name="call_calling">Dzwoni\u2026</string>
<string name="call_connecting">Łączenie\u2026</string>
<string name="call_ended">Połączenie zakończone</string>
<string name="call_with">Zadzwoń przez %1$s</string>
<string name="call_ongoing">Połączenia</string>
<string name="call_ongoing_description">Powiadomienie o trwającym połączeniu</string>
<string name="call_hangup">Rozłącz się</string>
<string name="call_accept">Akceptuj</string>
<string name="call_reject">Odrzuć</string>
@@ -684,11 +690,13 @@
<string name="call_camera_on">Kamera włączona</string>
<string name="call_camera_off">Kamera wyłączona</string>
<string name="call_speaker">Głośnik</string>
<string name="call_earpiece">Słuchawka</string>
<string name="call_bluetooth">Bluetooth</string>
<string name="call_voice">Połączenie głosowe</string>
<string name="call_video">Połączenie wideo</string>
<string name="call_failed_start">Nie udało się nawiązać połączenia</string>
<string name="call_failed_accept">Nie udało się przyjąć połączenia</string>
<string name="call_failed_session">Nie udało się utworzyć sesji połączenia</string>
<string name="reply_notify">Powiadom: </string>
<string name="channel_list_join_conversation">Dołącz do dyskusji</string>
<string name="channel_list_user_or_group_id">ID Użytkownika lub Grupy</string>
@@ -824,6 +832,9 @@
<string name="live_stream_planned_tag">ZAPLANOWANE</string>
<string name="live_stream_is_offline">Transmisja wyłączona</string>
<string name="live_stream_has_ended">Transmisja na żywo zakończona</string>
<string name="meeting_space_open_tag">OTWARTA</string>
<string name="meeting_space_private_tag">PRYWATNA</string>
<string name="meeting_space_closed_tag">ZAMKNIĘTA</string>
<string name="are_you_sure_you_want_to_log_out">Wylogowanie usuwa wszystkie informacje lokalne. Upewnij się, że masz kopię zapasową kluczy prywatnych, aby uniknąć utraty konta. Czy chcesz kontynuować?</string>
<string name="followed_tags">Obserwowane tagi</string>
<string name="relay_setup">Transmitery</string>
@@ -1180,6 +1191,10 @@
<string name="your_article_title">Tytuł artykułu\u2026</string>
<string name="article_description_placeholder">Krótki opis artykułu\u2026</string>
<string name="url_slug">URL Slug</string>
<string name="url_slug_placeholder">url-slug-artykułu</string>
<string name="tags_label"># Tagi</string>
<string name="add_a_tag">Dodaj tag\u2026</string>
<string name="start_writing_article">Zacznij pisać artykuł\u2026</string>
<string name="open_all_reactions_to_this_post">Otwórz wszystkie odzewy na ten post</string>
<string name="close_all_reactions_to_this_post">Zamknij wszystkie odzewy na ten post</string>
<string name="reply_description">Odpowiedź</string>
@@ -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<HexKey>,
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<HexKey>
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<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
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<HexKey>,
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<HexKey> = groupMembers
@@ -30,33 +30,34 @@ sealed interface CallState {
data class Offering(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
) : CallState
data class IncomingCall(
val callId: String,
val callerPubKey: HexKey,
val groupMembers: Set<HexKey>,
val callType: CallType,
val sdpOffer: String,
) : CallState
data class Connecting(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
) : CallState
data class Connected(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
val startedAtEpoch: Long,
) : CallState
data class Ended(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val reason: EndReason,
) : CallState
}
@@ -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.
@@ -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<GiftWrapEvent>,
)
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<HexKey>,
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<HexKey>,
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)
}
}
@@ -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<HexKey> = 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<HexKey> = 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<HexKey>,
callId: String,
type: CallType,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpOffer, createdAt) {
alt(ALT_DESCRIPTION)
pTagIds(calleePubKeys)
callId(callId)
callType(type)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -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)
}
}