feat: add WebRTC voice/video call infrastructure

Implements P2P calling over Nostr relays using WebRTC for media
transport and NIP-59 Gift Wraps for encrypted signaling. No custom
server required — only public STUN servers for NAT traversal.

Protocol layer (quartz/nip100WebRtcCalls):
- 6 new event kinds (25050-25055): offer, answer, ICE candidate,
  hangup, reject, renegotiate
- WebRtcCallFactory for creating and gift-wrapping signaling events
- CallIdTag and CallTypeTag for event metadata
- Events registered in EventFactory

Call state machine (commons/call):
- CallState sealed interface with full lifecycle states
- CallManager orchestrating signaling and state transitions
- Follow-gate spam prevention: only followed users can ring,
  non-follows are silently ignored

Android WebRTC integration (amethyst/service/call):
- WebRtcCallSession wrapping Google WebRTC PeerConnection
- CallForegroundService for keeping calls alive in background
- IceServerConfig with default public STUN servers
- User-configurable TURN server support

Android UI (amethyst/ui/call):
- CallScreen with offering, connecting, connected, and ended states
- IncomingCallUI with accept/reject buttons
- ConnectedCallUI with mute, video toggle, speaker, and timer
- Call button added to 1-on-1 DM chat header
- ActiveCall route added to navigation

