feat: add WebRTC voice/video call infrastructure
Implements P2P calling over Nostr relays using WebRTC for media transport and NIP-59 Gift Wraps for encrypted signaling. No custom server required — only public STUN servers for NAT traversal. Protocol layer (quartz/nip100WebRtcCalls): - 6 new event kinds (25050-25055): offer, answer, ICE candidate, hangup, reject, renegotiate - WebRtcCallFactory for creating and gift-wrapping signaling events - CallIdTag and CallTypeTag for event metadata - Events registered in EventFactory Call state machine (commons/call): - CallState sealed interface with full lifecycle states - CallManager orchestrating signaling and state transitions - Follow-gate spam prevention: only followed users can ring, non-follows are silently ignored Android WebRTC integration (amethyst/service/call): - WebRtcCallSession wrapping Google WebRTC PeerConnection - CallForegroundService for keeping calls alive in background - IceServerConfig with default public STUN servers - User-configurable TURN server support Android UI (amethyst/ui/call): - CallScreen with offering, connecting, connected, and ended states - IncomingCallUI with accept/reject buttons - ConnectedCallUI with mute, video toggle, speaker, and timer - Call button added to 1-on-1 DM chat header - ActiveCall route added to navigation https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
@@ -366,6 +366,9 @@ dependencies {
|
|||||||
// Voice anonymization DSP
|
// Voice anonymization DSP
|
||||||
implementation libs.tarsosdsp
|
implementation libs.tarsosdsp
|
||||||
|
|
||||||
|
// WebRTC for voice/video calls
|
||||||
|
implementation libs.stream.webrtc.android
|
||||||
|
|
||||||
// Cbor for cashuB format
|
// Cbor for cashuB format
|
||||||
implementation libs.kotlinx.serialization.cbor
|
implementation libs.kotlinx.serialization.cbor
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<!-- Audio/Video Playback -->
|
<!-- Audio/Video Playback -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||||
|
|
||||||
<!-- Keeps screen on while playing videos -->
|
<!-- Keeps screen on while playing videos -->
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
@@ -221,6 +222,12 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.call.CallForegroundService"
|
||||||
|
android:foregroundServiceType="phoneCall"
|
||||||
|
android:stopWithTask="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
|||||||
+101
@@ -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_PHONE_CALL
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Calls",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = "Ongoing call notification"
|
||||||
|
}
|
||||||
|
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("Call with $peerName")
|
||||||
|
.setSmallIcon(R.drawable.amethyst)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* 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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun buildIceServers(userTurnServers: List<TurnServerConfig> = emptyList()): List<PeerConnection.IceServer> {
|
||||||
|
val servers = defaultStunServers.toMutableList()
|
||||||
|
userTurnServers.forEach { turn ->
|
||||||
|
servers.add(
|
||||||
|
PeerConnection.IceServer
|
||||||
|
.builder(turn.url)
|
||||||
|
.setUsername(turn.username)
|
||||||
|
.setPassword(turn.credential)
|
||||||
|
.createIceServer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return servers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TurnServerConfig(
|
||||||
|
val url: String,
|
||||||
|
val username: String,
|
||||||
|
val credential: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.VideoSource
|
||||||
|
import org.webrtc.VideoTrack
|
||||||
|
|
||||||
|
private const val TAG = "WebRtcCallSession"
|
||||||
|
|
||||||
|
class WebRtcCallSession(
|
||||||
|
private val context: Context,
|
||||||
|
private val iceServers: List<PeerConnection.IceServer>,
|
||||||
|
private val onIceCandidate: (IceCandidate) -> Unit,
|
||||||
|
private val onPeerConnected: () -> Unit,
|
||||||
|
private val onRemoteStream: (MediaStream) -> Unit,
|
||||||
|
private val onDisconnected: () -> 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
|
||||||
|
|
||||||
|
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 { onIceCandidate(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) {}
|
||||||
|
|
||||||
|
override fun onSignalingChange(state: PeerConnection.SignalingState?) {}
|
||||||
|
|
||||||
|
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
|
||||||
|
Log.d(TAG) { "ICE connection state: $state" }
|
||||||
|
when (state) {
|
||||||
|
PeerConnection.IceConnectionState.CONNECTED -> {
|
||||||
|
onPeerConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
PeerConnection.IceConnectionState.DISCONNECTED,
|
||||||
|
PeerConnection.IceConnectionState.FAILED,
|
||||||
|
-> {
|
||||||
|
onDisconnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
|
||||||
|
|
||||||
|
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
|
||||||
|
|
||||||
|
override fun onAddStream(stream: MediaStream?) {
|
||||||
|
stream?.let { onRemoteStream(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoveStream(stream: MediaStream?) {}
|
||||||
|
|
||||||
|
override fun onDataChannel(channel: DataChannel?) {}
|
||||||
|
|
||||||
|
override fun onRenegotiationNeeded() {}
|
||||||
|
|
||||||
|
override fun onAddTrack(
|
||||||
|
receiver: RtpReceiver?,
|
||||||
|
streams: Array<out MediaStream>?,
|
||||||
|
) {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSetSuccess() {}
|
||||||
|
|
||||||
|
override fun onSetFailure(error: String?) {
|
||||||
|
Log.e(TAG, "SDP set failed: $error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.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.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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||||
|
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||||
|
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.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CallScreen(
|
||||||
|
callManager: CallManager,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onCallEnded: () -> Unit,
|
||||||
|
) {
|
||||||
|
val callState by callManager.state.collectAsState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
when (val state = callState) {
|
||||||
|
is CallState.Idle -> {
|
||||||
|
LaunchedEffect(Unit) { onCallEnded() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is CallState.Offering -> {
|
||||||
|
CallInProgressUI(
|
||||||
|
peerPubKey = state.peerPubKey,
|
||||||
|
statusText = "Calling...",
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onHangup = { scope.launch { callManager.hangup() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CallState.IncomingCall -> {
|
||||||
|
IncomingCallUI(
|
||||||
|
callerPubKey = state.callerPubKey,
|
||||||
|
callType = state.callType,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onAccept = { /* handled by caller */ },
|
||||||
|
onReject = { scope.launch { callManager.rejectCall() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CallState.Connecting -> {
|
||||||
|
CallInProgressUI(
|
||||||
|
peerPubKey = state.peerPubKey,
|
||||||
|
statusText = "Connecting...",
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onHangup = { scope.launch { callManager.hangup() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CallState.Connected -> {
|
||||||
|
ConnectedCallUI(
|
||||||
|
state = state,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onHangup = { scope.launch { callManager.hangup() } },
|
||||||
|
onToggleMute = { callManager.toggleAudioMute() },
|
||||||
|
onToggleVideo = { callManager.toggleVideo() },
|
||||||
|
onToggleSpeaker = { callManager.toggleSpeaker() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CallState.Ended -> {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(2000)
|
||||||
|
callManager.reset()
|
||||||
|
onCallEnded()
|
||||||
|
}
|
||||||
|
CallInProgressUI(
|
||||||
|
peerPubKey = state.peerPubKey,
|
||||||
|
statusText = "Call ended",
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onHangup = { onCallEnded() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = "Hang up",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IncomingCallUI(
|
||||||
|
callerPubKey: String,
|
||||||
|
callType: com.vitorpamplona.quartz.nip100WebRtcCalls.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 = "Incoming ${callType.value} call...",
|
||||||
|
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 = "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 = "Accept",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConnectedCallUI(
|
||||||
|
state: CallState.Connected,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onHangup: () -> Unit,
|
||||||
|
onToggleMute: () -> Unit,
|
||||||
|
onToggleVideo: () -> Unit,
|
||||||
|
onToggleSpeaker: () -> Unit,
|
||||||
|
) {
|
||||||
|
var elapsed by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
LaunchedEffect(state.startedAtEpoch) {
|
||||||
|
while (true) {
|
||||||
|
elapsed = TimeUtils.now() - state.startedAtEpoch
|
||||||
|
delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = formatDuration(elapsed),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onToggleMute) {
|
||||||
|
Text(if (state.isAudioMuted) "Unmute" else "Mute")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onToggleVideo) {
|
||||||
|
Text(if (state.isVideoEnabled) "Cam Off" else "Cam On")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onToggleSpeaker) {
|
||||||
|
Text(if (state.isSpeakerOn) "Earpiece" else "Speaker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onHangup,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDuration(seconds: Long): String {
|
||||||
|
val mins = seconds / 60
|
||||||
|
val secs = seconds % 60
|
||||||
|
return "%02d:%02d".format(mins, secs)
|
||||||
|
}
|
||||||
@@ -300,6 +300,11 @@ sealed class Route {
|
|||||||
val id: String,
|
val id: String,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable data class ActiveCall(
|
||||||
|
val callId: String,
|
||||||
|
val peerPubKey: HexKey,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable data class EventRedirect(
|
@Serializable data class EventRedirect(
|
||||||
val id: String,
|
val id: String,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|||||||
+29
-1
@@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -46,6 +52,7 @@ fun ChatroomHeader(
|
|||||||
room: ChatroomKey,
|
room: ChatroomKey,
|
||||||
modifier: Modifier = StdPadding,
|
modifier: Modifier = StdPadding,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
onCallClick: ((String) -> Unit)? = null,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
if (room.users.size == 1) {
|
if (room.users.size == 1) {
|
||||||
@@ -56,6 +63,7 @@ fun ChatroomHeader(
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +83,7 @@ fun UserChatroomHeader(
|
|||||||
modifier: Modifier = StdPadding,
|
modifier: Modifier = StdPadding,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onCallClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -91,9 +100,28 @@ fun UserChatroomHeader(
|
|||||||
size = Size34dp,
|
size = Size34dp,
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 10.dp)
|
||||||
|
.weight(1f),
|
||||||
|
) {
|
||||||
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip100WebRtcCalls.WebRtcCallFactory
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType
|
||||||
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CallManager(
|
||||||
|
private val signer: NostrSigner,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val isFollowing: (HexKey) -> Boolean,
|
||||||
|
private val publishEvent: (GiftWrapEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
private val factory = WebRtcCallFactory()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<CallState>(CallState.Idle)
|
||||||
|
val state: StateFlow<CallState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var timeoutJob: Job? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
_state.value = CallState.Ended(current.callId, current.callerPubKey, EndReason.REJECTED)
|
||||||
|
cancelTimeout()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCallRejected(event: CallRejectEvent) {
|
||||||
|
val current = _state.value
|
||||||
|
if (current !is CallState.Offering) return
|
||||||
|
if (event.callId() != current.callId) return
|
||||||
|
|
||||||
|
_state.value = CallState.Ended(current.callId, current.peerPubKey, EndReason.PEER_REJECTED)
|
||||||
|
cancelTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onIceCandidate(event: CallIceCandidateEvent) {
|
||||||
|
// ICE candidates are handled by the WebRTC session directly.
|
||||||
|
// This method exists for the call manager to validate the call-id.
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
_state.value = CallState.Ended(callId, peerPubKey, EndReason.HANGUP)
|
||||||
|
cancelTimeout()
|
||||||
|
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
|
||||||
|
_state.value = CallState.Ended(callId, peerPubKey, EndReason.PEER_HANGUP)
|
||||||
|
cancelTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSignalingEvent(event: Event) {
|
||||||
|
when (event) {
|
||||||
|
is CallOfferEvent -> onIncomingCallEvent(event)
|
||||||
|
is CallAnswerEvent -> onCallAnswered(event)
|
||||||
|
is CallRejectEvent -> onCallRejected(event)
|
||||||
|
is CallHangupEvent -> onPeerHangup(event)
|
||||||
|
is CallIceCandidateEvent -> onIceCandidate(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleAudioMute() {
|
||||||
|
val current = _state.value
|
||||||
|
if (current is CallState.Connected) {
|
||||||
|
_state.value = current.copy(isAudioMuted = !current.isAudioMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleVideo() {
|
||||||
|
val current = _state.value
|
||||||
|
if (current is CallState.Connected) {
|
||||||
|
_state.value = current.copy(isVideoEnabled = !current.isVideoEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSpeaker() {
|
||||||
|
val current = _state.value
|
||||||
|
if (current is CallState.Connected) {
|
||||||
|
_state.value = current.copy(isSpeakerOn = !current.isSpeakerOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_state.value = CallState.Idle
|
||||||
|
cancelTimeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
_state.value = CallState.Ended(callId, peerPubKey, EndReason.TIMEOUT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelTimeout() {
|
||||||
|
timeoutJob?.cancel()
|
||||||
|
timeoutJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.amethyst.commons.call
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.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,
|
||||||
|
val isAudioMuted: Boolean = false,
|
||||||
|
val isVideoEnabled: Boolean = true,
|
||||||
|
val isSpeakerOn: Boolean = false,
|
||||||
|
) : 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,
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ secp256k1KmpJniAndroid = "0.23.0"
|
|||||||
securityCryptoKtx = "1.1.0"
|
securityCryptoKtx = "1.1.0"
|
||||||
slf4j = "2.0.17"
|
slf4j = "2.0.17"
|
||||||
spotless = "8.4.0"
|
spotless = "8.4.0"
|
||||||
|
streamWebrtcAndroid = "1.3.8"
|
||||||
tarsosdsp = "2.5"
|
tarsosdsp = "2.5"
|
||||||
torAndroid = "0.4.9.5.1"
|
torAndroid = "0.4.9.5.1"
|
||||||
translate = "17.0.3"
|
translate = "17.0.3"
|
||||||
@@ -162,6 +163,7 @@ okhttpCoroutines = { group = "com.squareup.okhttp3", name = "okhttp-coroutines",
|
|||||||
secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", version.ref = "secp256k1KmpJniAndroid" }
|
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-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" }
|
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" }
|
tarsosdsp = { group = "be.tarsos.dsp", name = "core", version.ref = "tarsosdsp" }
|
||||||
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
|
tor-android = { module = "info.guardianproject:tor-android", version.ref = "torAndroid" }
|
||||||
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip100WebRtcCalls
|
||||||
|
|
||||||
|
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.nip100WebRtcCalls.events.CallAnswerEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType
|
||||||
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
|
|
||||||
|
class WebRtcCallFactory {
|
||||||
|
data class Result(
|
||||||
|
val msg: Event,
|
||||||
|
val wrap: GiftWrapEvent,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
return Result(signed, wrap)
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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.nip100WebRtcCalls.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.nip100WebRtcCalls.tags.CallIdTag
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class CallAnswerEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
|
||||||
|
|
||||||
|
fun sdpAnswer() = content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 25051
|
||||||
|
const val ALT_DESCRIPTION = "WebRTC call answer"
|
||||||
|
const val EXPIRATION_SECONDS = 300L
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
sdpAnswer: String,
|
||||||
|
callerPubKey: HexKey,
|
||||||
|
callId: String,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<CallAnswerEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, sdpAnswer, createdAt) {
|
||||||
|
alt(ALT_DESCRIPTION)
|
||||||
|
pTag(callerPubKey)
|
||||||
|
callId(callId)
|
||||||
|
expiration(createdAt + EXPIRATION_SECONDS)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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.nip100WebRtcCalls.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.nip100WebRtcCalls.tags.CallIdTag
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class CallHangupEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
|
||||||
|
|
||||||
|
fun reason() = content.ifEmpty { null }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 25053
|
||||||
|
const val ALT_DESCRIPTION = "WebRTC call hangup"
|
||||||
|
const val EXPIRATION_SECONDS = 300L
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
peerPubKey: HexKey,
|
||||||
|
callId: String,
|
||||||
|
reason: String = "",
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<CallHangupEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, reason, createdAt) {
|
||||||
|
alt(ALT_DESCRIPTION)
|
||||||
|
pTag(peerPubKey)
|
||||||
|
callId(callId)
|
||||||
|
expiration(createdAt + EXPIRATION_SECONDS)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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.nip100WebRtcCalls.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.nip100WebRtcCalls.tags.CallIdTag
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class CallIceCandidateEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
|
||||||
|
|
||||||
|
fun candidateJson() = content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 25052
|
||||||
|
const val ALT_DESCRIPTION = "WebRTC ICE candidate"
|
||||||
|
const val EXPIRATION_SECONDS = 300L
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
candidateJson: String,
|
||||||
|
peerPubKey: HexKey,
|
||||||
|
callId: String,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<CallIceCandidateEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, candidateJson, createdAt) {
|
||||||
|
alt(ALT_DESCRIPTION)
|
||||||
|
pTag(peerPubKey)
|
||||||
|
callId(callId)
|
||||||
|
expiration(createdAt + EXPIRATION_SECONDS)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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.nip100WebRtcCalls.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.nip100WebRtcCalls.tags.CallIdTag
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallTypeTag
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callType
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class CallOfferEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
|
||||||
|
|
||||||
|
fun callType() = tags.firstNotNullOfOrNull(CallTypeTag::parse)
|
||||||
|
|
||||||
|
fun sdpOffer() = content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 25050
|
||||||
|
const val ALT_DESCRIPTION = "WebRTC call offer"
|
||||||
|
const val EXPIRATION_SECONDS = 300L // 5 minutes
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
sdpOffer: String,
|
||||||
|
calleePubKey: HexKey,
|
||||||
|
callId: String,
|
||||||
|
type: CallType,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<CallOfferEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, sdpOffer, createdAt) {
|
||||||
|
alt(ALT_DESCRIPTION)
|
||||||
|
pTag(calleePubKey)
|
||||||
|
callId(callId)
|
||||||
|
callType(type)
|
||||||
|
expiration(createdAt + EXPIRATION_SECONDS)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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.nip100WebRtcCalls.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.nip100WebRtcCalls.tags.CallIdTag
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class CallRejectEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
|
||||||
|
|
||||||
|
fun reason() = content.ifEmpty { null }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 25054
|
||||||
|
const val ALT_DESCRIPTION = "WebRTC call rejection"
|
||||||
|
const val EXPIRATION_SECONDS = 300L
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
callerPubKey: HexKey,
|
||||||
|
callId: String,
|
||||||
|
reason: String = "",
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<CallRejectEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, reason, createdAt) {
|
||||||
|
alt(ALT_DESCRIPTION)
|
||||||
|
pTag(callerPubKey)
|
||||||
|
callId(callId)
|
||||||
|
expiration(createdAt + EXPIRATION_SECONDS)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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.nip100WebRtcCalls.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.nip100WebRtcCalls.tags.CallIdTag
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.tags.callId
|
||||||
|
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
class CallRenegotiateEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
fun callId() = tags.firstNotNullOfOrNull(CallIdTag::parse)
|
||||||
|
|
||||||
|
fun sdpOffer() = content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KIND = 25055
|
||||||
|
const val ALT_DESCRIPTION = "WebRTC call renegotiation"
|
||||||
|
const val EXPIRATION_SECONDS = 300L
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
sdpOffer: String,
|
||||||
|
peerPubKey: HexKey,
|
||||||
|
callId: String,
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<CallRenegotiateEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, sdpOffer, createdAt) {
|
||||||
|
alt(ALT_DESCRIPTION)
|
||||||
|
pTag(peerPubKey)
|
||||||
|
callId(callId)
|
||||||
|
expiration(createdAt + EXPIRATION_SECONDS)
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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.nip100WebRtcCalls.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
class CallIdTag {
|
||||||
|
companion object {
|
||||||
|
const val TAG_NAME = "call-id"
|
||||||
|
|
||||||
|
fun parse(tag: Array<String>): String? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(callId: String) = arrayOf(TAG_NAME, callId)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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.nip100WebRtcCalls.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||||
|
import com.vitorpamplona.quartz.utils.ensure
|
||||||
|
|
||||||
|
enum class CallType(
|
||||||
|
val value: String,
|
||||||
|
) {
|
||||||
|
VOICE("voice"),
|
||||||
|
VIDEO("video"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): CallType? =
|
||||||
|
when (value) {
|
||||||
|
"voice" -> VOICE
|
||||||
|
"video" -> VIDEO
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallTypeTag {
|
||||||
|
companion object {
|
||||||
|
const val TAG_NAME = "call-type"
|
||||||
|
|
||||||
|
fun parse(tag: Array<String>): CallType? {
|
||||||
|
ensure(tag.has(1)) { return null }
|
||||||
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
|
return CallType.fromString(tag[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
fun assemble(callType: CallType) = arrayOf(TAG_NAME, callType.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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.nip100WebRtcCalls.tags
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.callId(callId: String) = addUnique(CallIdTag.assemble(callId))
|
||||||
|
|
||||||
|
fun <T : Event> TagArrayBuilder<T>.callType(callType: CallType) = addUnique(CallTypeTag.assemble(callType))
|
||||||
@@ -50,6 +50,12 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
|||||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallAnswerEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallHangupEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallIceCandidateEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallOfferEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRejectEvent
|
||||||
|
import com.vitorpamplona.quartz.nip100WebRtcCalls.events.CallRenegotiateEvent
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent
|
import com.vitorpamplona.quartz.nip15Marketplace.auction.AuctionEvent
|
||||||
import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent
|
import com.vitorpamplona.quartz.nip15Marketplace.bid.BidEvent
|
||||||
@@ -295,6 +301,12 @@ class EventFactory {
|
|||||||
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
|
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(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)
|
CashuMintEvent.KIND -> CashuMintEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig)
|
CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig)
|
CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|||||||
Reference in New Issue
Block a user