Merge pull request #2068 from vitorpamplona/claude/add-webrtc-calls-4kBSR

Add WebRTC-based peer-to-peer voice and video calling via NIP-AC
This commit is contained in:
Vitor Pamplona
2026-04-02 11:28:23 -04:00
committed by GitHub
40 changed files with 4254 additions and 3 deletions
+3
View File
@@ -363,6 +363,9 @@ dependencies {
// Voice anonymization DSP
implementation libs.tarsosdsp
// WebRTC for voice/video calls
implementation libs.stream.webrtc.android
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
+14 -1
View File
@@ -39,6 +39,12 @@
<!-- Audio/Video Playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls -->
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -75,7 +81,8 @@
android:exported="true"
android:launchMode="singleInstance"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -221,6 +228,12 @@
</intent-filter>
</service>
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone"
android:stopWithTask="true"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -1686,6 +1686,11 @@ class Account(
suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
suspend fun publishCallSignaling(wrap: GiftWrapEvent) {
val relayList = computeRelayListToBroadcast(wrap)
client.publish(wrap, relayList)
}
suspend fun updateStatus(
oldStatus: AddressableNote,
newStatus: String,
@@ -219,6 +219,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
@@ -2570,6 +2576,12 @@ object LocalCache : ILocalCache, ICacheProvider {
is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is CallAnswerEvent -> consumeRegularEvent(event, relay, wasVerified)
is CallHangupEvent -> consumeRegularEvent(event, relay, wasVerified)
is CallIceCandidateEvent -> consumeRegularEvent(event, relay, wasVerified)
is CallOfferEvent -> consumeRegularEvent(event, relay, wasVerified)
is CallRejectEvent -> consumeRegularEvent(event, relay, wasVerified)
is CallRenegotiateEvent -> consumeRegularEvent(event, relay, wasVerified)
is ChannelCreateEvent -> consume(event, relay, wasVerified)
is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
is ChannelHideMessageEvent -> consume(event, relay, wasVerified)
@@ -0,0 +1,291 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.Ringtone
import android.media.RingtoneManager
import android.media.ToneGenerator
import android.os.Build
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class AudioRoute {
EARPIECE,
SPEAKER,
BLUETOOTH,
}
class CallAudioManager(
private val context: Context,
) {
private var ringtone: Ringtone? = null
private var vibrator: Vibrator? = null
private var proximityWakeLock: PowerManager.WakeLock? = null
private var ringbackTone: ToneGenerator? = null
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var previousAudioMode: Int = AudioManager.MODE_NORMAL
private var scoReceiver: BroadcastReceiver? = null
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow()
private val _isBluetoothAvailable = MutableStateFlow(false)
val isBluetoothAvailable: StateFlow<Boolean> = _isBluetoothAvailable.asStateFlow()
fun startRinging() {
startRingtone()
startVibration()
}
fun stopRinging() {
stopRingtone()
stopVibration()
}
fun startRingbackTone() {
try {
ringbackTone =
ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80).also {
it.startTone(ToneGenerator.TONE_SUP_RINGTONE)
}
} catch (_: Exception) {
}
}
fun stopRingbackTone() {
ringbackTone?.stopTone()
ringbackTone?.release()
ringbackTone = null
}
fun switchToCallAudioMode() {
previousAudioMode = audioManager.mode
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
_isBluetoothAvailable.value = hasBluetoothDevice()
if (_isBluetoothAvailable.value) {
startBluetoothSco()
} else {
routeToEarpiece()
}
registerBluetoothScoReceiver()
}
fun restoreAudioMode() {
stopBluetoothSco()
unregisterBluetoothScoReceiver()
audioManager.mode = previousAudioMode
audioManager.isSpeakerphoneOn = false
}
fun cycleAudioRoute() {
val hasBt = _isBluetoothAvailable.value
val next =
when (_audioRoute.value) {
AudioRoute.EARPIECE -> if (hasBt) AudioRoute.BLUETOOTH else AudioRoute.SPEAKER
AudioRoute.BLUETOOTH -> AudioRoute.SPEAKER
AudioRoute.SPEAKER -> AudioRoute.EARPIECE
}
setAudioRoute(next)
}
fun setAudioRoute(route: AudioRoute) {
_audioRoute.value = route
when (route) {
AudioRoute.EARPIECE -> routeToEarpiece()
AudioRoute.SPEAKER -> routeToSpeaker()
AudioRoute.BLUETOOTH -> routeToBluetooth()
}
}
fun acquireProximityWakeLock() {
if (proximityWakeLock != null) return
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
proximityWakeLock =
powerManager.newWakeLock(
PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK,
"amethyst:call_proximity",
)
proximityWakeLock?.acquire(60 * 60 * 1000L)
}
fun releaseProximityWakeLock() {
proximityWakeLock?.let { if (it.isHeld) it.release() }
proximityWakeLock = null
}
fun release() {
stopRinging()
stopRingbackTone()
restoreAudioMode()
releaseProximityWakeLock()
}
private fun routeToEarpiece() {
stopBluetoothSco()
audioManager.isSpeakerphoneOn = false
}
private fun routeToSpeaker() {
stopBluetoothSco()
audioManager.isSpeakerphoneOn = true
}
private fun routeToBluetooth() {
audioManager.isSpeakerphoneOn = false
startBluetoothSco()
}
private fun hasBluetoothDevice(): Boolean =
try {
audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.any {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
it.type == AudioDeviceInfo.TYPE_BLE_HEADSET
}
} catch (_: Exception) {
false
}
@Suppress("DEPRECATION")
private fun startBluetoothSco() {
try {
if (!audioManager.isBluetoothScoOn) {
audioManager.startBluetoothSco()
audioManager.isBluetoothScoOn = true
}
} catch (_: Exception) {
}
}
@Suppress("DEPRECATION")
private fun stopBluetoothSco() {
try {
if (audioManager.isBluetoothScoOn) {
audioManager.isBluetoothScoOn = false
audioManager.stopBluetoothSco()
}
} catch (_: Exception) {
}
}
private fun registerBluetoothScoReceiver() {
if (scoReceiver != null) return
scoReceiver =
object : BroadcastReceiver() {
override fun onReceive(
ctx: Context?,
intent: Intent?,
) {
val state = intent?.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1)
when (state) {
AudioManager.SCO_AUDIO_STATE_CONNECTED -> {
_isBluetoothAvailable.value = true
if (_audioRoute.value == AudioRoute.BLUETOOTH) {
audioManager.isBluetoothScoOn = true
}
}
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> {
_isBluetoothAvailable.value = hasBluetoothDevice()
if (_audioRoute.value == AudioRoute.BLUETOOTH) {
_audioRoute.value = AudioRoute.EARPIECE
routeToEarpiece()
}
}
}
}
}
@Suppress("DEPRECATION")
context.registerReceiver(
scoReceiver,
IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED),
)
}
private fun unregisterBluetoothScoReceiver() {
scoReceiver?.let {
try {
context.unregisterReceiver(it)
} catch (_: Exception) {
}
}
scoReceiver = null
}
private fun startRingtone() {
try {
val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
ringtone =
RingtoneManager.getRingtone(context, ringtoneUri)?.apply {
audioAttributes =
AudioAttributes
.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
isLooping = true
play()
}
} catch (_: Exception) {
}
}
private fun stopRingtone() {
ringtone?.stop()
ringtone = null
}
private fun startVibration() {
try {
vibrator =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
val pattern = longArrayOf(0, 1000, 1000)
vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0))
} catch (_: Exception) {
}
}
private fun stopVibration() {
vibrator?.cancel()
vibrator = null
}
}
@@ -0,0 +1,346 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.webrtc.IceCandidate
import org.webrtc.SessionDescription
import org.webrtc.VideoTrack
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
private const val TAG = "CallController"
class CallController(
private val context: Context,
val callManager: CallManager,
private val scope: CoroutineScope,
private val publishWrap: suspend (GiftWrapEvent) -> Unit,
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
) {
private var webRtcSession: WebRtcCallSession? = null
private val callFactory = WebRtcCallFactory()
private val remoteDescriptionSet =
java.util.concurrent.atomic
.AtomicBoolean(false)
private val pendingIceCandidates = CopyOnWriteArrayList<IceCandidate>()
val audioManager = CallAudioManager(context)
// Video tracks exposed to UI
private val _remoteVideoTrack = MutableStateFlow<VideoTrack?>(null)
val remoteVideoTrack: StateFlow<VideoTrack?> = _remoteVideoTrack.asStateFlow()
private val _localVideoTrack = MutableStateFlow<VideoTrack?>(null)
val localVideoTrack: StateFlow<VideoTrack?> = _localVideoTrack.asStateFlow()
// Error state exposed to UI
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// Audio/video toggle state (UI concerns, not domain state)
private val _isAudioMuted = MutableStateFlow(false)
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
private val _isVideoEnabled = MutableStateFlow(false)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
init {
scope.launch {
callManager.state.collect { state ->
when (state) {
is CallState.IncomingCall -> {
audioManager.startRinging()
}
is CallState.Offering -> {
audioManager.startRingbackTone()
}
is CallState.Connecting -> {
audioManager.stopRinging()
audioManager.stopRingbackTone()
audioManager.switchToCallAudioMode()
audioManager.acquireProximityWakeLock()
}
is CallState.Connected -> {
audioManager.acquireProximityWakeLock()
}
is CallState.Ended -> {
cleanup()
}
else -> {}
}
}
}
}
fun initiateCall(
peerPubKey: String,
callType: CallType,
) {
scope.launch {
val callId = UUID.randomUUID().toString()
_errorMessage.value = null
remoteDescriptionSet.set(false)
pendingIceCandidates.clear()
try {
withContext(Dispatchers.IO) { createWebRtcSession() }
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to start call: ${e.message}"
return@launch
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return@launch
}
session.addAudioTrack()
if (callType == CallType.VIDEO) {
session.addVideoTrack()
_localVideoTrack.value = session.getLocalVideoTrack()
_isVideoEnabled.value = true
}
session.createOffer { sdp ->
scope.launch {
callManager.initiateCall(peerPubKey, callType, callId, sdp.description)
}
}
}
}
fun acceptIncomingCall(sdpOffer: String) {
val state = callManager.state.value
if (state !is CallState.IncomingCall) return
scope.launch {
_errorMessage.value = null
remoteDescriptionSet.set(false)
// Don't clear pendingIceCandidates here — they were buffered while ringing
try {
withContext(Dispatchers.IO) { createWebRtcSession() }
} catch (e: Exception) {
Log.e(TAG, "Failed to create WebRTC session", e)
_errorMessage.value = "Failed to accept call: ${e.message}"
return@launch
}
val session =
webRtcSession ?: run {
_errorMessage.value = "Failed to create WebRTC session"
return@launch
}
session.addAudioTrack()
if (state.callType == CallType.VIDEO) {
session.addVideoTrack()
_localVideoTrack.value = session.getLocalVideoTrack()
_isVideoEnabled.value = true
}
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
flushPendingIceCandidates()
session.createAnswer { sdp ->
scope.launch {
callManager.acceptCall(sdp.description)
}
}
}
}
fun onCallAnswerReceived(sdpAnswer: String) {
Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, session=${webRtcSession != null}" }
webRtcSession?.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer))
flushPendingIceCandidates()
}
fun onIceCandidateReceived(event: CallIceCandidateEvent) {
try {
val candidate = IceCandidate(event.sdpMid(), event.sdpMLineIndex(), event.candidateSdp())
if (webRtcSession != null && remoteDescriptionSet.get()) {
Log.d(TAG) { "Adding ICE candidate directly: ${candidate.sdp.take(50)}" }
webRtcSession?.addIceCandidate(candidate)
} else {
Log.d(TAG) { "Buffering ICE candidate (session=${webRtcSession != null}, remoteSet=${remoteDescriptionSet.get()})" }
pendingIceCandidates.add(candidate)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse ICE candidate", e)
}
}
private fun flushPendingIceCandidates() {
remoteDescriptionSet.set(true)
val session = webRtcSession ?: return
val candidates = pendingIceCandidates.toList()
Log.d(TAG) { "Flushing ${candidates.size} buffered ICE candidates" }
pendingIceCandidates.clear()
candidates.forEach { session.addIceCandidate(it) }
}
// UI toggle controls
fun toggleAudioMute() {
val muted = !_isAudioMuted.value
_isAudioMuted.value = muted
webRtcSession?.setAudioEnabled(!muted)
}
fun toggleVideo() {
val session = webRtcSession ?: return
val enabling = !_isVideoEnabled.value
if (enabling) {
if (session.getLocalVideoTrack() == null) {
// No video track yet — create one (voice → video upgrade)
session.addVideoTrack()
} else {
// Track exists but camera was stopped — restart it
session.setVideoEnabled(true)
session.startCamera()
}
_localVideoTrack.value = session.getLocalVideoTrack()
_isVideoEnabled.value = true
} else {
// Disable video: stop camera and disable track
session.setVideoEnabled(false)
session.stopCamera()
_isVideoEnabled.value = false
}
}
fun cycleAudioRoute() {
audioManager.cycleAudioRoute()
}
fun getEglBase() = webRtcSession?.eglBase
fun hangup() {
scope.launch {
callManager.hangup()
// cleanup is triggered by the state collector when Ended is reached
}
}
fun clearError() {
_errorMessage.value = null
}
fun cleanup() {
audioManager.release()
stopForegroundService()
NotificationUtils.cancelCallNotification(context)
_remoteVideoTrack.value = null
_localVideoTrack.value = null
_isAudioMuted.value = false
_isVideoEnabled.value = false
webRtcSession?.dispose()
webRtcSession = null
remoteDescriptionSet.set(false)
pendingIceCandidates.clear()
}
private fun createWebRtcSession() {
val session =
WebRtcCallSession(
context = context,
iceServers = IceServerConfig.buildIceServers(),
onIceCandidate = { candidate -> onLocalIceCandidate(candidate) },
onPeerConnected = {
callManager.onPeerConnected()
startForegroundService()
},
onRemoteVideoTrack = { track -> _remoteVideoTrack.value = track },
onDisconnected = { scope.launch { callManager.hangup() } },
onError = { error -> _errorMessage.value = error },
)
try {
session.initialize()
session.createPeerConnection()
webRtcSession = session
} catch (e: Exception) {
session.dispose()
throw e
}
}
private fun onLocalIceCandidate(candidate: IceCandidate) {
Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" }
val callId = callManager.currentCallId() ?: return
val peerPubKey = callManager.currentPeerPubKey() ?: return
val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex)
scope.launch {
val signer = signerProvider()
val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer)
publishWrap(result.wrap)
}
}
private fun startForegroundService() {
try {
val peerName = callManager.currentPeerPubKey() ?: ""
val intent =
Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_START
putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName)
}
context.startForegroundService(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service", e)
}
}
private fun stopForegroundService() {
try {
val intent =
Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_STOP
}
context.startService(intent)
} catch (_: Exception) {
}
}
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.vitorpamplona.amethyst.R
class CallForegroundService : Service() {
companion object {
const val CHANNEL_ID = "amethyst_call_channel"
const val NOTIFICATION_ID = 9001
const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START"
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val EXTRA_PEER_NAME = "peer_name"
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
when (intent?.action) {
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
},
)
}
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
private fun createNotificationChannel() {
val channel =
NotificationChannel(
CHANNEL_ID,
getString(R.string.call_ongoing),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.call_ongoing_description)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
private fun buildNotification(peerName: String): Notification =
NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.call_with, peerName))
.setSmallIcon(R.drawable.amethyst)
.setOngoing(true)
.build()
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import org.webrtc.PeerConnection
object IceServerConfig {
val defaultStunServers =
listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(),
)
val defaultTurnServers =
listOf(
PeerConnection.IceServer
.builder("turn:openrelay.metered.ca:80")
.setUsername("openrelayproject")
.setPassword("openrelayproject")
.createIceServer(),
PeerConnection.IceServer
.builder("turn:openrelay.metered.ca:443")
.setUsername("openrelayproject")
.setPassword("openrelayproject")
.createIceServer(),
PeerConnection.IceServer
.builder("turn:openrelay.metered.ca:443?transport=tcp")
.setUsername("openrelayproject")
.setPassword("openrelayproject")
.createIceServer(),
)
fun buildIceServers(userTurnServers: List<TurnServerConfig> = emptyList()): List<PeerConnection.IceServer> {
val servers = (defaultStunServers + defaultTurnServers).toMutableList()
userTurnServers.forEach { turn ->
servers.add(
PeerConnection.IceServer
.builder(turn.url)
.setUsername(turn.username)
.setPassword(turn.credential)
.createIceServer(),
)
}
return servers
}
}
data class TurnServerConfig(
val url: String,
val username: String,
val credential: String,
)
@@ -0,0 +1,305 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import android.content.Context
import com.vitorpamplona.quartz.utils.Log
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.Camera2Enumerator
import org.webrtc.CameraVideoCapturer
import org.webrtc.DataChannel
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
private const val TAG = "WebRtcCallSession"
class WebRtcCallSession(
private val context: Context,
private val iceServers: List<PeerConnection.IceServer>,
private val onIceCandidate: (IceCandidate) -> Unit,
private val onPeerConnected: () -> Unit,
private val onRemoteVideoTrack: (VideoTrack) -> Unit,
private val onDisconnected: () -> Unit,
private val onError: (String) -> Unit = {},
) {
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
private var localAudioTrack: AudioTrack? = null
private var localVideoTrack: VideoTrack? = null
private var audioSource: AudioSource? = null
private var videoSource: VideoSource? = null
private var cameraCapturer: CameraVideoCapturer? = null
val eglBase: EglBase = EglBase.create()
fun initialize() {
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
.createPeerConnectionFactory()
}
fun createPeerConnection() {
val rtcConfig =
PeerConnection.RTCConfiguration(iceServers).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
}
peerConnection =
peerConnectionFactory?.createPeerConnection(
rtcConfig,
object : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate?) {
candidate?.let { this@WebRtcCallSession.onIceCandidate(it) }
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) {}
override fun onSignalingChange(state: PeerConnection.SignalingState?) {}
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
Log.d(TAG) { "ICE connection state: $state" }
when (state) {
PeerConnection.IceConnectionState.CONNECTED -> {
onPeerConnected()
}
PeerConnection.IceConnectionState.FAILED -> {
onError("Connection failed - check network")
onDisconnected()
}
PeerConnection.IceConnectionState.DISCONNECTED -> {
onDisconnected()
}
else -> {}
}
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
override fun onAddStream(stream: MediaStream?) {
// Fallback for Plan B SDP — Unified Plan uses onAddTrack
stream?.videoTracks?.firstOrNull()?.let { onRemoteVideoTrack(it) }
}
override fun onRemoveStream(stream: MediaStream?) {}
override fun onDataChannel(channel: DataChannel?) {}
override fun onRenegotiationNeeded() {}
override fun onAddTrack(
receiver: RtpReceiver?,
streams: Array<out MediaStream>?,
) {
// Unified Plan: extract video track from receiver
val track = receiver?.track()
if (track is VideoTrack) {
onRemoteVideoTrack(track)
}
}
},
)
}
fun addAudioTrack() {
val constraints = MediaConstraints()
audioSource = peerConnectionFactory?.createAudioSource(constraints)
localAudioTrack =
peerConnectionFactory?.createAudioTrack("audio0", audioSource).also {
peerConnection?.addTrack(it)
}
}
fun addVideoTrack() {
videoSource = peerConnectionFactory?.createVideoSource(false)
localVideoTrack =
peerConnectionFactory?.createVideoTrack("video0", videoSource).also {
peerConnection?.addTrack(it)
}
startCamera()
}
fun startCamera() {
if (cameraCapturer != null) return // Already running
val source = videoSource ?: return
val enumerator = Camera2Enumerator(context)
val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return
cameraCapturer =
enumerator.createCapturer(camera, null)?.also {
it.initialize(
SurfaceTextureHelper.create("CaptureThread", eglBase.eglBaseContext),
context,
source.capturerObserver,
)
it.startCapture(640, 480, 30)
}
}
fun stopCamera() {
cameraCapturer?.stopCapture()
cameraCapturer?.dispose()
cameraCapturer = null
}
fun getLocalVideoSource(): VideoSource? = videoSource
fun getLocalVideoTrack(): VideoTrack? = localVideoTrack
fun createOffer(onSdpCreated: (SessionDescription) -> Unit) {
val constraints =
MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
peerConnection?.createOffer(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let {
peerConnection?.setLocalDescription(noOpSdpObserver(), it)
onSdpCreated(it)
}
}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "Create offer failed: $error")
error?.let { onError("Create offer failed: $it") }
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
},
constraints,
)
}
fun createAnswer(onSdpCreated: (SessionDescription) -> Unit) {
val constraints =
MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
peerConnection?.createAnswer(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let {
peerConnection?.setLocalDescription(noOpSdpObserver(), it)
onSdpCreated(it)
}
}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "Create answer failed: $error")
error?.let { onError("Create answer failed: $it") }
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
},
constraints,
)
}
fun setRemoteDescription(sdp: SessionDescription) {
peerConnection?.setRemoteDescription(noOpSdpObserver(), sdp)
}
fun addIceCandidate(candidate: IceCandidate) {
peerConnection?.addIceCandidate(candidate)
}
fun setAudioEnabled(enabled: Boolean) {
localAudioTrack?.setEnabled(enabled)
}
fun setVideoEnabled(enabled: Boolean) {
localVideoTrack?.setEnabled(enabled)
}
fun dispose() {
stopCamera()
localAudioTrack?.dispose()
localVideoTrack?.dispose()
audioSource?.dispose()
videoSource?.dispose()
peerConnection?.close()
peerConnection?.dispose()
peerConnectionFactory?.dispose()
eglBase.release()
localAudioTrack = null
localVideoTrack = null
audioSource = null
videoSource = null
peerConnection = null
peerConnectionFactory = null
}
private fun noOpSdpObserver() =
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "SDP operation failed: $error")
error?.let { onError("SDP error: $it") }
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {
Log.e(TAG, "SDP set failed: $error")
error?.let { onError("SDP error: $it") }
}
}
}
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import java.math.BigDecimal
@@ -138,6 +139,10 @@ class EventNotificationConsumer(
is LiveChessMoveEvent -> {
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn)
}
is CallOfferEvent -> {
notifyIncomingCall(innerEvent, account)
}
}
}
}
@@ -620,6 +625,26 @@ class EventNotificationConsumer(
}
}
private fun notifyIncomingCall(
event: CallOfferEvent,
account: Account,
) {
if (!account.isFollowing(event.pubKey)) return
if (TimeUtils.now() - event.createdAt > CallOfferEvent.EXPIRATION_SECONDS) return
val callerUser = LocalCache.getUserIfExists(event.pubKey)
val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..."
NotificationUtils
.sendCallNotification(
callerName = callerName,
callerBitmap = null,
uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}",
applicationContext = applicationContext,
)
}
fun notificationManager(): NotificationManager =
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
as NotificationManager
@@ -47,11 +47,14 @@ object NotificationUtils {
private var zapChannel: NotificationChannel? = null
private var reactionChannel: NotificationChannel? = null
private var chessChannel: NotificationChannel? = null
private var callChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
@@ -510,6 +513,89 @@ object NotificationUtils {
notify(summaryId, summaryBuilder.build())
}
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
if (callChannel != null) return callChannel!!
callChannel =
NotificationChannel(
CALL_CHANNEL_ID,
stringRes(applicationContext, R.string.app_notification_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
}
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(callChannel!!)
return callChannel!!
}
fun sendCallNotification(
callerName: String,
callerBitmap: Bitmap?,
uri: String,
applicationContext: Context,
) {
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = getOrCreateCallChannel(applicationContext)
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
val contentPendingIntent =
PendingIntent.getActivity(
applicationContext,
CALL_NOTIFICATION_ID,
contentIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val fullScreenIntent =
Intent(applicationContext, MainActivity::class.java).apply {
data = uri.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val fullScreenPendingIntent =
PendingIntent.getActivity(
applicationContext,
CALL_NOTIFICATION_ID + 1,
fullScreenIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val builder =
NotificationCompat
.Builder(applicationContext, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(applicationContext, R.string.call_incoming))
.setContentText(callerName)
.setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent)
.setFullScreenIntent(fullScreenPendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), contentPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), fullScreenPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
}
fun cancelCallNotification(applicationContext: Context) {
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel("call", CALL_NOTIFICATION_ID)
}
private fun NotificationManager.isDuplicate(notId: Int): Boolean {
val notifications: Array<StatusBarNotification> = activeNotifications
for (notification in notifications) {
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.call
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.content.ContextCompat
fun hasPermission(
context: Context,
permission: String,
) = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
fun hasCallPermissions(
context: Context,
isVideo: Boolean,
): Boolean {
if (!hasPermission(context, Manifest.permission.RECORD_AUDIO)) return false
if (isVideo && !hasPermission(context, Manifest.permission.CAMERA)) return false
return true
}
@Composable
fun rememberCallWithPermission(
context: Context,
isVideo: Boolean = false,
onCall: () -> Unit,
): () -> Unit {
val permissions =
if (isVideo) {
arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
} else {
arrayOf(Manifest.permission.RECORD_AUDIO)
}
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
val allGranted = results.values.all { it }
if (allGranted) onCall()
}
return remember(onCall, isVideo) {
{
if (hasCallPermissions(context, isVideo)) {
onCall()
} else {
launcher.launch(permissions)
}
}
}
}
@@ -0,0 +1,590 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.call
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BluetoothAudio
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.AudioRoute
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable
fun CallScreen(
callManager: CallManager,
callController: CallController?,
accountViewModel: AccountViewModel,
onCallEnded: () -> Unit,
) {
val callState by callManager.state.collectAsState()
val scope = rememberCoroutineScope()
val context = LocalContext.current
val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<String?>(null) }
val errorMessage by (callController?.errorMessage ?: emptyStringFlow).collectAsState()
BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) {
scope.launch { callManager.hangup() }
}
KeepScreenOn()
EnterPipOnLeave(callState)
Box(modifier = Modifier.fillMaxSize()) {
when (val state = callState) {
is CallState.Idle -> {
// Wait briefly — initiateCall runs async and state may not have
// transitioned yet when navigating to this screen
LaunchedEffect(Unit) {
delay(500)
if (callManager.state.value is CallState.Idle) {
onCallEnded()
}
}
}
is CallState.Offering -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
}
is CallState.IncomingCall -> {
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val acceptWithPermission =
rememberCallWithPermission(context, isVideo = isVideoCall) {
callController?.acceptIncomingCall(state.sdpOffer)
}
IncomingCallUI(
callerPubKey = state.callerPubKey,
callType = state.callType,
accountViewModel = accountViewModel,
onAccept = acceptWithPermission,
onReject = { scope.launch { callManager.rejectCall() } },
)
}
is CallState.Connecting -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
}
is CallState.Connected -> {
ConnectedCallUI(
state = state,
callController = callController,
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { callController?.toggleAudioMute() },
onToggleVideo = { callController?.toggleVideo() },
onCycleAudioRoute = { callController?.cycleAudioRoute() },
)
}
is CallState.Ended -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel,
onHangup = { onCallEnded() },
)
}
}
errorMessage?.let { error ->
Snackbar(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
Text(
stringRes(R.string.call_dismiss),
modifier =
Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary,
)
},
) {
Text(error)
}
}
}
}
@Composable
private fun CallInProgressUI(
peerPubKey: String,
statusText: String,
accountViewModel: AccountViewModel,
onHangup: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp))
FloatingActionButton(
onClick = onHangup,
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = stringRes(R.string.call_hangup),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
@Composable
private fun IncomingCallUI(
callerPubKey: String,
callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType,
accountViewModel: AccountViewModel,
onAccept: () -> Unit,
onReject: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
stringRes(
if (callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO) {
R.string.call_incoming_video
} else {
R.string.call_incoming_voice
},
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(48.dp),
) {
FloatingActionButton(
onClick = onReject,
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = stringRes(R.string.call_reject),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
FloatingActionButton(
onClick = onAccept,
containerColor = Color(0xFF4CAF50),
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.Call,
contentDescription = stringRes(R.string.call_accept),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
}
@Composable
private fun ConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
accountViewModel: AccountViewModel,
onHangup: () -> Unit,
onToggleMute: () -> Unit,
onToggleVideo: () -> Unit,
onCycleAudioRoute: () -> Unit,
) {
var elapsed by remember { mutableLongStateOf(0L) }
LaunchedEffect(state.startedAtEpoch) {
while (true) {
elapsed = TimeUtils.now() - state.startedAtEpoch
delay(1000)
}
}
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).collectAsState()
val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
val defaultRoute = remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState()
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
// Remote video (full screen background)
remoteVideoTrack?.let { track ->
VideoRenderer(
videoTrack = track,
eglBase = callController?.getEglBase(),
modifier = Modifier.fillMaxSize(),
mirror = false,
)
}
// Local video (small pip in corner) — only when camera is active
if (isVideoEnabled) {
localVideoTrack?.let { track ->
VideoRenderer(
videoTrack = track,
eglBase = callController?.getEglBase(),
modifier =
Modifier
.size(120.dp, 160.dp)
.align(Alignment.TopEnd)
.padding(16.dp),
mirror = true,
)
}
}
// If no video, show avatar
if (remoteVideoTrack == null) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = Color.White,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 16.sp,
)
}
} else {
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 14.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 48.dp),
)
}
// Controls at bottom
Column(
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
onClick = onToggleMute,
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = if (isAudioMuted) Icons.Default.MicOff else Icons.Default.Mic,
contentDescription = stringRes(if (isAudioMuted) R.string.call_unmute else R.string.call_mute),
tint = if (isAudioMuted) Color.Red else Color.White,
modifier = Modifier.size(28.dp),
)
}
IconButton(
onClick = onToggleVideo,
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff,
contentDescription = stringRes(if (isVideoEnabled) R.string.call_camera_off else R.string.call_camera_on),
tint = if (!isVideoEnabled) Color.Red else Color.White,
modifier = Modifier.size(28.dp),
)
}
IconButton(
onClick = onCycleAudioRoute,
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector =
when (currentAudioRoute) {
AudioRoute.EARPIECE -> Icons.Default.VolumeOff
AudioRoute.SPEAKER -> Icons.Default.VolumeUp
AudioRoute.BLUETOOTH -> Icons.Default.BluetoothAudio
},
contentDescription =
stringRes(
when (currentAudioRoute) {
AudioRoute.EARPIECE -> R.string.call_earpiece
AudioRoute.SPEAKER -> R.string.call_speaker
AudioRoute.BLUETOOTH -> R.string.call_bluetooth
},
),
tint =
when (currentAudioRoute) {
AudioRoute.EARPIECE -> Color.White
AudioRoute.SPEAKER -> Color.Cyan
AudioRoute.BLUETOOTH -> Color(0xFF2196F3)
},
modifier = Modifier.size(28.dp),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
FloatingActionButton(
onClick = onHangup,
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = stringRes(R.string.call_hangup),
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
@Composable
private fun VideoRenderer(
videoTrack: VideoTrack,
eglBase: org.webrtc.EglBase?,
modifier: Modifier = Modifier,
mirror: Boolean = false,
) {
AndroidView(
modifier = modifier,
factory = { ctx ->
SurfaceViewRenderer(ctx).apply {
setMirror(mirror)
setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
eglBase?.eglBaseContext?.let { init(it, null) }
videoTrack.addSink(this)
}
},
onRelease = { renderer ->
videoTrack.removeSink(renderer)
renderer.release()
},
)
}
private fun formatDuration(seconds: Long): String {
val mins = seconds / 60
val secs = seconds % 60
return "%02d:%02d".format(mins, secs)
}
@Composable
private fun KeepScreenOn() {
val context = LocalContext.current
DisposableEffect(Unit) {
val window = (context as? android.app.Activity)?.window
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
@Composable
private fun EnterPipOnLeave(callState: CallState) {
val context = LocalContext.current
val activity = context as? android.app.Activity ?: return
val isActiveCall =
callState is CallState.Connected ||
callState is CallState.Connecting ||
callState is CallState.Offering
if (isActiveCall && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer =
object : androidx.lifecycle.DefaultLifecycleObserver {
override fun onStop(owner: androidx.lifecycle.LifecycleOwner) {
try {
val params =
android.app.PictureInPictureParams
.Builder()
.setAspectRatio(android.util.Rational(9, 16))
.build()
activity.enterPictureInPictureMode(params)
} catch (_: Exception) {
// PiP not supported or activity not in correct state
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
}
@@ -0,0 +1,362 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.call
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
@Composable
private fun PreviewCallInProgress(statusText: String) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Alice",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp))
FloatingActionButton(
onClick = {},
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Hang up",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewCallingScreen() {
ThemeComparisonColumn {
PreviewCallInProgress("Calling...")
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewConnectingScreen() {
ThemeComparisonColumn {
PreviewCallInProgress("Connecting...")
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewCallEndedScreen() {
ThemeComparisonColumn {
PreviewCallInProgress("Call ended")
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewIncomingCallScreen() {
ThemeComparisonColumn {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Bob",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Incoming voice call...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(48.dp),
) {
FloatingActionButton(
onClick = {},
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Reject",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
FloatingActionButton(
onClick = {},
containerColor = Color(0xFF4CAF50),
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.Call,
contentDescription = "Accept",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewConnectedCallScreen() {
ThemeComparisonColumn {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Alice",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "02:45",
color = Color.White.copy(alpha = 0.7f),
fontSize = 16.sp,
)
}
Column(
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
onClick = {},
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Mute",
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
IconButton(
onClick = {},
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = Icons.Default.VideocamOff,
contentDescription = "Camera off",
tint = Color.Red,
modifier = Modifier.size(28.dp),
)
}
IconButton(
onClick = {},
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = Icons.Default.VolumeOff,
contentDescription = "Speaker",
tint = Color.White,
modifier = Modifier.size(28.dp),
)
}
}
Spacer(modifier = Modifier.height(24.dp))
FloatingActionButton(
onClick = {},
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Hang up",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewConnectedCallMuted() {
ThemeComparisonColumn {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(16.dp))
Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White)
Spacer(modifier = Modifier.height(8.dp))
Text("05:12", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp)
}
Column(
modifier =
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(onClick = {}, modifier = Modifier.size(56.dp)) {
Icon(Icons.Default.MicOff, "Unmute", tint = Color.Red, modifier = Modifier.size(28.dp))
}
IconButton(onClick = {}, modifier = Modifier.size(56.dp)) {
Icon(Icons.Default.Videocam, "Camera on", tint = Color.White, modifier = Modifier.size(28.dp))
}
IconButton(onClick = {}, modifier = Modifier.size(56.dp)) {
Icon(Icons.Default.VolumeUp, "Earpiece", tint = Color.Cyan, modifier = Modifier.size(28.dp))
}
}
Spacer(modifier = Modifier.height(24.dp))
FloatingActionButton(
onClick = {},
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp))
}
}
}
}
}
@@ -28,6 +28,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -39,14 +40,17 @@ import androidx.core.util.Consumer
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallScreen
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.navigation.composableFromEnd
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -162,6 +166,23 @@ fun AppNavigation(
DisplayNotifyMessages(accountViewModel, nav)
DisplayCrashMessages(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
ObserveIncomingCalls(accountViewModel, nav)
}
@Composable
private fun ObserveIncomingCalls(
accountViewModel: AccountViewModel,
nav: INav,
) {
val callState by accountViewModel.callManager.state.collectAsState()
LaunchedEffect(callState) {
val state = callState
if (state is CallState.IncomingCall) {
nav.nav(Route.ActiveCall(state.callId, state.callerPubKey))
}
}
}
@Composable
@@ -169,6 +190,11 @@ fun BuildNavigation(
accountViewModel: AccountViewModel,
nav: Nav,
) {
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) {
accountViewModel.initCallController(context)
}
NavHost(
navController = nav.controller,
startDestination = Route.Home,
@@ -255,6 +281,15 @@ fun BuildNavigation(
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.ActiveCall> {
CallScreen(
callManager = accountViewModel.callManager,
callController = accountViewModel.callController,
accountViewModel = accountViewModel,
onCallEnded = { nav.popBack() },
)
}
composableFromEndArgs<Route.PublicChatChannel> {
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
}
@@ -302,6 +302,11 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ActiveCall(
val callId: String,
val peerPubKey: HexKey,
) : Route()
@Serializable data class EventRedirect(
val id: String,
) : Route()
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
@@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -187,6 +189,43 @@ class AccountViewModel(
val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope)
val callManager =
CallManager(
signer = account.signer,
scope = viewModelScope,
isFollowing = { account.isFollowing(it) },
publishEvent = { wrap ->
viewModelScope.launch {
account.publishCallSignaling(wrap)
}
},
)
var callController: CallController? = null
private set
@Synchronized
fun initCallController(context: Context) {
if (callController != null) return
// Wire EventProcessor before creating CallController so events aren't dropped
account.newNotesPreProcessor.callManager = callManager
val controller =
CallController(
context = context,
callManager = callManager,
scope = viewModelScope,
publishWrap = { wrap -> account.publishCallSignaling(wrap) },
signerProvider = { account.signer },
)
// Set callbacks before exposing controller to avoid timing races
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) }
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
callController = controller
}
val eventSync =
EventSync(
accountPubKey = account.signer.pubKey,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -36,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -52,6 +59,8 @@ class EventProcessor(
private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache)
private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache)
var callManager: CallManager? = null
suspend fun consume(note: Note) {
note.event?.let { event ->
try {
@@ -68,10 +77,22 @@ class EventProcessor(
publicNote: Note,
) {
when (event) {
is CallOfferEvent,
is CallAnswerEvent,
is CallIceCandidateEvent,
is CallHangupEvent,
is CallRejectEvent,
is CallRenegotiateEvent,
-> callManager?.onSignalingEvent(event)
is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
}
}
@@ -26,12 +26,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
@Composable
fun ChatroomScreen(
@@ -43,10 +47,32 @@ fun ChatroomScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val startVoiceCall =
rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE)
val callId = accountViewModel.callManager.currentCallId() ?: ""
nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey))
}
val startVideoCall =
rememberCallWithPermission(context, isVideo = true) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO)
val callId = accountViewModel.callManager.currentCallId() ?: ""
nav.nav(Route.ActiveCall(callId = callId, peerPubKey = peerPubKey))
}
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
RenderRoomTopBar(roomId, accountViewModel, nav)
RenderRoomTopBar(
room = roomId,
accountViewModel = accountViewModel,
nav = nav,
onCallClick = { _ -> startVoiceCall() },
onVideoCallClick = { _ -> startVideoCall() },
)
},
accountViewModel = accountViewModel,
) {
@@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -46,6 +52,7 @@ fun ChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onCallClick: ((String) -> Unit)? = null,
onClick: () -> Unit,
) {
if (room.users.size == 1) {
@@ -56,6 +63,7 @@ fun ChatroomHeader(
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClick,
onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } },
)
}
}
@@ -75,6 +83,7 @@ fun UserChatroomHeader(
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
onCallClick: (() -> Unit)? = null,
) {
Column(
Modifier
@@ -91,9 +100,28 @@ fun UserChatroomHeader(
size = Size34dp,
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
}
if (onCallClick != null) {
IconButton(
onClick = onCallClick,
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Voice call",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}
@@ -25,15 +25,20 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -69,6 +74,8 @@ fun RenderRoomTopBar(
room: ChatroomKey,
accountViewModel: AccountViewModel,
nav: INav,
onCallClick: ((String) -> Unit)? = null,
onVideoCallClick: ((String) -> Unit)? = null,
) {
if (room.users.size == 1) {
TopBarExtensibleWithBackButton(
@@ -84,6 +91,34 @@ fun RenderRoomTopBar(
Spacer(modifier = DoubleHorzSpacer)
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
if (onVideoCallClick != null) {
IconButton(
onClick = { onVideoCallClick(baseUser.pubkeyHex) },
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = stringRes(R.string.call_video),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
if (onCallClick != null) {
IconButton(
onClick = { onCallClick(baseUser.pubkeyHex) },
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),
)
}
}
}
}
},
+29
View File
@@ -771,6 +771,35 @@
<string name="app_notification_chess_your_turn">%1$s moved — your turn</string>
<string name="app_notification_chess_summary">Chess updates</string>
<!-- Call notifications and UI -->
<string name="app_notification_calls_channel_name">Incoming calls</string>
<string name="app_notification_calls_channel_description">Notifications for incoming voice and video calls</string>
<string name="call_incoming">Incoming call</string>
<string name="call_incoming_voice">Incoming voice call\u2026</string>
<string name="call_incoming_video">Incoming video call\u2026</string>
<string name="call_calling">Calling\u2026</string>
<string name="call_connecting">Connecting\u2026</string>
<string name="call_ended">Call ended</string>
<string name="call_with">Call with %1$s</string>
<string name="call_ongoing">Calls</string>
<string name="call_ongoing_description">Ongoing call notification</string>
<string name="call_hangup">Hang up</string>
<string name="call_accept">Accept</string>
<string name="call_reject">Reject</string>
<string name="call_dismiss">Dismiss</string>
<string name="call_mute">Mute</string>
<string name="call_unmute">Unmute</string>
<string name="call_camera_on">Camera on</string>
<string name="call_camera_off">Camera off</string>
<string name="call_speaker">Speaker</string>
<string name="call_earpiece">Earpiece</string>
<string name="call_bluetooth">Bluetooth</string>
<string name="call_voice">Voice call</string>
<string name="call_video">Video call</string>
<string name="call_failed_start">Failed to start call</string>
<string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string>
<string name="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string>
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import org.junit.Assert.assertEquals
import org.junit.Test
class IceCandidateSerializationTest {
@Test
fun serializeCandidateProducesValidJson() {
val json = CallIceCandidateEvent.serializeCandidate("candidate:123 1 udp 456", "audio", 1)
assert(json.contains(""""candidate":"candidate:123 1 udp 456""""))
assert(json.contains(""""sdpMid":"audio""""))
assert(json.contains(""""sdpMLineIndex":1"""))
}
@Test
fun serializeAndParseRoundTrip() {
val sdp = "candidate:999 1 udp 789 10.0.0.1 5000 typ host"
val sdpMid = "video"
val sdpMLineIndex = 2
val json = CallIceCandidateEvent.serializeCandidate(sdp, sdpMid, sdpMLineIndex)
// Create a minimal event to test parsing
val event =
CallIceCandidateEvent(
id = "test",
pubKey = "test",
createdAt = 0,
tags = emptyArray(),
content = json,
sig = "test",
)
assertEquals(sdp, event.candidateSdp())
assertEquals(sdpMid, event.sdpMid())
assertEquals(sdpMLineIndex, event.sdpMLineIndex())
}
@Test
fun parseCandidateHandlesMissingFields() {
val event =
CallIceCandidateEvent(
id = "test",
pubKey = "test",
createdAt = 0,
tags = emptyArray(),
content = """{"candidate":"test"}""",
sig = "test",
)
assertEquals("test", event.candidateSdp())
assertEquals("0", event.sdpMid()) // default
assertEquals(0, event.sdpMLineIndex()) // default
}
}
@@ -0,0 +1,290 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.call
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class CallManager(
private val signer: NostrSigner,
private val scope: CoroutineScope,
private val isFollowing: (HexKey) -> Boolean,
private val publishEvent: (GiftWrapEvent) -> Unit,
) {
private val factory = WebRtcCallFactory()
private val _state = MutableStateFlow<CallState>(CallState.Idle)
val state: StateFlow<CallState> = _state.asStateFlow()
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
private var timeoutJob: Job? = null
private var resetJob: Job? = null
private val processedEventIds = mutableSetOf<String>()
companion object {
const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout
const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting
const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this
}
private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS
suspend fun initiateCall(
calleePubKey: HexKey,
callType: CallType,
callId: String,
sdpOffer: String,
) {
val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer)
_state.value = CallState.Offering(callId, calleePubKey, callType)
publishEvent(result.wrap)
startTimeout(callId)
}
fun onIncomingCallEvent(event: CallOfferEvent) {
val callerPubKey = event.pubKey
val callId = event.callId() ?: return
val callType = event.callType() ?: CallType.VOICE
if (!isFollowing(callerPubKey)) return
if (_state.value !is CallState.Idle) return
_state.value =
CallState.IncomingCall(
callId = callId,
callerPubKey = callerPubKey,
callType = callType,
sdpOffer = event.sdpOffer(),
)
startTimeout(callId)
}
suspend fun acceptCall(sdpAnswer: String) {
val current = _state.value
if (current !is CallState.IncomingCall) return
val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer)
_state.value = CallState.Connecting(current.callId, current.callerPubKey, current.callType)
cancelTimeout()
publishEvent(result.wrap)
}
suspend fun rejectCall() {
val current = _state.value
if (current !is CallState.IncomingCall) return
val result = factory.createReject(current.callerPubKey, current.callId, signer = signer)
transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED)
publishEvent(result.wrap)
}
fun onCallAnswered(event: CallAnswerEvent) {
val current = _state.value
if (current !is CallState.Offering) return
if (event.callId() != current.callId) return
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType)
cancelTimeout()
onAnswerReceived?.invoke(event)
}
fun onCallRejected(event: CallRejectEvent) {
val current = _state.value
if (current !is CallState.Offering) return
if (event.callId() != current.callId) return
transitionToEnded(current.callId, current.peerPubKey, EndReason.PEER_REJECTED)
}
fun onIceCandidate(event: CallIceCandidateEvent) {
onIceCandidateReceived?.invoke(event)
}
fun onPeerConnected() {
val current = _state.value
if (current !is CallState.Connecting) return
_state.value =
CallState.Connected(
callId = current.callId,
peerPubKey = current.peerPubKey,
callType = current.callType,
startedAtEpoch = TimeUtils.now(),
)
}
suspend fun hangup() {
val peerPubKey: HexKey
val callId: String
when (val current = _state.value) {
is CallState.Offering -> {
peerPubKey = current.peerPubKey
callId = current.callId
}
is CallState.Connecting -> {
peerPubKey = current.peerPubKey
callId = current.callId
}
is CallState.Connected -> {
peerPubKey = current.peerPubKey
callId = current.callId
}
else -> {
return
}
}
val result = factory.createHangup(peerPubKey, callId, signer = signer)
transitionToEnded(callId, peerPubKey, EndReason.HANGUP)
publishEvent(result.wrap)
}
fun onPeerHangup(event: CallHangupEvent) {
val current = _state.value
val callId = event.callId() ?: return
val currentCallId =
when (current) {
is CallState.Offering -> current.callId
is CallState.Connecting -> current.callId
is CallState.Connected -> current.callId
is CallState.IncomingCall -> current.callId
else -> return
}
if (callId != currentCallId) return
val peerPubKey = event.pubKey
transitionToEnded(callId, peerPubKey, EndReason.PEER_HANGUP)
}
fun onSignalingEvent(event: Event) {
if (isEventTooOld(event)) {
Log.d("CallManager") { "Discarding old event kind=${event.kind} age=${TimeUtils.now() - event.createdAt}s" }
return
}
if (!processedEventIds.add(event.id)) return
Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" }
when (event) {
is CallOfferEvent -> onIncomingCallEvent(event)
is CallAnswerEvent -> onCallAnswered(event)
is CallRejectEvent -> onCallRejected(event)
is CallHangupEvent -> onPeerHangup(event)
is CallIceCandidateEvent -> onIceCandidate(event)
}
}
fun currentCallId(): String? =
when (val s = _state.value) {
is CallState.Offering -> s.callId
is CallState.IncomingCall -> s.callId
is CallState.Connecting -> s.callId
is CallState.Connected -> s.callId
else -> null
}
fun currentPeerPubKey(): HexKey? =
when (val s = _state.value) {
is CallState.Offering -> s.peerPubKey
is CallState.IncomingCall -> s.callerPubKey
is CallState.Connecting -> s.peerPubKey
is CallState.Connected -> s.peerPubKey
else -> null
}
fun reset() {
_state.value = CallState.Idle
cancelTimeout()
resetJob?.cancel()
resetJob = null
processedEventIds.clear()
}
private fun transitionToEnded(
callId: String,
peerPubKey: HexKey,
reason: EndReason,
) {
_state.value = CallState.Ended(callId, peerPubKey, reason)
cancelTimeout()
resetJob?.cancel()
resetJob =
scope.launch {
delay(ENDED_DISPLAY_MS)
if (_state.value is CallState.Ended) {
_state.value = CallState.Idle
}
}
}
private fun startTimeout(callId: String) {
cancelTimeout()
timeoutJob =
scope.launch {
delay(CALL_TIMEOUT_MS)
val current = _state.value
val currentCallId =
when (current) {
is CallState.Offering -> current.callId
is CallState.IncomingCall -> current.callId
else -> null
}
if (currentCallId == callId) {
val peerPubKey =
when (current) {
is CallState.Offering -> current.peerPubKey
is CallState.IncomingCall -> current.callerPubKey
else -> return@launch
}
transitionToEnded(callId, peerPubKey, EndReason.TIMEOUT)
}
}
}
private fun cancelTimeout() {
timeoutJob?.cancel()
timeoutJob = null
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.call
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
@Immutable
sealed interface CallState {
data object Idle : CallState
data class Offering(
val callId: String,
val peerPubKey: HexKey,
val callType: CallType,
) : CallState
data class IncomingCall(
val callId: String,
val callerPubKey: HexKey,
val callType: CallType,
val sdpOffer: String,
) : CallState
data class Connecting(
val callId: String,
val peerPubKey: HexKey,
val callType: CallType,
) : CallState
data class Connected(
val callId: String,
val peerPubKey: HexKey,
val callType: CallType,
val startedAtEpoch: Long,
) : CallState
data class Ended(
val callId: String,
val peerPubKey: HexKey,
val reason: EndReason,
) : CallState
}
enum class EndReason {
HANGUP,
REJECTED,
TIMEOUT,
ERROR,
PEER_HANGUP,
PEER_REJECTED,
}
+2
View File
@@ -48,6 +48,7 @@ secp256k1KmpJniAndroid = "0.23.0"
securityCryptoKtx = "1.1.0"
slf4j = "2.0.17"
spotless = "8.4.0"
streamWebrtcAndroid = "1.3.8"
tarsosdsp = "2.5"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
@@ -157,6 +158,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines",
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
@@ -0,0 +1,301 @@
NIP-AC
======
WebRTC Calls
------------
`draft` `optional`
This NIP defines a protocol for establishing private peer-to-peer voice and video calls between Nostr
users using WebRTC, with Nostr relays serving as the signaling transport and public STUN servers for
NAT traversal — no custom server infrastructure is required.
## Overview
The protocol works as follows:
1. **Caller** creates a signed call offer event containing an SDP offer
2. The event is **gift-wrapped** ([NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md)) and published to relays
3. **Callee** unwraps the event, verifies the signature, and decides whether to accept
4. If accepted, callee sends back a gift-wrapped call answer event containing an SDP answer
5. Both parties exchange **ICE candidates** as gift-wrapped events for NAT traversal
6. A **direct WebRTC peer connection** is established for audio/video
All signaling events MUST be gift-wrapped using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) for metadata privacy.
Events are signed by the sender's key and wrapped directly (without the seal layer) — the gift wrap's
random ephemeral key already hides the sender from relay operators.
## Event Kinds
| Kind | Name | Description |
|-------|---------------------|----------------------------------------------|
| 25050 | Call Offer | SDP offer initiating a call |
| 25051 | Call Answer | SDP answer accepting a call |
| 25052 | ICE Candidate | ICE candidate for NAT traversal |
| 25053 | Call Hangup | Terminates an active or pending call |
| 25054 | Call Reject | Rejects an incoming call |
| 25055 | Call Renegotiate | New SDP offer for mid-call changes |
## Tags
All signaling events MUST include:
| Tag | Description | Required |
|---------------|-------------------------------------------------------|----------|
| `p` | Hex pubkey of the recipient | YES |
| `call-id` | UUID identifying the call session | YES |
| `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be `created_at + 20` seconds | YES |
| `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES |
Additional tags for **Call Offer** (kind 25050):
| Tag | Description | Required |
|---------------|-------------------------------------------------------|----------|
| `call-type` | `"voice"` or `"video"` | YES |
## Event Structures
### Call Offer (kind 25050)
The `content` field contains the SDP offer string.
```json
{
"kind": 25050,
"pubkey": "<caller-hex-pubkey>",
"created_at": 1234567890,
"content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...",
"tags": [
["p", "<callee-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["call-type", "video"],
["expiration", "1234567910"],
["alt", "WebRTC call offer"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
### Call Answer (kind 25051)
The `content` field contains the SDP answer string.
```json
{
"kind": 25051,
"pubkey": "<callee-hex-pubkey>",
"created_at": 1234567895,
"content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...",
"tags": [
["p", "<caller-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234567915"],
["alt", "WebRTC call answer"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
### ICE Candidate (kind 25052)
The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. Special characters in the SDP string MUST be properly JSON-escaped.
```json
{
"kind": 25052,
"pubkey": "<sender-hex-pubkey>",
"created_at": 1234567896,
"content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}",
"tags": [
["p", "<peer-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234567916"],
["alt", "WebRTC ICE candidate"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
### Call Hangup (kind 25053)
The `content` field MAY contain a human-readable reason or be empty.
```json
{
"kind": 25053,
"pubkey": "<sender-hex-pubkey>",
"created_at": 1234568000,
"content": "",
"tags": [
["p", "<peer-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234568020"],
["alt", "WebRTC call hangup"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
### Call Reject (kind 25054)
The `content` field MAY contain a reason or be empty.
```json
{
"kind": 25054,
"pubkey": "<callee-hex-pubkey>",
"created_at": 1234567893,
"content": "",
"tags": [
["p", "<caller-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234567913"],
["alt", "WebRTC call rejection"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
### Call Renegotiate (kind 25055)
Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer.
```json
{
"kind": 25055,
"pubkey": "<sender-hex-pubkey>",
"created_at": 1234568100,
"content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...",
"tags": [
["p", "<peer-hex-pubkey>"],
["call-id", "550e8400-e29b-41d4-a716-446655440000"],
["expiration", "1234568120"],
["alt", "WebRTC call renegotiation"]
],
"id": "<event-id>",
"sig": "<signature>"
}
```
## Encryption and Delivery
All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps:
1. **Sign** the signaling event with the sender's key
2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption
3. **Set expiration on the gift wrap** — the outer gift wrap MUST include an `expiration` tag matching the inner event's expiration (adjusted for the randomized `created_at`). This allows relays to garbage-collect ephemeral signaling data.
4. **Publish** the gift wrap to the recipient's relay list
The seal layer (`SealedRumorEvent`) is NOT used. The gift wrap already provides:
- **NIP-44 encryption** — content is unreadable to relay operators
- **Random ephemeral pubkey** — the relay cannot identify the sender
- **`p` tag** — reveals only the recipient (necessary for delivery)
- **`expiration` tag** — allows relays to delete the wrap after the signaling window
Recipients unwrap the gift, verify the inner event's signature against the sender's pubkey, and then process the signaling message.
## Protocol Flow
### Initiating a Call
```
Caller Relay Callee
| | |
|-- GiftWrap(CallOffer) ------->| |
| |-- GiftWrap(CallOffer) ------->|
| | |
| | [Callee unwraps, verifies signature]
| | [Checks: is caller followed?]
| | [YES → ring / NO → ignore]
| | |
|<-- GiftWrap(CallAnswer) ------|<-- GiftWrap(CallAnswer) ------|
| | |
|<-> GiftWrap(IceCandidate) <-->|<-> GiftWrap(IceCandidate) <-->|
| | |
|============= WebRTC P2P Connection Established ===============|
| (relay no longer involved) |
```
### ICE Candidate Buffering
ICE candidates may arrive before the WebRTC peer connection is ready (e.g., the callee is still ringing). Clients MUST buffer incoming ICE candidates and apply them after `setRemoteDescription()` succeeds. Candidates buffered while ringing MUST NOT be cleared when accepting the call.
### Ending a Call
Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHOULD close the WebRTC peer connection and release media resources upon receiving it.
### Rejecting a Call
The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state.
## Spam Prevention
Clients SHOULD implement call filtering:
- **Follow-gated ringing**: Only display incoming call notifications for users in the recipient's follow list. Calls from non-followed users SHOULD be silently ignored.
- **Staleness check**: Clients MUST discard signaling events older than the expiration window (20 seconds). This prevents old cached events from triggering phantom calls on app restart or relay reconnect.
- **Deduplication**: Clients MUST track processed event IDs to prevent the same signaling event (delivered by multiple relays) from being processed twice.
- **Rate limiting**: Clients SHOULD ignore duplicate call offers from the same pubkey within a short window.
## NAT Traversal
This NIP does not mandate specific STUN or TURN servers. Clients SHOULD:
- Ship with a default set of public STUN servers (e.g., `stun:stun.l.google.com:19302`)
- Ship with default TURN servers for relay fallback when direct P2P fails (~20% of cases, including devices on the same WiFi network where hairpin NAT is not supported)
- Allow users to configure custom TURN servers for restrictive network environments
- Use trickle ICE (sending candidates as they are discovered) rather than waiting for all candidates before sending the offer/answer
- Use `GATHER_CONTINUALLY` policy for ongoing ICE candidate discovery
## Implementation Notes
### Event Lifecycle
- The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`.
- Both inner events and outer gift wraps SHOULD have short expiration times (~20 seconds) since signaling data is ephemeral and has no long-term value. This allows relays to garbage-collect call signaling events.
- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state.
- After a call ends, the call state SHOULD auto-reset to idle after a brief display period (e.g., 2 seconds) to ensure the client is ready for subsequent calls.
### WebRTC Configuration
- The WebRTC `PeerConnection` SHOULD use Unified Plan SDP semantics.
- Clients MAY support call renegotiation (kind 25055) for toggling video on/off mid-call without tearing down the connection.
- ICE candidate JSON content MUST be properly escaped — SDP strings can contain quotes and backslashes that break naive string interpolation.
### Audio and Media
- Clients SHOULD switch `AudioManager` to `MODE_IN_COMMUNICATION` when a call connects and restore to `MODE_NORMAL` when the call ends.
- Clients SHOULD support audio routing between earpiece, speaker, and Bluetooth SCO headsets. If a Bluetooth headset disconnects mid-call, the client SHOULD fall back to earpiece automatically.
- Clients SHOULD play a ringback tone (e.g., `TONE_SUP_RINGTONE`) for the caller while waiting for the callee to answer.
- Clients SHOULD play the device's default ringtone and vibrate when an incoming call arrives from a followed user.
### Platform Integration
- Clients SHOULD use a foreground service (type `microphone`) to keep calls alive when the app is backgrounded.
- Clients SHOULD acquire a proximity wake lock during active calls to turn off the screen when held to the ear.
- Clients SHOULD keep the screen on during active calls.
- Clients MAY enter Picture-in-Picture mode when the user navigates away from the call screen during an active call.
- Clients SHOULD request `RECORD_AUDIO` permission (and `CAMERA` for video calls) at runtime before initiating or accepting a call.
### Error Handling
- If WebRTC session creation fails, the client SHOULD display an error to the user and transition to an ended state.
- If SDP offer/answer creation fails, the client SHOULD surface the error instead of hanging silently.
- Clients SHOULD handle `ICE_CONNECTION_FAILED` state by ending the call and notifying the user of a connection failure.
## References
- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) — Event structure
- [NIP-31: Alt Tag](https://github.com/nostr-protocol/nips/blob/master/31.md) — Human-readable event descriptions
- [NIP-40: Expiration](https://github.com/nostr-protocol/nips/blob/master/40.md) — Event expiration timestamps
- [NIP-44: Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) — XChaCha20-Poly1305 encryption
- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md) — Encrypted event delivery
- [WebRTC Specification](https://www.w3.org/TR/webrtc/) — Peer-to-peer real-time communication
- [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) — Interactive Connectivity Establishment
- [nostr-protocol/nips#771](https://github.com/nostr-protocol/nips/issues/771) — WebRTC signaling discussion
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
class WebRtcCallFactory {
data class Result(
val msg: Event,
val wrap: GiftWrapEvent,
)
companion object {
const val WRAP_EXPIRATION_SECONDS = 20L
}
suspend fun createCallOffer(
sdpOffer: String,
calleePubKey: HexKey,
callId: String,
callType: CallType,
signer: NostrSigner,
): Result {
val template = CallOfferEvent.build(sdpOffer, calleePubKey, callId, callType)
val signed = signer.sign(template)
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
return Result(signed, wrap)
}
suspend fun createCallAnswer(
sdpAnswer: String,
callerPubKey: HexKey,
callId: String,
signer: NostrSigner,
): Result {
val template = CallAnswerEvent.build(sdpAnswer, callerPubKey, callId)
val signed = signer.sign(template)
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
return Result(signed, wrap)
}
suspend fun createIceCandidate(
candidateJson: String,
peerPubKey: HexKey,
callId: String,
signer: NostrSigner,
): Result {
val template = CallIceCandidateEvent.build(candidateJson, peerPubKey, callId)
val signed = signer.sign(template)
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
return Result(signed, wrap)
}
suspend fun createHangup(
peerPubKey: HexKey,
callId: String,
reason: String = "",
signer: NostrSigner,
): Result {
val template = CallHangupEvent.build(peerPubKey, callId, reason)
val signed = signer.sign(template)
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
return Result(signed, wrap)
}
suspend fun createReject(
callerPubKey: HexKey,
callId: String,
reason: String = "",
signer: NostrSigner,
): Result {
val template = CallRejectEvent.build(callerPubKey, callId, reason)
val signed = signer.sign(template)
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = callerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
return Result(signed, wrap)
}
suspend fun createRenegotiate(
sdpOffer: String,
peerPubKey: HexKey,
callId: String,
signer: NostrSigner,
): Result {
val template = CallRenegotiateEvent.build(sdpOffer, peerPubKey, callId)
val signed = signer.sign(template)
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
return Result(signed, wrap)
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class CallAnswerEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
fun sdpAnswer() = content
companion object {
const val KIND = 25051
const val ALT_DESCRIPTION = "WebRTC call answer"
const val EXPIRATION_SECONDS = 20L
fun build(
sdpAnswer: String,
callerPubKey: HexKey,
callId: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallAnswerEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpAnswer, createdAt) {
alt(ALT_DESCRIPTION)
pTag(callerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class CallHangupEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
fun reason() = content.ifEmpty { null }
companion object {
const val KIND = 25053
const val ALT_DESCRIPTION = "WebRTC call hangup"
const val EXPIRATION_SECONDS = 20L
fun build(
peerPubKey: HexKey,
callId: String,
reason: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallHangupEvent>.() -> Unit = {},
) = eventTemplate(KIND, reason, createdAt) {
alt(ALT_DESCRIPTION)
pTag(peerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class CallIceCandidateEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
fun candidateJson() = content
fun candidateSdp(): String = CANDIDATE_REGEX.find(content)?.groupValues?.get(1) ?: ""
fun sdpMid(): String = SDP_MID_REGEX.find(content)?.groupValues?.get(1) ?: "0"
fun sdpMLineIndex(): Int =
SDP_MLINE_INDEX_REGEX
.find(content)
?.groupValues
?.get(1)
?.toIntOrNull() ?: 0
companion object {
const val KIND = 25052
const val ALT_DESCRIPTION = "WebRTC ICE candidate"
const val EXPIRATION_SECONDS = 20L
private val CANDIDATE_REGEX = """"candidate"\s*:\s*"([^"]*)"""".toRegex()
private val SDP_MID_REGEX = """"sdpMid"\s*:\s*"([^"]*)"""".toRegex()
private val SDP_MLINE_INDEX_REGEX = """"sdpMLineIndex"\s*:\s*(\d+)""".toRegex()
fun serializeCandidate(
sdp: String,
sdpMid: String,
sdpMLineIndex: Int,
): String {
val escapedSdp = sdp.replace("\\", "\\\\").replace("\"", "\\\"")
val escapedMid = sdpMid.replace("\\", "\\\\").replace("\"", "\\\"")
return """{"candidate":"$escapedSdp","sdpMid":"$escapedMid","sdpMLineIndex":$sdpMLineIndex}"""
}
fun build(
candidateJson: String,
peerPubKey: HexKey,
callId: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallIceCandidateEvent>.() -> Unit = {},
) = eventTemplate(KIND, candidateJson, createdAt) {
alt(ALT_DESCRIPTION)
pTag(peerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callType
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class CallOfferEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse)
fun sdpOffer() = content
companion object {
const val KIND = 25050
const val ALT_DESCRIPTION = "WebRTC call offer"
const val EXPIRATION_SECONDS = 20L
fun build(
sdpOffer: String,
calleePubKey: HexKey,
callId: String,
type: CallType,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpOffer, createdAt) {
alt(ALT_DESCRIPTION)
pTag(calleePubKey)
callId(callId)
callType(type)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class CallRejectEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
fun reason() = content.ifEmpty { null }
companion object {
const val KIND = 25054
const val ALT_DESCRIPTION = "WebRTC call rejection"
const val EXPIRATION_SECONDS = 20L
fun build(
callerPubKey: HexKey,
callId: String,
reason: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallRejectEvent>.() -> Unit = {},
) = eventTemplate(KIND, reason, createdAt) {
alt(ALT_DESCRIPTION)
pTag(callerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.callId
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class CallRenegotiateEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
fun sdpOffer() = content
companion object {
const val KIND = 25055
const val ALT_DESCRIPTION = "WebRTC call renegotiation"
const val EXPIRATION_SECONDS = 20L
fun build(
sdpOffer: String,
peerPubKey: HexKey,
callId: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallRenegotiateEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpOffer, createdAt) {
alt(ALT_DESCRIPTION)
pTag(peerPubKey)
callId(callId)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class CallIdTag {
companion object {
const val TAG_NAME = "call-id"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(callId: String) = arrayOf(TAG_NAME, callId)
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
enum class CallType(
val value: String,
) {
VOICE("voice"),
VIDEO("video"),
;
companion object {
fun fromString(value: String): CallType? =
when (value) {
"voice" -> VOICE
"video" -> VIDEO
else -> null
}
}
}
class CallTypeTag {
companion object {
const val TAG_NAME = "call-type"
fun parse(tag: Array<String>): CallType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return CallType.fromString(tag[1])
}
fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value)
}
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls.tags
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.callId(callId: String) = addUnique(CallIdTag.assemble(callId))
fun <T : Event> TagArrayBuilder<T>.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType))
@@ -252,6 +252,12 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
@@ -311,6 +317,12 @@ class EventFactory {
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig)
CallAnswerEvent.KIND -> CallAnswerEvent(id, pubKey, createdAt, tags, content, sig)
CallHangupEvent.KIND -> CallHangupEvent(id, pubKey, createdAt, tags, content, sig)
CallIceCandidateEvent.KIND -> CallIceCandidateEvent(id, pubKey, createdAt, tags, content, sig)
CallOfferEvent.KIND -> CallOfferEvent(id, pubKey, createdAt, tags, content, sig)
CallRejectEvent.KIND -> CallRejectEvent(id, pubKey, createdAt, tags, content, sig)
CallRenegotiateEvent.KIND -> CallRenegotiateEvent(id, pubKey, createdAt, tags, content, sig)
CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig)
CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig)
CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import kotlin.test.Test
import kotlin.test.assertEquals
class CallEventsTest {
@Test
fun callOfferEventHasCorrectKind() {
assertEquals(25050, CallOfferEvent.KIND)
}
@Test
fun callAnswerEventHasCorrectKind() {
assertEquals(25051, CallAnswerEvent.KIND)
}
@Test
fun callIceCandidateEventHasCorrectKind() {
assertEquals(25052, CallIceCandidateEvent.KIND)
}
@Test
fun callHangupEventHasCorrectKind() {
assertEquals(25053, CallHangupEvent.KIND)
}
@Test
fun callRejectEventHasCorrectKind() {
assertEquals(25054, CallRejectEvent.KIND)
}
@Test
fun callRenegotiateEventHasCorrectKind() {
assertEquals(25055, CallRenegotiateEvent.KIND)
}
@Test
fun callOfferBuildIncludesCallIdTag() {
val template =
CallOfferEvent.build(
sdpOffer = "v=0\r\n...",
calleePubKey = "abc123",
callId = "test-call-id",
type = CallType.VOICE,
)
assertEquals(CallOfferEvent.KIND, template.kind)
assertEquals("v=0\r\n...", template.content)
val callIdTag = template.tags.firstOrNull { it[0] == "call-id" }
assertEquals("test-call-id", callIdTag?.get(1))
}
@Test
fun callOfferBuildIncludesCallTypeTag() {
val template =
CallOfferEvent.build(
sdpOffer = "sdp",
calleePubKey = "abc123",
callId = "id",
type = CallType.VIDEO,
)
val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
assertEquals("video", callTypeTag?.get(1))
}
@Test
fun callOfferBuildIncludesPTag() {
val template =
CallOfferEvent.build(
sdpOffer = "sdp",
calleePubKey = "recipient-hex-key",
callId = "id",
type = CallType.VOICE,
)
val pTag = template.tags.firstOrNull { it[0] == "p" }
assertEquals("recipient-hex-key", pTag?.get(1))
}
@Test
fun callOfferBuildIncludesExpirationTag() {
val template =
CallOfferEvent.build(
sdpOffer = "sdp",
calleePubKey = "abc",
callId = "id",
type = CallType.VOICE,
createdAt = 1000L,
)
val expirationTag = template.tags.firstOrNull { it[0] == "expiration" }
assertEquals((1000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1))
}
@Test
fun callOfferExpirationIs20Seconds() {
assertEquals(20L, CallOfferEvent.EXPIRATION_SECONDS)
}
@Test
fun callAnswerBuildContainsSdp() {
val template =
CallAnswerEvent.build(
sdpAnswer = "answer-sdp",
callerPubKey = "caller",
callId = "call-1",
)
assertEquals("answer-sdp", template.content)
assertEquals(CallAnswerEvent.KIND, template.kind)
}
@Test
fun callIceCandidateBuildContainsJson() {
val candidateJson = """{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}"""
val template =
CallIceCandidateEvent.build(
candidateJson = candidateJson,
peerPubKey = "peer",
callId = "call-1",
)
assertEquals(candidateJson, template.content)
}
@Test
fun callHangupBuildAllowsEmptyReason() {
val template =
CallHangupEvent.build(
peerPubKey = "peer",
callId = "call-1",
)
assertEquals("", template.content)
}
@Test
fun callHangupBuildAllowsCustomReason() {
val template =
CallHangupEvent.build(
peerPubKey = "peer",
callId = "call-1",
reason = "busy",
)
assertEquals("busy", template.content)
}
@Test
fun callRejectBuildHasCorrectKind() {
val template =
CallRejectEvent.build(
callerPubKey = "caller",
callId = "call-1",
)
assertEquals(CallRejectEvent.KIND, template.kind)
}
@Test
fun callRenegotiateBuildContainsSdp() {
val template =
CallRenegotiateEvent.build(
sdpOffer = "new-sdp-offer",
peerPubKey = "peer",
callId = "call-1",
)
assertEquals("new-sdp-offer", template.content)
assertEquals(CallRenegotiateEvent.KIND, template.kind)
}
}
@@ -0,0 +1,131 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipACWebRtcCalls
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class CallTagsTest {
@Test
fun parseCallIdTag() {
val tag = arrayOf("call-id", "550e8400-e29b-41d4-a716-446655440000")
val result = CallIdTag.parse(tag)
assertNotNull(result)
assertEquals("550e8400-e29b-41d4-a716-446655440000", result)
}
@Test
fun parseCallIdTagRejectsEmpty() {
val tag = arrayOf("call-id", "")
assertNull(CallIdTag.parse(tag))
}
@Test
fun parseCallIdTagRejectsMissingValue() {
val tag = arrayOf("call-id")
assertNull(CallIdTag.parse(tag))
}
@Test
fun parseCallIdTagRejectsWrongName() {
val tag = arrayOf("other", "some-id")
assertNull(CallIdTag.parse(tag))
}
@Test
fun assembleCallIdTag() {
val tag = CallIdTag.assemble("test-id-123")
assertEquals("call-id", tag[0])
assertEquals("test-id-123", tag[1])
}
@Test
fun roundTripCallIdTag() {
val original = "550e8400-e29b-41d4-a716-446655440000"
val assembled = CallIdTag.assemble(original)
val parsed = CallIdTag.parse(assembled)
assertEquals(original, parsed)
}
@Test
fun parseCallTypeVoice() {
val tag = arrayOf("call-type", "voice")
val result = CallTypeTag.parse(tag)
assertNotNull(result)
assertEquals(CallType.VOICE, result)
}
@Test
fun parseCallTypeVideo() {
val tag = arrayOf("call-type", "video")
val result = CallTypeTag.parse(tag)
assertNotNull(result)
assertEquals(CallType.VIDEO, result)
}
@Test
fun parseCallTypeRejectsUnknown() {
val tag = arrayOf("call-type", "screenshare")
assertNull(CallTypeTag.parse(tag))
}
@Test
fun parseCallTypeRejectsMissing() {
val tag = arrayOf("call-type")
assertNull(CallTypeTag.parse(tag))
}
@Test
fun assembleCallTypeVoice() {
val tag = CallTypeTag.assemble(CallType.VOICE)
assertEquals("call-type", tag[0])
assertEquals("voice", tag[1])
}
@Test
fun assembleCallTypeVideo() {
val tag = CallTypeTag.assemble(CallType.VIDEO)
assertEquals("call-type", tag[0])
assertEquals("video", tag[1])
}
@Test
fun roundTripCallType() {
for (type in CallType.entries) {
val assembled = CallTypeTag.assemble(type)
val parsed = CallTypeTag.parse(assembled)
assertEquals(type, parsed)
}
}
@Test
fun callTypeFromString() {
assertEquals(CallType.VOICE, CallType.fromString("voice"))
assertEquals(CallType.VIDEO, CallType.fromString("video"))
assertNull(CallType.fromString("audio"))
assertNull(CallType.fromString(""))
}
}