https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS
This commit is contained in:
Claude
2026-04-01 21:09:12 +00:00
parent 0f7bcdba38
commit 2ee48da064
22 changed files with 1802 additions and 1 deletions
+3
View File
@@ -363,6 +363,9 @@ dependencies {
// Voice anonymization DSP
implementation libs.tarsosdsp
// WebRTC for voice/video calls
implementation libs.stream.webrtc.android
// Cbor for cashuB format
implementation libs.kotlinx.serialization.cbor
+7
View File
@@ -39,6 +39,7 @@
<!-- Audio/Video Playback -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -221,6 +222,12 @@
</intent-filter>
</service>
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="phoneCall"
android:stopWithTask="true"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.vitorpamplona.amethyst.R
class CallForegroundService : Service() {
companion object {
const val CHANNEL_ID = "amethyst_call_channel"
const val NOTIFICATION_ID = 9001
const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START"
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val EXTRA_PEER_NAME = "peer_name"
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
when (intent?.action) {
ACTION_START -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val notification = buildNotification(peerName)
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
} else {
0
},
)
}
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_NOT_STICKY
}
private fun createNotificationChannel() {
val channel =
NotificationChannel(
CHANNEL_ID,
"Calls",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Ongoing call notification"
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
private fun buildNotification(peerName: String): Notification =
NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText("Call with $peerName")
.setSmallIcon(R.drawable.amethyst)
.setOngoing(true)
.build()
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import org.webrtc.PeerConnection
object IceServerConfig {
val defaultStunServers =
listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("stun:stun1.l.google.com:19302").createIceServer(),
PeerConnection.IceServer.builder("stun:stun.cloudflare.com:3478").createIceServer(),
)
fun buildIceServers(userTurnServers: List<TurnServerConfig> = emptyList()): List<PeerConnection.IceServer> {
val servers = defaultStunServers.toMutableList()
userTurnServers.forEach { turn ->
servers.add(
PeerConnection.IceServer
.builder(turn.url)
.setUsername(turn.username)
.setPassword(turn.credential)
.createIceServer(),
)
}
return servers
}
}
data class TurnServerConfig(
val url: String,
val username: String,
val credential: String,
)
@@ -0,0 +1,260 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
import android.content.Context
import com.vitorpamplona.quartz.utils.Log
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.DataChannel
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpReceiver
import org.webrtc.SdpObserver
import org.webrtc.SessionDescription
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
private const val TAG = "WebRtcCallSession"
class WebRtcCallSession(
private val context: Context,
private val iceServers: List<PeerConnection.IceServer>,
private val onIceCandidate: (IceCandidate) -> Unit,
private val onPeerConnected: () -> Unit,
private val onRemoteStream: (MediaStream) -> Unit,
private val onDisconnected: () -> Unit,
) {
private var peerConnectionFactory: PeerConnectionFactory? = null
private var peerConnection: PeerConnection? = null
private var localAudioTrack: AudioTrack? = null
private var localVideoTrack: VideoTrack? = null
private var audioSource: AudioSource? = null
private var videoSource: VideoSource? = null
val eglBase: EglBase = EglBase.create()
fun initialize() {
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
.createPeerConnectionFactory()
}
fun createPeerConnection() {
val rtcConfig =
PeerConnection.RTCConfiguration(iceServers).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
}
peerConnection =
peerConnectionFactory?.createPeerConnection(
rtcConfig,
object : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate?) {
candidate?.let { onIceCandidate(it) }
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) {}
override fun onSignalingChange(state: PeerConnection.SignalingState?) {}
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
Log.d(TAG) { "ICE connection state: $state" }
when (state) {
PeerConnection.IceConnectionState.CONNECTED -> {
onPeerConnected()
}
PeerConnection.IceConnectionState.DISCONNECTED,
PeerConnection.IceConnectionState.FAILED,
-> {
onDisconnected()
}
else -> {}
}
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
override fun onAddStream(stream: MediaStream?) {
stream?.let { onRemoteStream(it) }
}
override fun onRemoveStream(stream: MediaStream?) {}
override fun onDataChannel(channel: DataChannel?) {}
override fun onRenegotiationNeeded() {}
override fun onAddTrack(
receiver: RtpReceiver?,
streams: Array<out MediaStream>?,
) {}
},
)
}
fun addAudioTrack() {
val constraints = MediaConstraints()
audioSource = peerConnectionFactory?.createAudioSource(constraints)
localAudioTrack =
peerConnectionFactory?.createAudioTrack("audio0", audioSource).also {
peerConnection?.addTrack(it)
}
}
fun addVideoTrack() {
videoSource = peerConnectionFactory?.createVideoSource(false)
localVideoTrack =
peerConnectionFactory?.createVideoTrack("video0", videoSource).also {
peerConnection?.addTrack(it)
}
}
fun getLocalVideoSource(): VideoSource? = videoSource
fun getLocalVideoTrack(): VideoTrack? = localVideoTrack
fun createOffer(onSdpCreated: (SessionDescription) -> Unit) {
val constraints =
MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
peerConnection?.createOffer(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let {
peerConnection?.setLocalDescription(noOpSdpObserver(), it)
onSdpCreated(it)
}
}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "Create offer failed: $error")
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
},
constraints,
)
}
fun createAnswer(onSdpCreated: (SessionDescription) -> Unit) {
val constraints =
MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
peerConnection?.createAnswer(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let {
peerConnection?.setLocalDescription(noOpSdpObserver(), it)
onSdpCreated(it)
}
}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "Create answer failed: $error")
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {}
},
constraints,
)
}
fun setRemoteDescription(sdp: SessionDescription) {
peerConnection?.setRemoteDescription(noOpSdpObserver(), sdp)
}
fun addIceCandidate(candidate: IceCandidate) {
peerConnection?.addIceCandidate(candidate)
}
fun setAudioEnabled(enabled: Boolean) {
localAudioTrack?.setEnabled(enabled)
}
fun setVideoEnabled(enabled: Boolean) {
localVideoTrack?.setEnabled(enabled)
}
fun dispose() {
localAudioTrack?.dispose()
localVideoTrack?.dispose()
audioSource?.dispose()
videoSource?.dispose()
peerConnection?.close()
peerConnection?.dispose()
peerConnectionFactory?.dispose()
eglBase.release()
localAudioTrack = null
localVideoTrack = null
audioSource = null
videoSource = null
peerConnection = null
peerConnectionFactory = null
}
private fun noOpSdpObserver() =
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {}
override fun onCreateFailure(error: String?) {
Log.e(TAG, "SDP operation failed: $error")
}
override fun onSetSuccess() {}
override fun onSetFailure(error: String?) {
Log.e(TAG, "SDP set failed: $error")
}
}
}
@@ -0,0 +1,354 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.call
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun CallScreen(
callManager: CallManager,
accountViewModel: AccountViewModel,
onCallEnded: () -> Unit,
) {
val callState by callManager.state.collectAsState()
val scope = rememberCoroutineScope()
when (val state = callState) {
is CallState.Idle -> {
LaunchedEffect(Unit) { onCallEnded() }
}
is CallState.Offering -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = "Calling...",
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
}
is CallState.IncomingCall -> {
IncomingCallUI(
callerPubKey = state.callerPubKey,
callType = state.callType,
accountViewModel = accountViewModel,
onAccept = { /* handled by caller */ },
onReject = { scope.launch { callManager.rejectCall() } },
)
}
is CallState.Connecting -> {
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = "Connecting...",
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
)
}
is CallState.Connected -> {
ConnectedCallUI(
state = state,
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { callManager.toggleAudioMute() },
onToggleVideo = { callManager.toggleVideo() },
onToggleSpeaker = { callManager.toggleSpeaker() },
)
}
is CallState.Ended -> {
LaunchedEffect(Unit) {
delay(2000)
callManager.reset()
onCallEnded()
}
CallInProgressUI(
peerPubKey = state.peerPubKey,
statusText = "Call ended",
accountViewModel = accountViewModel,
onHangup = { onCallEnded() },
)
}
}
}
@Composable
private fun CallInProgressUI(
peerPubKey: String,
statusText: String,
accountViewModel: AccountViewModel,
onHangup: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp))
FloatingActionButton(
onClick = onHangup,
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Hang up",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
@Composable
private fun IncomingCallUI(
callerPubKey: String,
callType: com.vitorpamplona.quartz.nip100WebRtcCalls.tags.CallType,
accountViewModel: AccountViewModel,
onAccept: () -> Unit,
onReject: () -> Unit,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Incoming ${callType.value} call...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(48.dp),
) {
FloatingActionButton(
onClick = onReject,
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Reject",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
FloatingActionButton(
onClick = onAccept,
containerColor = Color(0xFF4CAF50),
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.Call,
contentDescription = "Accept",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
}
@Composable
private fun ConnectedCallUI(
state: CallState.Connected,
accountViewModel: AccountViewModel,
onHangup: () -> Unit,
onToggleMute: () -> Unit,
onToggleVideo: () -> Unit,
onToggleSpeaker: () -> Unit,
) {
var elapsed by remember { mutableLongStateOf(0L) }
LaunchedEffect(state.startedAtEpoch) {
while (true) {
elapsed = TimeUtils.now() - state.startedAtEpoch
delay(1000)
}
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = formatDuration(elapsed),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(onClick = onToggleMute) {
Text(if (state.isAudioMuted) "Unmute" else "Mute")
}
IconButton(onClick = onToggleVideo) {
Text(if (state.isVideoEnabled) "Cam Off" else "Cam On")
}
IconButton(onClick = onToggleSpeaker) {
Text(if (state.isSpeakerOn) "Earpiece" else "Speaker")
}
}
Spacer(modifier = Modifier.height(24.dp))
FloatingActionButton(
onClick = onHangup,
containerColor = Color.Red,
shape = CircleShape,
modifier = Modifier.size(64.dp),
) {
Icon(
Icons.Default.CallEnd,
contentDescription = "Hang up",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
}
}
}
}
private fun formatDuration(seconds: Long): String {
val mins = seconds / 60
val secs = seconds % 60
return "%02d:%02d".format(mins, secs)
}
@@ -302,6 +302,11 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ActiveCall(
val callId: String,
val peerPubKey: HexKey,
) : Route()
@Serializable data class EventRedirect(
val id: String,
) : Route()
@@ -26,6 +26,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -46,6 +52,7 @@ fun ChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onCallClick: ((String) -> Unit)? = null,
onClick: () -> Unit,
) {
if (room.users.size == 1) {
@@ -56,6 +63,7 @@ fun ChatroomHeader(
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClick,
onCallClick = onCallClick?.let { callback -> { callback(baseUser.pubkeyHex) } },
)
}
}
@@ -75,6 +83,7 @@ fun UserChatroomHeader(
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
onCallClick: (() -> Unit)? = null,
) {
Column(
Modifier
@@ -91,9 +100,28 @@ fun UserChatroomHeader(
size = Size34dp,
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Column(
modifier =
Modifier
.padding(start = 10.dp)
.weight(1f),
) {
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
}
if (onCallClick != null) {
IconButton(
onClick = onCallClick,
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Voice call",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
}
}
}