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("")) + } +}