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