Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
@@ -166,6 +166,20 @@ class CallController(
|
|||||||
fun initiateCall(
|
fun initiateCall(
|
||||||
peerPubKey: String,
|
peerPubKey: String,
|
||||||
callType: CallType,
|
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 {
|
scope.launch {
|
||||||
val callId = UUID.randomUUID().toString()
|
val callId = UUID.randomUUID().toString()
|
||||||
@@ -196,7 +210,11 @@ class CallController(
|
|||||||
|
|
||||||
session.createOffer { sdp ->
|
session.createOffer { sdp ->
|
||||||
scope.launch {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-10
@@ -20,17 +20,23 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.service.call
|
package com.vitorpamplona.amethyst.service.call
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
|
||||||
|
private const val TAG = "CallForegroundService"
|
||||||
|
|
||||||
class CallForegroundService : Service() {
|
class CallForegroundService : Service() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -57,16 +63,26 @@ class CallForegroundService : Service() {
|
|||||||
ACTION_START -> {
|
ACTION_START -> {
|
||||||
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
|
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
|
||||||
val notification = buildNotification(peerName)
|
val notification = buildNotification(peerName)
|
||||||
ServiceCompat.startForeground(
|
val hasAudioPermission =
|
||||||
this,
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||||
NOTIFICATION_ID,
|
PackageManager.PERMISSION_GRANTED
|
||||||
notification,
|
try {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
val fgsType =
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
} else {
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||||
0
|
} 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 -> {
|
ACTION_STOP -> {
|
||||||
|
|||||||
+10
-2
@@ -164,7 +164,15 @@ class WebRtcCallSession(
|
|||||||
videoSource = peerConnectionFactory?.createVideoSource(false)
|
videoSource = peerConnectionFactory?.createVideoSource(false)
|
||||||
localVideoTrack =
|
localVideoTrack =
|
||||||
peerConnectionFactory?.createVideoTrack("video0", videoSource).also {
|
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()
|
startCamera()
|
||||||
}
|
}
|
||||||
@@ -183,7 +191,7 @@ class WebRtcCallSession(
|
|||||||
context,
|
context,
|
||||||
source.capturerObserver,
|
source.capturerObserver,
|
||||||
)
|
)
|
||||||
it.startCapture(640, 480, 30)
|
it.startCapture(1280, 720, 30)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ fun CallScreen(
|
|||||||
|
|
||||||
is CallState.Offering -> {
|
is CallState.Offering -> {
|
||||||
if (isInPipMode) {
|
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 {
|
} else {
|
||||||
CallInProgressUI(
|
CallInProgressUI(
|
||||||
peerPubKey = state.peerPubKey,
|
peerPubKey = state.peerPubKeys.first(),
|
||||||
statusText = stringRes(R.string.call_calling),
|
statusText = stringRes(R.string.call_calling),
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onHangup = { scope.launch { callManager.hangup() } },
|
onHangup = { scope.launch { callManager.hangup() } },
|
||||||
@@ -155,10 +155,10 @@ fun CallScreen(
|
|||||||
|
|
||||||
is CallState.Connecting -> {
|
is CallState.Connecting -> {
|
||||||
if (isInPipMode) {
|
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 {
|
} else {
|
||||||
CallInProgressUI(
|
CallInProgressUI(
|
||||||
peerPubKey = state.peerPubKey,
|
peerPubKey = state.peerPubKeys.first(),
|
||||||
statusText = stringRes(R.string.call_connecting),
|
statusText = stringRes(R.string.call_connecting),
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onHangup = { scope.launch { callManager.hangup() } },
|
onHangup = { scope.launch { callManager.hangup() } },
|
||||||
@@ -185,7 +185,7 @@ fun CallScreen(
|
|||||||
is CallState.Ended -> {
|
is CallState.Ended -> {
|
||||||
if (!isInPipMode) {
|
if (!isInPipMode) {
|
||||||
CallInProgressUI(
|
CallInProgressUI(
|
||||||
peerPubKey = state.peerPubKey,
|
peerPubKey = state.peerPubKeys.first(),
|
||||||
statusText = stringRes(R.string.call_ended),
|
statusText = stringRes(R.string.call_ended),
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onHangup = { onCallEnded() },
|
onHangup = { onCallEnded() },
|
||||||
@@ -433,7 +433,7 @@ private fun ConnectedCallUI(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
|
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user ->
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
ClickableUserPicture(
|
ClickableUserPicture(
|
||||||
baseUser = user,
|
baseUser = user,
|
||||||
@@ -665,7 +665,7 @@ private fun PipConnectedCallUI(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
|
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user ->
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
ClickableUserPicture(
|
ClickableUserPicture(
|
||||||
baseUser = user,
|
baseUser = user,
|
||||||
|
|||||||
+13
-4
@@ -49,18 +49,27 @@ fun ChatroomScreen(
|
|||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val isGroupChat = roomId.users.size > 1
|
||||||
val startVoiceCall =
|
val startVoiceCall =
|
||||||
rememberCallWithPermission(context) {
|
rememberCallWithPermission(context) {
|
||||||
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
|
|
||||||
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
|
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)
|
CallActivity.launch(context)
|
||||||
}
|
}
|
||||||
val startVideoCall =
|
val startVideoCall =
|
||||||
rememberCallWithPermission(context, isVideo = true) {
|
rememberCallWithPermission(context, isVideo = true) {
|
||||||
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
|
|
||||||
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
|
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)
|
CallActivity.launch(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
@@ -146,6 +146,34 @@ fun RenderRoomTopBar(
|
|||||||
)
|
)
|
||||||
|
|
||||||
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel)
|
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 = {
|
extendableRow = {
|
||||||
|
|||||||
@@ -673,8 +673,14 @@
|
|||||||
<string name="app_notification_calls_channel_name">Połączenia przychodzące</string>
|
<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="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">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_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">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_hangup">Rozłącz się</string>
|
||||||
<string name="call_accept">Akceptuj</string>
|
<string name="call_accept">Akceptuj</string>
|
||||||
<string name="call_reject">Odrzuć</string>
|
<string name="call_reject">Odrzuć</string>
|
||||||
@@ -684,11 +690,13 @@
|
|||||||
<string name="call_camera_on">Kamera włączona</string>
|
<string name="call_camera_on">Kamera włączona</string>
|
||||||
<string name="call_camera_off">Kamera wyłączona</string>
|
<string name="call_camera_off">Kamera wyłączona</string>
|
||||||
<string name="call_speaker">Głośnik</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_bluetooth">Bluetooth</string>
|
||||||
<string name="call_voice">Połączenie głosowe</string>
|
<string name="call_voice">Połączenie głosowe</string>
|
||||||
<string name="call_video">Połączenie wideo</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_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_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="reply_notify">Powiadom: </string>
|
||||||
<string name="channel_list_join_conversation">Dołącz do dyskusji</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>
|
<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_planned_tag">ZAPLANOWANE</string>
|
||||||
<string name="live_stream_is_offline">Transmisja wyłączona</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="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="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="followed_tags">Obserwowane tagi</string>
|
||||||
<string name="relay_setup">Transmitery</string>
|
<string name="relay_setup">Transmitery</string>
|
||||||
@@ -1180,6 +1191,10 @@
|
|||||||
<string name="your_article_title">Tytuł artykułu\u2026</string>
|
<string name="your_article_title">Tytuł artykułu\u2026</string>
|
||||||
<string name="article_description_placeholder">Krótki opis 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">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="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="close_all_reactions_to_this_post">Zamknij wszystkie odzewy na ten post</string>
|
||||||
<string name="reply_description">Odpowiedź</string>
|
<string name="reply_description">Odpowiedź</string>
|
||||||
|
|||||||
+76
-30
@@ -69,6 +69,8 @@ class CallManager(
|
|||||||
|
|
||||||
private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS
|
private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS
|
||||||
|
|
||||||
|
// ---- P2P call initiation ----
|
||||||
|
|
||||||
suspend fun initiateCall(
|
suspend fun initiateCall(
|
||||||
calleePubKey: HexKey,
|
calleePubKey: HexKey,
|
||||||
callType: CallType,
|
callType: CallType,
|
||||||
@@ -76,11 +78,31 @@ class CallManager(
|
|||||||
sdpOffer: String,
|
sdpOffer: String,
|
||||||
) {
|
) {
|
||||||
val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer)
|
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)
|
publishEvent(result.wrap)
|
||||||
startTimeout(callId)
|
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) {
|
fun onIncomingCallEvent(event: CallOfferEvent) {
|
||||||
val callerPubKey = event.pubKey
|
val callerPubKey = event.pubKey
|
||||||
val callId = event.callId() ?: return
|
val callId = event.callId() ?: return
|
||||||
@@ -90,10 +112,13 @@ class CallManager(
|
|||||||
|
|
||||||
if (_state.value !is CallState.Idle) return
|
if (_state.value !is CallState.Idle) return
|
||||||
|
|
||||||
|
val groupMembers = event.groupMembers()
|
||||||
|
|
||||||
_state.value =
|
_state.value =
|
||||||
CallState.IncomingCall(
|
CallState.IncomingCall(
|
||||||
callId = callId,
|
callId = callId,
|
||||||
callerPubKey = callerPubKey,
|
callerPubKey = callerPubKey,
|
||||||
|
groupMembers = groupMembers,
|
||||||
callType = callType,
|
callType = callType,
|
||||||
sdpOffer = event.sdpOffer(),
|
sdpOffer = event.sdpOffer(),
|
||||||
)
|
)
|
||||||
@@ -105,13 +130,11 @@ class CallManager(
|
|||||||
if (current !is CallState.IncomingCall) return
|
if (current !is CallState.IncomingCall) return
|
||||||
|
|
||||||
val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer)
|
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()
|
cancelTimeout()
|
||||||
publishEvent(result.wrap)
|
publishEvent(result.wrap)
|
||||||
|
|
||||||
// Notify other devices of this user that the call was answered here.
|
// 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)
|
val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer)
|
||||||
publishEvent(selfNotify.wrap)
|
publishEvent(selfNotify.wrap)
|
||||||
}
|
}
|
||||||
@@ -121,7 +144,7 @@ class CallManager(
|
|||||||
if (current !is CallState.IncomingCall) return
|
if (current !is CallState.IncomingCall) return
|
||||||
|
|
||||||
val result = factory.createReject(current.callerPubKey, current.callId, signer = signer)
|
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)
|
publishEvent(result.wrap)
|
||||||
|
|
||||||
// Notify other devices of this user that the call was rejected here.
|
// Notify other devices of this user that the call was rejected here.
|
||||||
@@ -136,7 +159,7 @@ class CallManager(
|
|||||||
when (current) {
|
when (current) {
|
||||||
is CallState.Offering -> {
|
is CallState.Offering -> {
|
||||||
if (callId != current.callId) return
|
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()
|
cancelTimeout()
|
||||||
onAnswerReceived?.invoke(event)
|
onAnswerReceived?.invoke(event)
|
||||||
}
|
}
|
||||||
@@ -144,7 +167,7 @@ class CallManager(
|
|||||||
is CallState.IncomingCall -> {
|
is CallState.IncomingCall -> {
|
||||||
// Another device of this user answered the call — stop ringing.
|
// Another device of this user answered the call — stop ringing.
|
||||||
if (callId != current.callId) return
|
if (callId != current.callId) return
|
||||||
transitionToEnded(current.callId, current.callerPubKey, EndReason.ANSWERED_ELSEWHERE)
|
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE)
|
||||||
}
|
}
|
||||||
|
|
||||||
is CallState.Connected -> {
|
is CallState.Connected -> {
|
||||||
@@ -166,13 +189,13 @@ class CallManager(
|
|||||||
when (current) {
|
when (current) {
|
||||||
is CallState.Offering -> {
|
is CallState.Offering -> {
|
||||||
if (callId != current.callId) return
|
if (callId != current.callId) return
|
||||||
transitionToEnded(current.callId, current.peerPubKey, EndReason.PEER_REJECTED)
|
transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
is CallState.IncomingCall -> {
|
is CallState.IncomingCall -> {
|
||||||
// Another device of this user rejected the call — stop ringing.
|
// Another device of this user rejected the call — stop ringing.
|
||||||
if (callId != current.callId) return
|
if (callId != current.callId) return
|
||||||
transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED)
|
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -219,28 +242,28 @@ class CallManager(
|
|||||||
_state.value =
|
_state.value =
|
||||||
CallState.Connected(
|
CallState.Connected(
|
||||||
callId = current.callId,
|
callId = current.callId,
|
||||||
peerPubKey = current.peerPubKey,
|
peerPubKeys = current.peerPubKeys,
|
||||||
callType = current.callType,
|
callType = current.callType,
|
||||||
startedAtEpoch = TimeUtils.now(),
|
startedAtEpoch = TimeUtils.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun hangup() {
|
suspend fun hangup() {
|
||||||
val peerPubKey: HexKey
|
val peerPubKeys: Set<HexKey>
|
||||||
val callId: String
|
val callId: String
|
||||||
when (val current = _state.value) {
|
when (val current = _state.value) {
|
||||||
is CallState.Offering -> {
|
is CallState.Offering -> {
|
||||||
peerPubKey = current.peerPubKey
|
peerPubKeys = current.peerPubKeys
|
||||||
callId = current.callId
|
callId = current.callId
|
||||||
}
|
}
|
||||||
|
|
||||||
is CallState.Connecting -> {
|
is CallState.Connecting -> {
|
||||||
peerPubKey = current.peerPubKey
|
peerPubKeys = current.peerPubKeys
|
||||||
callId = current.callId
|
callId = current.callId
|
||||||
}
|
}
|
||||||
|
|
||||||
is CallState.Connected -> {
|
is CallState.Connected -> {
|
||||||
peerPubKey = current.peerPubKey
|
peerPubKeys = current.peerPubKeys
|
||||||
callId = current.callId
|
callId = current.callId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +272,14 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = factory.createHangup(peerPubKey, callId, signer = signer)
|
if (peerPubKeys.size == 1) {
|
||||||
transitionToEnded(callId, peerPubKey, EndReason.HANGUP)
|
val result = factory.createHangup(peerPubKeys.first(), callId, signer = signer)
|
||||||
publishEvent(result.wrap)
|
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) {
|
fun onPeerHangup(event: CallHangupEvent) {
|
||||||
@@ -267,8 +295,8 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
if (callId != currentCallId) return
|
if (callId != currentCallId) return
|
||||||
|
|
||||||
val peerPubKey = event.pubKey
|
val peerPubKeys = currentPeerPubKeys() ?: return
|
||||||
transitionToEnded(callId, peerPubKey, EndReason.PEER_HANGUP)
|
transitionToEnded(callId, peerPubKeys, EndReason.PEER_HANGUP)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onSignalingEvent(event: Event) {
|
fun onSignalingEvent(event: Event) {
|
||||||
@@ -299,15 +327,22 @@ class CallManager(
|
|||||||
else -> null
|
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) {
|
when (val s = _state.value) {
|
||||||
is CallState.Offering -> s.peerPubKey
|
is CallState.Offering -> s.peerPubKeys
|
||||||
is CallState.IncomingCall -> s.callerPubKey
|
is CallState.IncomingCall -> s.peerPubKeys()
|
||||||
is CallState.Connecting -> s.peerPubKey
|
is CallState.Connecting -> s.peerPubKeys
|
||||||
is CallState.Connected -> s.peerPubKey
|
is CallState.Connected -> s.peerPubKeys
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the current call has more than one peer. */
|
||||||
|
fun isGroupCall(): Boolean = (currentPeerPubKeys()?.size ?: 0) > 1
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
_state.value = CallState.Idle
|
_state.value = CallState.Idle
|
||||||
cancelTimeout()
|
cancelTimeout()
|
||||||
@@ -318,10 +353,10 @@ class CallManager(
|
|||||||
|
|
||||||
private fun transitionToEnded(
|
private fun transitionToEnded(
|
||||||
callId: String,
|
callId: String,
|
||||||
peerPubKey: HexKey,
|
peerPubKeys: Set<HexKey>,
|
||||||
reason: EndReason,
|
reason: EndReason,
|
||||||
) {
|
) {
|
||||||
_state.value = CallState.Ended(callId, peerPubKey, reason)
|
_state.value = CallState.Ended(callId, peerPubKeys, reason)
|
||||||
cancelTimeout()
|
cancelTimeout()
|
||||||
resetJob?.cancel()
|
resetJob?.cancel()
|
||||||
resetJob =
|
resetJob =
|
||||||
@@ -346,13 +381,13 @@ class CallManager(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (currentCallId == callId) {
|
if (currentCallId == callId) {
|
||||||
val peerPubKey =
|
val peerPubKeys =
|
||||||
when (current) {
|
when (current) {
|
||||||
is CallState.Offering -> current.peerPubKey
|
is CallState.Offering -> current.peerPubKeys
|
||||||
is CallState.IncomingCall -> current.callerPubKey
|
is CallState.IncomingCall -> current.peerPubKeys()
|
||||||
else -> return@launch
|
else -> return@launch
|
||||||
}
|
}
|
||||||
transitionToEnded(callId, peerPubKey, EndReason.TIMEOUT)
|
transitionToEnded(callId, peerPubKeys, EndReason.TIMEOUT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,3 +397,14 @@ class CallManager(
|
|||||||
timeoutJob = null
|
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(
|
data class Offering(
|
||||||
val callId: String,
|
val callId: String,
|
||||||
val peerPubKey: HexKey,
|
val peerPubKeys: Set<HexKey>,
|
||||||
val callType: CallType,
|
val callType: CallType,
|
||||||
) : CallState
|
) : CallState
|
||||||
|
|
||||||
data class IncomingCall(
|
data class IncomingCall(
|
||||||
val callId: String,
|
val callId: String,
|
||||||
val callerPubKey: HexKey,
|
val callerPubKey: HexKey,
|
||||||
|
val groupMembers: Set<HexKey>,
|
||||||
val callType: CallType,
|
val callType: CallType,
|
||||||
val sdpOffer: String,
|
val sdpOffer: String,
|
||||||
) : CallState
|
) : CallState
|
||||||
|
|
||||||
data class Connecting(
|
data class Connecting(
|
||||||
val callId: String,
|
val callId: String,
|
||||||
val peerPubKey: HexKey,
|
val peerPubKeys: Set<HexKey>,
|
||||||
val callType: CallType,
|
val callType: CallType,
|
||||||
) : CallState
|
) : CallState
|
||||||
|
|
||||||
data class Connected(
|
data class Connected(
|
||||||
val callId: String,
|
val callId: String,
|
||||||
val peerPubKey: HexKey,
|
val peerPubKeys: Set<HexKey>,
|
||||||
val callType: CallType,
|
val callType: CallType,
|
||||||
val startedAtEpoch: Long,
|
val startedAtEpoch: Long,
|
||||||
) : CallState
|
) : CallState
|
||||||
|
|
||||||
data class Ended(
|
data class Ended(
|
||||||
val callId: String,
|
val callId: String,
|
||||||
val peerPubKey: HexKey,
|
val peerPubKeys: Set<HexKey>,
|
||||||
val reason: EndReason,
|
val reason: EndReason,
|
||||||
) : CallState
|
) : CallState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ The `content` field MAY contain a reason or be empty.
|
|||||||
|
|
||||||
### Call Renegotiate (kind 25055)
|
### 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
|
```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.
|
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
|
### 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.
|
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
|
### WebRTC Configuration
|
||||||
|
|
||||||
- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics.
|
- 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.
|
- 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
|
### 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 switch `AudioManager` to `MODE_IN_COMMUNICATION` when a call connects and restore to `MODE_NORMAL` when the call ends.
|
||||||
|
|||||||
+70
@@ -33,15 +33,24 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
|
|||||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
|
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
|
||||||
|
|
||||||
class WebRtcCallFactory {
|
class WebRtcCallFactory {
|
||||||
|
/** Result for a single-recipient signaling message (P2P). */
|
||||||
data class Result(
|
data class Result(
|
||||||
val msg: Event,
|
val msg: Event,
|
||||||
val wrap: GiftWrapEvent,
|
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 {
|
companion object {
|
||||||
const val WRAP_EXPIRATION_SECONDS = 20L
|
const val WRAP_EXPIRATION_SECONDS = 20L
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- P2P (single recipient) methods ----
|
||||||
|
|
||||||
suspend fun createCallOffer(
|
suspend fun createCallOffer(
|
||||||
sdpOffer: String,
|
sdpOffer: String,
|
||||||
calleePubKey: HexKey,
|
calleePubKey: HexKey,
|
||||||
@@ -114,4 +123,65 @@ class WebRtcCallFactory {
|
|||||||
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
|
||||||
return Result(signed, wrap)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
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.pTag
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds
|
||||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
|
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
|
||||||
@@ -50,6 +52,18 @@ class CallOfferEvent(
|
|||||||
|
|
||||||
fun sdpOffer() = content
|
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 {
|
companion object {
|
||||||
const val KIND = 25050
|
const val KIND = 25050
|
||||||
const val ALT_DESCRIPTION = "WebRTC call offer"
|
const val ALT_DESCRIPTION = "WebRTC call offer"
|
||||||
@@ -70,5 +84,21 @@ class CallOfferEvent(
|
|||||||
expiration(createdAt + EXPIRATION_SECONDS)
|
expiration(createdAt + EXPIRATION_SECONDS)
|
||||||
initializer()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
@@ -188,4 +188,77 @@ class CallEventsTest {
|
|||||||
assertEquals("new-sdp-offer", template.content)
|
assertEquals("new-sdp-offer", template.content)
|
||||||
assertEquals(CallRenegotiateEvent.KIND, template.kind)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user