diff --git a/amethyst/build.gradle b/amethyst/build.gradle
index 5b4516875..57630fc71 100644
--- a/amethyst/build.gradle
+++ b/amethyst/build.gradle
@@ -363,6 +363,9 @@ dependencies {
// Voice anonymization DSP
implementation libs.tarsosdsp
+ // WebRTC for voice/video calls
+ implementation libs.stream.webrtc.android
+
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index 4aae58459..f5e39a388 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -39,6 +39,12 @@
+
+
+
+
+
+
@@ -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">
@@ -221,6 +228,12 @@
+
+
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)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt
new file mode 100644
index 000000000..d703c22d8
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt
@@ -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.asStateFlow()
+
+ private val _isBluetoothAvailable = MutableStateFlow(false)
+ val isBluetoothAvailable: StateFlow = _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
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt
new file mode 100644
index 000000000..0b761a6d4
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt
@@ -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()
+ val audioManager = CallAudioManager(context)
+
+ // Video tracks exposed to UI
+ private val _remoteVideoTrack = MutableStateFlow(null)
+ val remoteVideoTrack: StateFlow = _remoteVideoTrack.asStateFlow()
+ private val _localVideoTrack = MutableStateFlow(null)
+ val localVideoTrack: StateFlow = _localVideoTrack.asStateFlow()
+
+ // Error state exposed to UI
+ private val _errorMessage = MutableStateFlow(null)
+ val errorMessage: StateFlow = _errorMessage.asStateFlow()
+
+ // Audio/video toggle state (UI concerns, not domain state)
+ private val _isAudioMuted = MutableStateFlow(false)
+ val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow()
+ private val _isVideoEnabled = MutableStateFlow(false)
+ val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow()
+ val audioRoute: StateFlow = audioManager.audioRoute
+ val isBluetoothAvailable: StateFlow = 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) {
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt
new file mode 100644
index 000000000..96944b69e
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt
@@ -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()
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt
new file mode 100644
index 000000000..402a1bf7d
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/IceServerConfig.kt
@@ -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 = emptyList()): List {
+ 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,
+)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt
new file mode 100644
index 000000000..9981ec3c1
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/WebRtcCallSession.kt
@@ -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,
+ 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?) {}
+
+ 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?,
+ ) {
+ // 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") }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
index 567b4ac44..d47e1058c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt
@@ -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
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt
index ed5e76ac9..f59f44776 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt
@@ -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 = activeNotifications
for (notification in notifications) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt
new file mode 100644
index 000000000..841caf946
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt
new file mode 100644
index 000000000..1f47e69bc
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt
@@ -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(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(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) }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt
new file mode 100644
index 000000000..85d832c63
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt
@@ -0,0 +1,362 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.call
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material.icons.filled.CallEnd
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.MicOff
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material.icons.filled.VideocamOff
+import androidx.compose.material.icons.filled.VolumeOff
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
+
+@Composable
+private fun PreviewCallInProgress(statusText: String) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(120.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Alice",
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = statusText,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 16.sp,
+ )
+ Spacer(modifier = Modifier.height(48.dp))
+ FloatingActionButton(
+ onClick = {},
+ containerColor = Color.Red,
+ shape = CircleShape,
+ modifier = Modifier.size(64.dp),
+ ) {
+ Icon(
+ Icons.Default.CallEnd,
+ contentDescription = "Hang up",
+ tint = Color.White,
+ modifier = Modifier.size(32.dp),
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360, heightDp = 640)
+@Composable
+fun PreviewCallingScreen() {
+ ThemeComparisonColumn {
+ PreviewCallInProgress("Calling...")
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360, heightDp = 640)
+@Composable
+fun PreviewConnectingScreen() {
+ ThemeComparisonColumn {
+ PreviewCallInProgress("Connecting...")
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360, heightDp = 640)
+@Composable
+fun PreviewCallEndedScreen() {
+ ThemeComparisonColumn {
+ PreviewCallInProgress("Call ended")
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360, heightDp = 640)
+@Composable
+fun PreviewIncomingCallScreen() {
+ ThemeComparisonColumn {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(120.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Bob",
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Incoming voice call...",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 16.sp,
+ )
+ Spacer(modifier = Modifier.height(48.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(48.dp),
+ ) {
+ FloatingActionButton(
+ onClick = {},
+ containerColor = Color.Red,
+ shape = CircleShape,
+ modifier = Modifier.size(64.dp),
+ ) {
+ Icon(
+ Icons.Default.CallEnd,
+ contentDescription = "Reject",
+ tint = Color.White,
+ modifier = Modifier.size(32.dp),
+ )
+ }
+ FloatingActionButton(
+ onClick = {},
+ containerColor = Color(0xFF4CAF50),
+ shape = CircleShape,
+ modifier = Modifier.size(64.dp),
+ ) {
+ Icon(
+ Icons.Default.Call,
+ contentDescription = "Accept",
+ tint = Color.White,
+ modifier = Modifier.size(32.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360, heightDp = 640)
+@Composable
+fun PreviewConnectedCallScreen() {
+ ThemeComparisonColumn {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(120.dp),
+ tint = Color.White.copy(alpha = 0.5f),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Alice",
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ color = Color.White,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "02:45",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 16.sp,
+ )
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 48.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ IconButton(
+ onClick = {},
+ modifier = Modifier.size(56.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Mic,
+ contentDescription = "Mute",
+ tint = Color.White,
+ modifier = Modifier.size(28.dp),
+ )
+ }
+ IconButton(
+ onClick = {},
+ modifier = Modifier.size(56.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.VideocamOff,
+ contentDescription = "Camera off",
+ tint = Color.Red,
+ modifier = Modifier.size(28.dp),
+ )
+ }
+ IconButton(
+ onClick = {},
+ modifier = Modifier.size(56.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.VolumeOff,
+ contentDescription = "Speaker",
+ tint = Color.White,
+ modifier = Modifier.size(28.dp),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ FloatingActionButton(
+ onClick = {},
+ containerColor = Color.Red,
+ shape = CircleShape,
+ modifier = Modifier.size(64.dp),
+ ) {
+ Icon(
+ Icons.Default.CallEnd,
+ contentDescription = "Hang up",
+ tint = Color.White,
+ modifier = Modifier.size(32.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true, widthDp = 360, heightDp = 640)
+@Composable
+fun PreviewConnectedCallMuted() {
+ ThemeComparisonColumn {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(Color.Black),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(120.dp),
+ tint = Color.White.copy(alpha = 0.5f),
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White)
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("05:12", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp)
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 48.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ IconButton(onClick = {}, modifier = Modifier.size(56.dp)) {
+ Icon(Icons.Default.MicOff, "Unmute", tint = Color.Red, modifier = Modifier.size(28.dp))
+ }
+ IconButton(onClick = {}, modifier = Modifier.size(56.dp)) {
+ Icon(Icons.Default.Videocam, "Camera on", tint = Color.White, modifier = Modifier.size(28.dp))
+ }
+ IconButton(onClick = {}, modifier = Modifier.size(56.dp)) {
+ Icon(Icons.Default.VolumeUp, "Earpiece", tint = Color.Cyan, modifier = Modifier.size(28.dp))
+ }
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ FloatingActionButton(
+ onClick = {},
+ containerColor = Color.Red,
+ shape = CircleShape,
+ modifier = Modifier.size(64.dp),
+ ) {
+ Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp))
+ }
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index 6c46a57f4..aafcb4a21 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -28,6 +28,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -39,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 { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
+ composableFromEndArgs {
+ CallScreen(
+ callManager = accountViewModel.callManager,
+ callController = accountViewModel.callController,
+ accountViewModel = accountViewModel,
+ onCallEnded = { nav.popBack() },
+ )
+ }
+
composableFromEndArgs {
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
index 3239b3aa5..16883691d 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt
@@ -302,6 +302,11 @@ sealed class Route {
val id: String,
) : Route()
+ @Serializable data class ActiveCall(
+ val callId: String,
+ val peerPubKey: HexKey,
+ ) : Route()
+
@Serializable data class EventRedirect(
val id: String,
) : Route()
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
index 1c01f7a6f..70818f512 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt
index a569b5ac9..8b6c15a4f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn
+import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -36,6 +37,12 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -52,6 +59,8 @@ class EventProcessor(
private val zapRequest = LnZapRequestEventHandler(account.privateZapsDecryptionCache)
private val zapEvent = LnZapEventHandler(account.privateZapsDecryptionCache)
+ var callManager: CallManager? = null
+
suspend fun consume(note: Note) {
note.event?.let { event ->
try {
@@ -68,10 +77,22 @@ class EventProcessor(
publicNote: Note,
) {
when (event) {
+ is CallOfferEvent,
+ is CallAnswerEvent,
+ is CallIceCandidateEvent,
+ is CallHangupEvent,
+ is CallRejectEvent,
+ is CallRenegotiateEvent,
+ -> callManager?.onSignalingEvent(event)
+
is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
+
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
+
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
+
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
+
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt
index c1d324abd..221519c7f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt
@@ -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,
) {
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt
index ed24d5ccc..09085a2d0 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/ChatroomHeader.kt
@@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Call
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -46,6 +52,7 @@ fun ChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
+ onCallClick: ((String) -> Unit)? = null,
onClick: () -> Unit,
) {
if (room.users.size == 1) {
@@ -56,6 +63,7 @@ fun ChatroomHeader(
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClick,
+ onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } },
)
}
}
@@ -75,6 +83,7 @@ fun UserChatroomHeader(
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
+ onCallClick: (() -> Unit)? = null,
) {
Column(
Modifier
@@ -91,9 +100,28 @@ fun UserChatroomHeader(
size = Size34dp,
)
- Column(modifier = Modifier.padding(start = 10.dp)) {
+ Column(
+ modifier =
+ Modifier
+ .padding(start = 10.dp)
+ .weight(1f),
+ ) {
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
}
+
+ if (onCallClick != null) {
+ IconButton(
+ onClick = onCallClick,
+ modifier = Modifier.size(40.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Call,
+ contentDescription = "Voice call",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
}
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt
index 33513c931..d97d7ee4c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt
@@ -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),
+ )
+ }
+ }
}
}
},
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index c2fe9959a..b2c1dba13 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -771,6 +771,35 @@
%1$s moved — your turn
Chess updates
+
+ Incoming calls
+ Notifications for incoming voice and video calls
+ Incoming call
+ Incoming voice call\u2026
+ Incoming video call\u2026
+ Calling\u2026
+ Connecting\u2026
+ Call ended
+ Call with %1$s
+ Calls
+ Ongoing call notification
+ Hang up
+ Accept
+ Reject
+ Dismiss
+ Mute
+ Unmute
+ Camera on
+ Camera off
+ Speaker
+ Earpiece
+ Bluetooth
+ Voice call
+ Video call
+ Failed to start call
+ Failed to accept call
+ Failed to create call session
+
Notify:
Join Conversation
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt
new file mode 100644
index 000000000..760fb1f29
--- /dev/null
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/call/IceCandidateSerializationTest.kt
@@ -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
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt
new file mode 100644
index 000000000..de2cddd8d
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt
@@ -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.Idle)
+ val state: StateFlow = _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()
+
+ 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
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt
new file mode 100644
index 000000000..79334a527
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt
@@ -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,
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index cc6e06459..60485652c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -48,6 +48,7 @@ secp256k1KmpJniAndroid = "0.23.0"
securityCryptoKtx = "1.1.0"
slf4j = "2.0.17"
spotless = "8.4.0"
+streamWebrtcAndroid = "1.3.8"
tarsosdsp = "2.5"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
@@ -157,6 +158,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines",
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" }
+stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" }
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md
new file mode 100644
index 000000000..92fffd552
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md
@@ -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": "",
+ "created_at": 1234567890,
+ "content": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n...",
+ "tags": [
+ ["p", ""],
+ ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
+ ["call-type", "video"],
+ ["expiration", "1234567910"],
+ ["alt", "WebRTC call offer"]
+ ],
+ "id": "",
+ "sig": ""
+}
+```
+
+### Call Answer (kind 25051)
+
+The `content` field contains the SDP answer string.
+
+```json
+{
+ "kind": 25051,
+ "pubkey": "",
+ "created_at": 1234567895,
+ "content": "v=0\r\no=- 4611731400430051337 2 IN IP4 127.0.0.1\r\n...",
+ "tags": [
+ ["p", ""],
+ ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
+ ["expiration", "1234567915"],
+ ["alt", "WebRTC call answer"]
+ ],
+ "id": "",
+ "sig": ""
+}
+```
+
+### ICE Candidate (kind 25052)
+
+The `content` field contains the ICE candidate as a JSON string with the fields `candidate`, `sdpMid`, and `sdpMLineIndex`. Special characters in the SDP string MUST be properly JSON-escaped.
+
+```json
+{
+ "kind": 25052,
+ "pubkey": "",
+ "created_at": 1234567896,
+ "content": "{\"candidate\":\"candidate:842163049 1 udp 1677729535 203.0.113.1 44323 typ srflx raddr 0.0.0.0 rport 0 generation 0\",\"sdpMid\":\"0\",\"sdpMLineIndex\":0}",
+ "tags": [
+ ["p", ""],
+ ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
+ ["expiration", "1234567916"],
+ ["alt", "WebRTC ICE candidate"]
+ ],
+ "id": "",
+ "sig": ""
+}
+```
+
+### Call Hangup (kind 25053)
+
+The `content` field MAY contain a human-readable reason or be empty.
+
+```json
+{
+ "kind": 25053,
+ "pubkey": "",
+ "created_at": 1234568000,
+ "content": "",
+ "tags": [
+ ["p", ""],
+ ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
+ ["expiration", "1234568020"],
+ ["alt", "WebRTC call hangup"]
+ ],
+ "id": "",
+ "sig": ""
+}
+```
+
+### Call Reject (kind 25054)
+
+The `content` field MAY contain a reason or be empty.
+
+```json
+{
+ "kind": 25054,
+ "pubkey": "",
+ "created_at": 1234567893,
+ "content": "",
+ "tags": [
+ ["p", ""],
+ ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
+ ["expiration", "1234567913"],
+ ["alt", "WebRTC call rejection"]
+ ],
+ "id": "",
+ "sig": ""
+}
+```
+
+### Call Renegotiate (kind 25055)
+
+Used for mid-call changes such as toggling video on/off. The `content` field contains a new SDP offer.
+
+```json
+{
+ "kind": 25055,
+ "pubkey": "",
+ "created_at": 1234568100,
+ "content": "v=0\r\no=- 4611731400430051338 3 IN IP4 127.0.0.1\r\n...",
+ "tags": [
+ ["p", ""],
+ ["call-id", "550e8400-e29b-41d4-a716-446655440000"],
+ ["expiration", "1234568120"],
+ ["alt", "WebRTC call renegotiation"]
+ ],
+ "id": "",
+ "sig": ""
+}
+```
+
+## Encryption and Delivery
+
+All signaling events MUST be delivered using [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) Gift Wraps:
+
+1. **Sign** the signaling event with the sender's key
+2. **Gift-wrap** the signed event directly using `GiftWrapEvent` (kind 1059) with NIP-44 encryption
+3. **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
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt
new file mode 100644
index 000000000..34e02aa5e
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt
@@ -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)
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt
new file mode 100644
index 000000000..e36619b45
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.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>,
+ 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.() -> Unit = {},
+ ) = eventTemplate(KIND, sdpAnswer, createdAt) {
+ alt(ALT_DESCRIPTION)
+ pTag(callerPubKey)
+ callId(callId)
+ expiration(createdAt + EXPIRATION_SECONDS)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt
new file mode 100644
index 000000000..3251cbc5f
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.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>,
+ 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.() -> Unit = {},
+ ) = eventTemplate(KIND, reason, createdAt) {
+ alt(ALT_DESCRIPTION)
+ pTag(peerPubKey)
+ callId(callId)
+ expiration(createdAt + EXPIRATION_SECONDS)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt
new file mode 100644
index 000000000..1ef209b36
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallIceCandidateEvent.kt
@@ -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>,
+ 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.() -> Unit = {},
+ ) = eventTemplate(KIND, candidateJson, createdAt) {
+ alt(ALT_DESCRIPTION)
+ pTag(peerPubKey)
+ callId(callId)
+ expiration(createdAt + EXPIRATION_SECONDS)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt
new file mode 100644
index 000000000..71a4d94fb
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallOfferEvent.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.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>,
+ 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.() -> Unit = {},
+ ) = eventTemplate(KIND, sdpOffer, createdAt) {
+ alt(ALT_DESCRIPTION)
+ pTag(calleePubKey)
+ callId(callId)
+ callType(type)
+ expiration(createdAt + EXPIRATION_SECONDS)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt
new file mode 100644
index 000000000..f4e4a62cd
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.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>,
+ 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.() -> Unit = {},
+ ) = eventTemplate(KIND, reason, createdAt) {
+ alt(ALT_DESCRIPTION)
+ pTag(callerPubKey)
+ callId(callId)
+ expiration(createdAt + EXPIRATION_SECONDS)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt
new file mode 100644
index 000000000..d4df637ab
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.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>,
+ 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.() -> Unit = {},
+ ) = eventTemplate(KIND, sdpOffer, createdAt) {
+ alt(ALT_DESCRIPTION)
+ pTag(peerPubKey)
+ callId(callId)
+ expiration(createdAt + EXPIRATION_SECONDS)
+ initializer()
+ }
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt
new file mode 100644
index 000000000..371913dfd
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallIdTag.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.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? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ ensure(tag[1].isNotEmpty()) { return null }
+ return tag[1]
+ }
+
+ fun assemble(callId: String) = arrayOf(TAG_NAME, callId)
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt
new file mode 100644
index 000000000..0dc18bdf0
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/CallTypeTag.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.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): CallType? {
+ ensure(tag.has(1)) { return null }
+ ensure(tag[0] == TAG_NAME) { return null }
+ return CallType.fromString(tag[1])
+ }
+
+ fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value)
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt
new file mode 100644
index 000000000..f58ba6177
--- /dev/null
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/tags/TagArrayBuilderExt.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nipACWebRtcCalls.tags
+
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
+
+fun TagArrayBuilder.callId(callId: String) = addUnique(CallIdTag.assemble(callId))
+
+fun TagArrayBuilder.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType))
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
index 507364ad6..b792d6f45 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
@@ -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)
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt
new file mode 100644
index 000000000..ca69124e1
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nipACWebRtcCalls
+
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
+import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class CallEventsTest {
+ @Test
+ fun callOfferEventHasCorrectKind() {
+ assertEquals(25050, CallOfferEvent.KIND)
+ }
+
+ @Test
+ fun callAnswerEventHasCorrectKind() {
+ assertEquals(25051, CallAnswerEvent.KIND)
+ }
+
+ @Test
+ fun callIceCandidateEventHasCorrectKind() {
+ assertEquals(25052, CallIceCandidateEvent.KIND)
+ }
+
+ @Test
+ fun callHangupEventHasCorrectKind() {
+ assertEquals(25053, CallHangupEvent.KIND)
+ }
+
+ @Test
+ fun callRejectEventHasCorrectKind() {
+ assertEquals(25054, CallRejectEvent.KIND)
+ }
+
+ @Test
+ fun callRenegotiateEventHasCorrectKind() {
+ assertEquals(25055, CallRenegotiateEvent.KIND)
+ }
+
+ @Test
+ fun callOfferBuildIncludesCallIdTag() {
+ val template =
+ CallOfferEvent.build(
+ sdpOffer = "v=0\r\n...",
+ calleePubKey = "abc123",
+ callId = "test-call-id",
+ type = CallType.VOICE,
+ )
+ assertEquals(CallOfferEvent.KIND, template.kind)
+ assertEquals("v=0\r\n...", template.content)
+
+ val callIdTag = template.tags.firstOrNull { it[0] == "call-id" }
+ assertEquals("test-call-id", callIdTag?.get(1))
+ }
+
+ @Test
+ fun callOfferBuildIncludesCallTypeTag() {
+ val template =
+ CallOfferEvent.build(
+ sdpOffer = "sdp",
+ calleePubKey = "abc123",
+ callId = "id",
+ type = CallType.VIDEO,
+ )
+ val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
+ assertEquals("video", callTypeTag?.get(1))
+ }
+
+ @Test
+ fun callOfferBuildIncludesPTag() {
+ val template =
+ CallOfferEvent.build(
+ sdpOffer = "sdp",
+ calleePubKey = "recipient-hex-key",
+ callId = "id",
+ type = CallType.VOICE,
+ )
+ val pTag = template.tags.firstOrNull { it[0] == "p" }
+ assertEquals("recipient-hex-key", pTag?.get(1))
+ }
+
+ @Test
+ fun callOfferBuildIncludesExpirationTag() {
+ val template =
+ CallOfferEvent.build(
+ sdpOffer = "sdp",
+ calleePubKey = "abc",
+ callId = "id",
+ type = CallType.VOICE,
+ createdAt = 1000L,
+ )
+ val expirationTag = template.tags.firstOrNull { it[0] == "expiration" }
+ assertEquals((1000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1))
+ }
+
+ @Test
+ fun callOfferExpirationIs20Seconds() {
+ assertEquals(20L, CallOfferEvent.EXPIRATION_SECONDS)
+ }
+
+ @Test
+ fun callAnswerBuildContainsSdp() {
+ val template =
+ CallAnswerEvent.build(
+ sdpAnswer = "answer-sdp",
+ callerPubKey = "caller",
+ callId = "call-1",
+ )
+ assertEquals("answer-sdp", template.content)
+ assertEquals(CallAnswerEvent.KIND, template.kind)
+ }
+
+ @Test
+ fun callIceCandidateBuildContainsJson() {
+ val candidateJson = """{"candidate":"candidate:1","sdpMid":"0","sdpMLineIndex":0}"""
+ val template =
+ CallIceCandidateEvent.build(
+ candidateJson = candidateJson,
+ peerPubKey = "peer",
+ callId = "call-1",
+ )
+ assertEquals(candidateJson, template.content)
+ }
+
+ @Test
+ fun callHangupBuildAllowsEmptyReason() {
+ val template =
+ CallHangupEvent.build(
+ peerPubKey = "peer",
+ callId = "call-1",
+ )
+ assertEquals("", template.content)
+ }
+
+ @Test
+ fun callHangupBuildAllowsCustomReason() {
+ val template =
+ CallHangupEvent.build(
+ peerPubKey = "peer",
+ callId = "call-1",
+ reason = "busy",
+ )
+ assertEquals("busy", template.content)
+ }
+
+ @Test
+ fun callRejectBuildHasCorrectKind() {
+ val template =
+ CallRejectEvent.build(
+ callerPubKey = "caller",
+ callId = "call-1",
+ )
+ assertEquals(CallRejectEvent.KIND, template.kind)
+ }
+
+ @Test
+ fun callRenegotiateBuildContainsSdp() {
+ val template =
+ CallRenegotiateEvent.build(
+ sdpOffer = "new-sdp-offer",
+ peerPubKey = "peer",
+ callId = "call-1",
+ )
+ assertEquals("new-sdp-offer", template.content)
+ assertEquals(CallRenegotiateEvent.KIND, template.kind)
+ }
+}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt
new file mode 100644
index 000000000..1d5f90109
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallTagsTest.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.quartz.nipACWebRtcCalls
+
+import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
+import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
+import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallTypeTag
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class CallTagsTest {
+ @Test
+ fun parseCallIdTag() {
+ val tag = arrayOf("call-id", "550e8400-e29b-41d4-a716-446655440000")
+ val result = CallIdTag.parse(tag)
+ assertNotNull(result)
+ assertEquals("550e8400-e29b-41d4-a716-446655440000", result)
+ }
+
+ @Test
+ fun parseCallIdTagRejectsEmpty() {
+ val tag = arrayOf("call-id", "")
+ assertNull(CallIdTag.parse(tag))
+ }
+
+ @Test
+ fun parseCallIdTagRejectsMissingValue() {
+ val tag = arrayOf("call-id")
+ assertNull(CallIdTag.parse(tag))
+ }
+
+ @Test
+ fun parseCallIdTagRejectsWrongName() {
+ val tag = arrayOf("other", "some-id")
+ assertNull(CallIdTag.parse(tag))
+ }
+
+ @Test
+ fun assembleCallIdTag() {
+ val tag = CallIdTag.assemble("test-id-123")
+ assertEquals("call-id", tag[0])
+ assertEquals("test-id-123", tag[1])
+ }
+
+ @Test
+ fun roundTripCallIdTag() {
+ val original = "550e8400-e29b-41d4-a716-446655440000"
+ val assembled = CallIdTag.assemble(original)
+ val parsed = CallIdTag.parse(assembled)
+ assertEquals(original, parsed)
+ }
+
+ @Test
+ fun parseCallTypeVoice() {
+ val tag = arrayOf("call-type", "voice")
+ val result = CallTypeTag.parse(tag)
+ assertNotNull(result)
+ assertEquals(CallType.VOICE, result)
+ }
+
+ @Test
+ fun parseCallTypeVideo() {
+ val tag = arrayOf("call-type", "video")
+ val result = CallTypeTag.parse(tag)
+ assertNotNull(result)
+ assertEquals(CallType.VIDEO, result)
+ }
+
+ @Test
+ fun parseCallTypeRejectsUnknown() {
+ val tag = arrayOf("call-type", "screenshare")
+ assertNull(CallTypeTag.parse(tag))
+ }
+
+ @Test
+ fun parseCallTypeRejectsMissing() {
+ val tag = arrayOf("call-type")
+ assertNull(CallTypeTag.parse(tag))
+ }
+
+ @Test
+ fun assembleCallTypeVoice() {
+ val tag = CallTypeTag.assemble(CallType.VOICE)
+ assertEquals("call-type", tag[0])
+ assertEquals("voice", tag[1])
+ }
+
+ @Test
+ fun assembleCallTypeVideo() {
+ val tag = CallTypeTag.assemble(CallType.VIDEO)
+ assertEquals("call-type", tag[0])
+ assertEquals("video", tag[1])
+ }
+
+ @Test
+ fun roundTripCallType() {
+ for (type in CallType.entries) {
+ val assembled = CallTypeTag.assemble(type)
+ val parsed = CallTypeTag.parse(assembled)
+ assertEquals(type, parsed)
+ }
+ }
+
+ @Test
+ fun callTypeFromString() {
+ assertEquals(CallType.VOICE, CallType.fromString("voice"))
+ assertEquals(CallType.VIDEO, CallType.fromString("video"))
+ assertNull(CallType.fromString("audio"))
+ assertNull(CallType.fromString(""))
+ }
+}