Merge pull request #2168 from vitorpamplona/claude/review-call-screens-6zzLt

Add thread-safe state management to CallManager
This commit is contained in:
Vitor Pamplona
2026-04-08 08:43:11 -04:00
committed by GitHub
16 changed files with 1595 additions and 1419 deletions
+1 -1
View File
@@ -276,7 +276,7 @@
android:exported="false" /> android:exported="false" />
<receiver <receiver
android:name=".ui.call.CallNotificationReceiver" android:name=".service.call.CallNotificationReceiver"
android:exported="false" /> android:exported="false" />
</application> </application>
@@ -22,10 +22,6 @@ package com.vitorpamplona.amethyst.service.call
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import coil3.ImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.commons.call.AnswerRouteAction import com.vitorpamplona.amethyst.commons.call.AnswerRouteAction
import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.commons.call.CallState
@@ -34,11 +30,8 @@ import com.vitorpamplona.amethyst.commons.call.PeerSession
import com.vitorpamplona.amethyst.commons.call.PeerSessionManager import com.vitorpamplona.amethyst.commons.call.PeerSessionManager
import com.vitorpamplona.amethyst.commons.call.SdpType import com.vitorpamplona.amethyst.commons.call.SdpType
import com.vitorpamplona.amethyst.commons.call.SignalingState import com.vitorpamplona.amethyst.commons.call.SignalingState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
@@ -47,30 +40,15 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.Camera2Enumerator
import org.webrtc.CameraVideoCapturer
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase import org.webrtc.EglBase
import org.webrtc.IceCandidate import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnectionFactory
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
import org.webrtc.VideoSource
import org.webrtc.VideoTrack import org.webrtc.VideoTrack
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
private const val TAG = "CallController" private const val TAG = "CallController"
private const val VIDEO_MAX_BITRATE_BPS = 1_500_000 private const val VIDEO_MAX_BITRATE_BPS = 1_500_000
@@ -83,71 +61,31 @@ class CallController(
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
localPubKey: HexKey, localPubKey: HexKey,
) { ) {
// ---- Per-peer session state (delegated to PeerSessionManager) ----
private var peerSessionMgr = PeerSessionManager(localPubKey) private var peerSessionMgr = PeerSessionManager(localPubKey)
/** Retrieves the underlying WebRtcCallSession for a peer (for WebRTC-specific APIs like addTrack). */
private fun webRtcSession(peerPubKey: HexKey): WebRtcCallSession? = (peerSessionMgr.getSession(peerPubKey)?.session as? WebRtcPeerSessionAdapter)?.webRtcSession private fun webRtcSession(peerPubKey: HexKey): WebRtcCallSession? = (peerSessionMgr.getSession(peerPubKey)?.session as? WebRtcPeerSessionAdapter)?.webRtcSession
// ---- Shared WebRTC resources ---- val mediaManager = CallMediaManager(context)
private var peerConnectionFactory: PeerConnectionFactory? = null
private var sharedEglBase: EglBase? = null
private var localAudioSource: AudioSource? = null
private var localVideoSource: VideoSource? = null
private var localAudioTrackInternal: AudioTrack? = null
private var localVideoTrackInternal: VideoTrack? = null
private var cameraCapturer: CameraVideoCapturer? = null
private val callFactory = WebRtcCallFactory()
val audioManager = CallAudioManager(context) val audioManager = CallAudioManager(context)
val videoMonitor = RemoteVideoMonitor(scope)
private val callFactory = WebRtcCallFactory()
// ---- UI-exposed state ---- // ---- UI-exposed state ----
private val _localVideoTrack = MutableStateFlow<VideoTrack?>(null) val localVideoTrack: StateFlow<VideoTrack?> = mediaManager.localVideoTrackFlow
val localVideoTrack: StateFlow<VideoTrack?> = _localVideoTrack.asStateFlow() val remoteVideoTrack: StateFlow<VideoTrack?> = videoMonitor.remoteVideoTrack
val remoteVideoTracks: StateFlow<Map<HexKey, VideoTrack>> = videoMonitor.remoteVideoTracks
// Primary remote track (first connected peer) for backward-compat with P2P UI
private val _remoteVideoTrack = MutableStateFlow<VideoTrack?>(null)
val remoteVideoTrack: StateFlow<VideoTrack?> = _remoteVideoTrack.asStateFlow()
// All remote tracks keyed by peer pubkey (for group call UI)
private val _remoteVideoTracks = MutableStateFlow<Map<HexKey, VideoTrack>>(emptyMap())
val remoteVideoTracks: StateFlow<Map<HexKey, VideoTrack>> = _remoteVideoTracks.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null) private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow() val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _isRemoteVideoActive = MutableStateFlow(false) val isRemoteVideoActive: StateFlow<Boolean> = videoMonitor.isRemoteVideoActive
val isRemoteVideoActive: StateFlow<Boolean> = _isRemoteVideoActive.asStateFlow() val remoteVideoAspectRatio: StateFlow<Float?> = videoMonitor.remoteVideoAspectRatio
private val _remoteVideoAspectRatio = MutableStateFlow<Float?>(null) val activePeerVideos: StateFlow<Set<HexKey>> = videoMonitor.activePeerVideos
val remoteVideoAspectRatio: StateFlow<Float?> = _remoteVideoAspectRatio.asStateFlow()
private val lastRemoteFrameTimeMs = AtomicLong(0L)
private var remoteVideoMonitorJob: kotlinx.coroutines.Job? = null
private val remoteFrameSink =
VideoSink { frame: VideoFrame ->
lastRemoteFrameTimeMs.set(System.currentTimeMillis())
val w = frame.rotatedWidth
val h = frame.rotatedHeight
if (w > 0 && h > 0) {
_remoteVideoAspectRatio.value = w.toFloat() / h.toFloat()
}
}
// Per-peer video activity monitoring for group calls
private val perPeerFrameSinks = ConcurrentHashMap<HexKey, VideoSink>()
private val perPeerLastFrameTimeMs = ConcurrentHashMap<HexKey, AtomicLong>()
private var groupVideoMonitorJob: kotlinx.coroutines.Job? = null
// Set of peer pubkeys that are actively sending video frames
private val _activePeerVideos = MutableStateFlow<Set<HexKey>>(emptySet())
val activePeerVideos: StateFlow<Set<HexKey>> = _activePeerVideos.asStateFlow()
private val _isAudioMuted = MutableStateFlow(false) private val _isAudioMuted = MutableStateFlow(false)
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow() val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
private val _isVideoEnabled = MutableStateFlow(false) val isVideoEnabled: StateFlow<Boolean> = mediaManager.isVideoEnabled
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
@@ -164,13 +102,9 @@ class CallController(
when (state) { when (state) {
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
withContext(Dispatchers.IO) { audioManager.startRinging() } withContext(Dispatchers.IO) { audioManager.startRinging() }
// Launch notification in a separate coroutine so that scope.launch {
// long-running network I/O (profile picture download) NotificationUtils.showIncomingCallNotification(state.callerPubKey, context)
// does not block the state collector. StateFlow is }
// conflated — if the collector is suspended when the
// state transitions Ended → Idle, the Ended emission
// is lost and cleanup/stopRinging never runs.
scope.launch { showIncomingCallNotification(state.callerPubKey) }
} }
is CallState.Offering -> { is CallState.Offering -> {
@@ -186,10 +120,6 @@ class CallController(
} }
is CallState.Connected -> { is CallState.Connected -> {
// Stop ringing/ringback in case the Connecting state
// was skipped due to StateFlow conflation (the value
// can change Offering → Connecting → Connected before
// the collector processes Connecting).
audioManager.stopRinging() audioManager.stopRinging()
audioManager.stopRingbackTone() audioManager.stopRingbackTone()
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
@@ -202,10 +132,6 @@ class CallController(
} }
is CallState.Idle -> { is CallState.Idle -> {
// Safety net: full cleanup in case the Ended state
// was missed due to StateFlow conflation. cleanup()
// is idempotent — calling it twice is harmless because
// each resource is null-checked and nulled out.
cleanup() cleanup()
} }
} }
@@ -214,33 +140,23 @@ class CallController(
scope.launch { scope.launch {
audioManager.isNearEar.collect { nearEar -> audioManager.isNearEar.collect { nearEar ->
val videoTrack = localVideoTrackInternal ?: return@collect val videoTrack = mediaManager.localVideoTrack ?: return@collect
if (nearEar && _isVideoEnabled.value && !videoPausedByProximity) { if (nearEar && mediaManager.isVideoEnabled.value && !videoPausedByProximity) {
videoPausedByProximity = true videoPausedByProximity = true
videoTrack.setEnabled(false) videoTrack.setEnabled(false)
stopCamera() mediaManager.stopCamera()
} else if (!nearEar && videoPausedByProximity) { } else if (!nearEar && videoPausedByProximity) {
videoPausedByProximity = false videoPausedByProximity = false
videoTrack.setEnabled(true) videoTrack.setEnabled(true)
startCamera() mediaManager.startCamera()
} }
} }
} }
} }
// ---- Call initiation (caller side) ---- // ---- Call initiation (caller side) ----
fun initiateGroupCall(
peerPubKeys: Set<String>,
callType: CallType,
) {
initiateCallInternal(peerPubKeys, callType)
}
/** fun initiateGroupCall(
* Creates a separate PeerConnection (and SDP offer) for each callee,
* establishing full-mesh connectivity for group calls.
*/
private fun initiateCallInternal(
peerPubKeys: Set<String>, peerPubKeys: Set<String>,
callType: CallType, callType: CallType,
) { ) {
@@ -250,29 +166,23 @@ class CallController(
_errorMessage.value = null _errorMessage.value = null
try { try {
withContext(Dispatchers.IO) { initializeSharedResources(callType) } withContext(Dispatchers.IO) { mediaManager.initialize(callType) }
Log.d(TAG) { "initiateCall: shared resources initialized" }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize WebRTC", e) Log.e(TAG, "Failed to initialize WebRTC", e)
_errorMessage.value = "Failed to start call: ${e.message}" _errorMessage.value = "Failed to start call: ${e.message}"
return@launch return@launch
} }
// Set state to Offering before creating peer sessions
callManager.beginOffering(callId, peerPubKeys, callType) callManager.beginOffering(callId, peerPubKeys, callType)
// Create a PeerConnection + offer for each callee
for (peerPubKey in peerPubKeys) { for (peerPubKey in peerPubKeys) {
try { try {
val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter) peerSessionMgr.registerSession(peerPubKey, adapter)
Log.d(TAG) { "initiateCall: PeerConnection created for ${peerPubKey.take(8)}" }
webRtcSession.createOffer { sdp -> webRtcSession.createOffer { sdp ->
Log.d(TAG) { "initiateCall: offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
scope.launch { scope.launch {
callManager.publishOfferToPeer(peerPubKey, peerPubKeys, callType, callId, sdp.description) callManager.publishOfferToPeer(peerPubKey, peerPubKeys, callType, callId, sdp.description)
Log.d(TAG) { "initiateCall: offer published for ${peerPubKey.take(8)}" }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -286,20 +196,14 @@ class CallController(
fun acceptIncomingCall(sdpOffer: String) { fun acceptIncomingCall(sdpOffer: String) {
val state = callManager.state.value val state = callManager.state.value
if (state !is CallState.IncomingCall) { if (state !is CallState.IncomingCall) return
Log.d(TAG) { "acceptIncomingCall: state is ${state::class.simpleName}, not IncomingCall — ignoring" }
return
}
val callerPubKey = state.callerPubKey val callerPubKey = state.callerPubKey
Log.d(TAG) { "acceptIncomingCall: callId=${state.callId}, callType=${state.callType}, sdpOfferLength=${sdpOffer.length}" }
scope.launch { scope.launch {
_errorMessage.value = null _errorMessage.value = null
try { try {
withContext(Dispatchers.IO) { initializeSharedResources(state.callType) } withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) }
Log.d(TAG) { "acceptIncomingCall: shared resources initialized" }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to initialize WebRTC", e) Log.e(TAG, "Failed to initialize WebRTC", e)
_errorMessage.value = "Failed to accept call: ${e.message}" _errorMessage.value = "Failed to accept call: ${e.message}"
@@ -316,19 +220,13 @@ class CallController(
} }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
val entry = peerSessionMgr.registerSession(callerPubKey, adapter) peerSessionMgr.registerSession(callerPubKey, adapter)
Log.d(TAG) { "acceptIncomingCall: setting remote description (OFFER)..." }
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer) adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
Log.d(TAG) { "acceptIncomingCall: flushing ${entry.pendingIceCandidates.size} pending ICE candidates..." }
peerSessionMgr.flushPendingIceCandidates(callerPubKey) peerSessionMgr.flushPendingIceCandidates(callerPubKey)
Log.d(TAG) { "acceptIncomingCall: creating answer..." }
webRtcSession.createAnswer { sdp -> webRtcSession.createAnswer { sdp ->
Log.d(TAG) { "acceptIncomingCall: answer created, sdpLength=${sdp.description.length}, publishing..." }
scope.launch { scope.launch {
callManager.acceptCall(sdp.description) callManager.acceptCall(sdp.description)
Log.d(TAG) { "acceptIncomingCall: answer published, state=${callManager.state.value::class.simpleName}" }
} }
} }
} }
@@ -336,27 +234,18 @@ class CallController(
// ---- Answer routing ---- // ---- Answer routing ----
/**
* Routes an answer to the correct per-peer PeerConnection.
* For the caller: the answer is for a PeerConnection we already created.
* For a callee seeing another callee's answer: we don't have a session
* for them yet, so we trigger callee-to-callee connection.
*/
fun onCallAnswerReceived( fun onCallAnswerReceived(
peerPubKey: HexKey, peerPubKey: HexKey,
sdpAnswer: String, sdpAnswer: String,
) { ) {
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
val action = peerSessionMgr.routeAnswer(peerPubKey, sdpAnswer) val action = peerSessionMgr.routeAnswer(peerPubKey, sdpAnswer)
Log.d(TAG) { "onCallAnswerReceived: action=$action" }
when (action) { when (action) {
AnswerRouteAction.APPLIED -> { AnswerRouteAction.APPLIED -> {
Log.d(TAG) { "Answer applied for ${peerPubKey.take(8)}" } Log.d(TAG) { "Answer applied for ${peerPubKey.take(8)}" }
} }
AnswerRouteAction.NO_SESSION -> { AnswerRouteAction.NO_SESSION -> {
Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee connection" } Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee" }
onNewPeerInGroupCall(peerPubKey) onNewPeerInGroupCall(peerPubKey)
} }
@@ -368,16 +257,10 @@ class CallController(
// ---- ICE candidate routing ---- // ---- ICE candidate routing ----
/**
* Routes an incoming ICE candidate to the correct per-peer session.
* Before a session exists for a peer, candidates are buffered globally.
*/
fun onIceCandidateReceived(event: CallIceCandidateEvent) { fun onIceCandidateReceived(event: CallIceCandidateEvent) {
try { try {
val senderPubKey = event.pubKey
val candidate = IceCandidateData(event.candidateSdp(), event.sdpMid(), event.sdpMLineIndex()) val candidate = IceCandidateData(event.candidateSdp(), event.sdpMid(), event.sdpMLineIndex())
val action = peerSessionMgr.routeIceCandidate(senderPubKey, candidate) peerSessionMgr.routeIceCandidate(event.pubKey, candidate)
Log.d(TAG) { "ICE candidate from ${senderPubKey.take(8)}: $action" }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse ICE candidate", e) Log.e(TAG, "Failed to parse ICE candidate", e)
} }
@@ -387,10 +270,8 @@ class CallController(
peerPubKey: HexKey, peerPubKey: HexKey,
candidate: IceCandidate, candidate: IceCandidate,
) { ) {
Log.d(TAG) { "Local ICE candidate for ${peerPubKey.take(8)}: ${candidate.sdp.take(50)}" }
val callId = callManager.currentCallId() ?: return val callId = callManager.currentCallId() ?: return
val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex) val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex)
scope.launch { scope.launch {
val signer = signerProvider() val signer = signerProvider()
val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer) val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer)
@@ -400,31 +281,17 @@ class CallController(
// ---- Callee-to-callee mesh connections ---- // ---- Callee-to-callee mesh connections ----
/**
* Another callee joined the group call. Establish a direct PeerConnection
* to them. To avoid glare (both sides sending offers simultaneously), the
* peer with the lexicographically lower pubkey initiates.
*/
fun onNewPeerInGroupCall(peerPubKey: HexKey) { fun onNewPeerInGroupCall(peerPubKey: HexKey) {
if (peerSessionMgr.hasSession(peerPubKey)) { if (peerSessionMgr.hasSession(peerPubKey)) return
Log.d(TAG) { "onNewPeerInGroupCall: session already exists for ${peerPubKey.take(8)} — skipping" }
return
}
scope.launch { scope.launch {
Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, shouldInitiate=${peerSessionMgr.shouldInitiateOffer(peerPubKey)}" }
if (peerSessionMgr.shouldInitiateOffer(peerPubKey)) { if (peerSessionMgr.shouldInitiateOffer(peerPubKey)) {
Log.d(TAG) { "Initiating callee-to-callee connection to ${peerPubKey.take(8)} (I have lower pubkey)" }
createAndOfferToPeer(peerPubKey) createAndOfferToPeer(peerPubKey)
} else {
Log.d(TAG) { "Waiting for callee-to-callee offer from ${peerPubKey.take(8)} (they have lower pubkey)" }
} }
} }
} }
private suspend fun createAndOfferToPeer(peerPubKey: HexKey) { private suspend fun createAndOfferToPeer(peerPubKey: HexKey) {
if (peerConnectionFactory == null) return if (mediaManager.peerConnectionFactory == null) return
val webRtcSession = val webRtcSession =
try { try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
@@ -432,38 +299,20 @@ class CallController(
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e) Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
return return
} }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter) peerSessionMgr.registerSession(peerPubKey, adapter)
webRtcSession.createOffer { sdp -> webRtcSession.createOffer { sdp ->
Log.d(TAG) { "Callee-to-callee offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" } scope.launch { callManager.publishOfferToPeer(peerPubKey, sdp.description) }
scope.launch {
callManager.publishOfferToPeer(peerPubKey, sdp.description)
}
} }
} }
/**
* A mid-call offer was received from another callee in the group.
* Create a PeerSession, accept their offer, and send back an answer.
*/
fun onMidCallOfferReceived( fun onMidCallOfferReceived(
peerPubKey: HexKey, peerPubKey: HexKey,
sdpOffer: String, sdpOffer: String,
) { ) {
if (peerSessionMgr.hasSession(peerPubKey)) { if (peerSessionMgr.hasSession(peerPubKey)) return
Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)} but session already exists — ignoring" }
return
}
Log.d(TAG) { "Mid-call offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
scope.launch { scope.launch {
if (peerConnectionFactory == null) { if (mediaManager.peerConnectionFactory == null) return@launch
Log.e(TAG, "Mid-call offer but factory not initialized")
return@launch
}
val webRtcSession = val webRtcSession =
try { try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
@@ -471,17 +320,12 @@ class CallController(
Log.e(TAG, "Failed to create PeerConnection for mid-call offer from ${peerPubKey.take(8)}", e) Log.e(TAG, "Failed to create PeerConnection for mid-call offer from ${peerPubKey.take(8)}", e)
return@launch return@launch
} }
val adapter = WebRtcPeerSessionAdapter(webRtcSession) val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter) peerSessionMgr.registerSession(peerPubKey, adapter)
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer) adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
peerSessionMgr.flushPendingIceCandidates(peerPubKey) peerSessionMgr.flushPendingIceCandidates(peerPubKey)
webRtcSession.createAnswer { sdp -> webRtcSession.createAnswer { sdp ->
Log.d(TAG) { "Callee-to-callee answer for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" } scope.launch { callManager.publishAnswerToPeer(peerPubKey, sdp.description) }
scope.launch {
callManager.publishAnswerToPeer(peerPubKey, sdp.description)
}
} }
} }
} }
@@ -491,14 +335,10 @@ class CallController(
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) { private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
val peerPubKey = event.pubKey val peerPubKey = event.pubKey
val sdpOffer = event.sdpOffer() val sdpOffer = event.sdpOffer()
Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
scope.launch { scope.launch {
val resolution = peerSessionMgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry ->
peerSessionMgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry -> applyRenegotiationOffer(entry.session, peerPubKey, sdpOffer)
applyRenegotiationOffer(entry.session, peerPubKey, sdpOffer) }
}
Log.d(TAG) { "Renegotiation glare resolution with ${peerPubKey.take(8)}: $resolution" }
} }
} }
@@ -509,9 +349,7 @@ class CallController(
) { ) {
session.setRemoteDescription(SdpType.OFFER, sdpOffer) session.setRemoteDescription(SdpType.OFFER, sdpOffer)
session.createAnswer { sdpAnswer -> session.createAnswer { sdpAnswer ->
scope.launch { scope.launch { callManager.sendRenegotiationAnswer(sdpAnswer, peerPubKey) }
callManager.sendRenegotiationAnswer(sdpAnswer, peerPubKey)
}
} }
} }
@@ -519,12 +357,8 @@ class CallController(
val webRtcSession = webRtcSession(peerPubKey) ?: return val webRtcSession = webRtcSession(peerPubKey) ?: return
val state = callManager.state.value val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return if (state !is CallState.Connected && state !is CallState.Connecting) return
Log.d(TAG) { "Starting renegotiation with ${peerPubKey.take(8)}" }
webRtcSession.createOffer { sdp -> webRtcSession.createOffer { sdp ->
scope.launch { scope.launch { callManager.sendRenegotiation(sdp.description, peerPubKey) }
callManager.sendRenegotiation(sdp.description, peerPubKey)
}
} }
} }
@@ -533,31 +367,24 @@ class CallController(
fun toggleAudioMute() { fun toggleAudioMute() {
val muted = !_isAudioMuted.value val muted = !_isAudioMuted.value
_isAudioMuted.value = muted _isAudioMuted.value = muted
localAudioTrackInternal?.setEnabled(!muted) mediaManager.setAudioMuted(muted)
} }
fun toggleVideo() { fun toggleVideo() {
val enabling = !_isVideoEnabled.value val enabling = !mediaManager.isVideoEnabled.value
if (enabling) { if (enabling) {
if (localVideoTrackInternal == null) { if (mediaManager.localVideoTrack == null) {
// Voice → video upgrade: create video source/track and add to all sessions mediaManager.createVideoResources()
createVideoResources()
peerSessionMgr.allSessionKeys().forEach { key -> peerSessionMgr.allSessionKeys().forEach { key ->
webRtcSession(key)?.let { session -> webRtcSession(key)?.let { session ->
localVideoTrackInternal?.let { track -> session.addTrack(track, VIDEO_MAX_BITRATE_BPS) } mediaManager.localVideoTrack?.let { track -> session.addTrack(track, VIDEO_MAX_BITRATE_BPS) }
} }
} }
} else { } else {
localVideoTrackInternal?.setEnabled(true) mediaManager.enableVideo()
startCamera()
} }
_localVideoTrack.value = localVideoTrackInternal
_isVideoEnabled.value = true
} else { } else {
localVideoTrackInternal?.setEnabled(false) mediaManager.disableVideo()
stopCamera()
_isVideoEnabled.value = false
} }
} }
@@ -565,98 +392,24 @@ class CallController(
audioManager.cycleAudioRoute() audioManager.cycleAudioRoute()
} }
fun getEglBase(): EglBase? = sharedEglBase fun getEglBase(): EglBase? = mediaManager.sharedEglBase
fun invitePeer(peerPubKey: String) { fun invitePeer(peerPubKey: String) {
scope.launch { scope.launch { createAndOfferToPeer(peerPubKey) }
createAndOfferToPeer(peerPubKey)
}
} }
fun hangup() { fun hangup() {
scope.launch { scope.launch { callManager.hangup() }
callManager.hangup()
}
} }
fun clearError() { fun clearError() {
_errorMessage.value = null _errorMessage.value = null
} }
// ---- Shared resource management ----
private fun initializeSharedResources(callType: CallType) {
if (peerConnectionFactory != null) return // already initialized
sharedEglBase = EglBase.create()
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(sharedEglBase!!.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(sharedEglBase!!.eglBaseContext, true, true))
.createPeerConnectionFactory()
// Audio
localAudioSource = peerConnectionFactory?.createAudioSource(MediaConstraints())
localAudioTrackInternal = peerConnectionFactory?.createAudioTrack("audio0", localAudioSource)
// Video (if video call)
if (callType == CallType.VIDEO) {
createVideoResources()
}
}
private fun createVideoResources() {
if (localVideoSource != null) return
val factory = peerConnectionFactory ?: return
localVideoSource = factory.createVideoSource(false)
localVideoTrackInternal = factory.createVideoTrack("video0", localVideoSource)
_localVideoTrack.value = localVideoTrackInternal
_isVideoEnabled.value = true
startCamera()
}
private fun startCamera() {
if (cameraCapturer != null) return
val source = localVideoSource ?: return
val egl = sharedEglBase ?: 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", egl.eglBaseContext),
context,
source.capturerObserver,
)
it.startCapture(1280, 720, 30)
}
}
private fun stopCamera() {
cameraCapturer?.stopCapture()
cameraCapturer?.dispose()
cameraCapturer = null
}
// ---- Per-peer PeerConnection creation ---- // ---- Per-peer PeerConnection creation ----
private fun createWebRtcSession(peerPubKey: HexKey): WebRtcCallSession { private fun createWebRtcSession(peerPubKey: HexKey): WebRtcCallSession {
Log.d(TAG) { "createWebRtcSession: ${peerPubKey.take(8)}, existing sessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" } val factory = mediaManager.peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
val session = val session =
WebRtcCallSession( WebRtcCallSession(
peerConnectionFactory = factory, peerConnectionFactory = factory,
@@ -664,95 +417,28 @@ class CallController(
onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) }, onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) },
onPeerConnected = { onPeerConnected = {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" } Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" }
callManager.onPeerConnected() scope.launch { callManager.onPeerConnected() }
if (!foregroundServiceStarted) { if (!foregroundServiceStarted) {
foregroundServiceStarted = true foregroundServiceStarted = true
startForegroundService() startForegroundService()
} }
}, },
onRemoteVideoTrack = { track -> onRemoteVideoTrack(peerPubKey, track) }, onRemoteVideoTrack = { track -> videoMonitor.onRemoteVideoTrack(peerPubKey, track) },
onDisconnected = { onPeerDisconnected(peerPubKey) }, onDisconnected = { onPeerDisconnected(peerPubKey) },
onError = { error -> _errorMessage.value = error }, onError = { error -> _errorMessage.value = error },
onRenegotiationNeeded = { performRenegotiation(peerPubKey) }, onRenegotiationNeeded = { performRenegotiation(peerPubKey) },
) )
try { try {
session.createPeerConnection() session.createPeerConnection()
} catch (e: Exception) { } catch (e: Exception) {
session.dispose() session.dispose()
throw e throw e
} }
mediaManager.localAudioTrack?.let { session.addTrack(it) }
// Add shared local tracks to this PeerConnection mediaManager.localVideoTrack?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
localAudioTrackInternal?.let { session.addTrack(it) }
localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
return session return session
} }
private fun onRemoteVideoTrack(
peerPubKey: HexKey,
track: VideoTrack,
) {
Log.d(TAG) { "Remote video track from ${peerPubKey.take(8)}" }
_remoteVideoTracks.value = _remoteVideoTracks.value + (peerPubKey to track)
// Backward-compat: set the first remote track as the primary
if (_remoteVideoTrack.value == null) {
_remoteVideoTrack.value = track
startRemoteVideoMonitor(track)
}
// Monitor this peer's video activity for group call UI
startPeerVideoMonitor(peerPubKey, track)
}
private fun startPeerVideoMonitor(
peerPubKey: HexKey,
track: VideoTrack,
) {
// Remove any existing sink for this peer
stopPeerVideoMonitor(peerPubKey)
val lastFrameTime = AtomicLong(System.currentTimeMillis())
perPeerLastFrameTimeMs[peerPubKey] = lastFrameTime
val sink =
VideoSink { frame: VideoFrame ->
lastFrameTime.set(System.currentTimeMillis())
}
perPeerFrameSinks[peerPubKey] = sink
track.addSink(sink)
ensureGroupVideoMonitorRunning()
}
private fun stopPeerVideoMonitor(peerPubKey: HexKey) {
val sink = perPeerFrameSinks.remove(peerPubKey) ?: return
perPeerLastFrameTimeMs.remove(peerPubKey)
val track = _remoteVideoTracks.value[peerPubKey]
try {
track?.removeSink(sink)
} catch (_: Exception) {
}
}
private fun ensureGroupVideoMonitorRunning() {
if (groupVideoMonitorJob != null) return
groupVideoMonitorJob =
scope.launch {
while (true) {
delay(1500)
val now = System.currentTimeMillis()
val activePeers = mutableSetOf<HexKey>()
for ((peerKey, lastFrame) in perPeerLastFrameTimeMs) {
if (now - lastFrame.get() < 2000) {
activePeers.add(peerKey)
}
}
_activePeerVideos.value = activePeers
val anyActive = activePeers.isNotEmpty()
_isRemoteVideoActive.value = anyActive || (now - lastRemoteFrameTimeMs.get() < 2000)
}
}
}
private fun onPeerDisconnected(peerPubKey: HexKey) { private fun onPeerDisconnected(peerPubKey: HexKey) {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" } Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" }
val allDisconnected = val allDisconnected =
@@ -762,57 +448,27 @@ class CallController(
peerSessionMgr.getSession(key)?.remoteDescriptionSet != true peerSessionMgr.getSession(key)?.remoteDescriptionSet != true
} }
if (allDisconnected) { if (allDisconnected) {
Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" }
scope.launch { callManager.hangup() } scope.launch { callManager.hangup() }
} else {
Log.d(TAG) { "onPeerDisconnected: other peers still active, continuing call" }
} }
} }
// ---- Per-peer cleanup ---- // ---- Per-peer cleanup ----
/**
* Disposes a single peer's WebRTC session when they leave the call
* but the call continues with remaining peers.
*/
fun disposePeerSession(peerPubKey: HexKey) { fun disposePeerSession(peerPubKey: HexKey) {
val entry = peerSessionMgr.removeSession(peerPubKey) val entry = peerSessionMgr.removeSession(peerPubKey)
if (entry != null) { if (entry != null) {
Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}" }
try { try {
entry?.session?.dispose() entry.session.dispose()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "disposePeerSession: dispose() failed for ${peerPubKey.take(8)}", e) Log.e(TAG, "disposePeerSession: dispose() failed for ${peerPubKey.take(8)}", e)
} }
// Clean up per-peer video monitor videoMonitor.onPeerRemoved(peerPubKey)
stopPeerVideoMonitor(peerPubKey)
// Update remote video tracks
val currentTracks = _remoteVideoTracks.value
if (peerPubKey in currentTracks) {
_remoteVideoTracks.value = currentTracks - peerPubKey
// If the disposed peer was the primary remote track, pick a new one
if (_remoteVideoTrack.value == currentTracks[peerPubKey]) {
stopRemoteVideoMonitor()
val nextTrack = _remoteVideoTracks.value.values.firstOrNull()
_remoteVideoTrack.value = nextTrack
if (nextTrack != null) {
startRemoteVideoMonitor(nextTrack)
}
}
}
} else {
Log.d(TAG) { "disposePeerSession: no session for ${peerPubKey.take(8)} (already disposed or never created)" }
} }
} }
// ---- Cleanup ---- // ---- Cleanup ----
fun cleanup() { fun cleanup() {
Log.d(TAG) { "cleanup: disposing ${peerSessionMgr.allSessionKeys().size} peer sessions, state=${callManager.state.value::class.simpleName}" }
// Each block is wrapped individually so that a failure in one
// (e.g. a WebRTC native crash) does not prevent the rest from
// running. Without this, a single exception could leave the
// camera open, audio mode stuck, or the foreground service alive.
try { try {
audioManager.release() audioManager.release()
} catch (e: Exception) { } catch (e: Exception) {
@@ -825,14 +481,9 @@ class CallController(
} }
foregroundServiceStarted = false foregroundServiceStarted = false
NotificationUtils.cancelCallNotification(context) NotificationUtils.cancelCallNotification(context)
stopRemoteVideoMonitor()
// Clean up per-peer video monitors videoMonitor.dispose()
for (peerPubKey in perPeerFrameSinks.keys.toList()) {
stopPeerVideoMonitor(peerPubKey)
}
// Dispose all peer sessions
try { try {
peerSessionMgr.disposeAll() peerSessionMgr.disposeAll()
} catch (e: Exception) { } catch (e: Exception) {
@@ -840,95 +491,18 @@ class CallController(
} }
peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey) peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey)
// Dispose shared resources — each in its own try-catch so one mediaManager.dispose()
// failure does not prevent the others from being released.
try {
stopCamera()
} catch (e: Exception) {
Log.e(TAG, "cleanup: stopCamera() failed", e)
}
try {
localAudioTrackInternal?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localAudioTrack.dispose() failed", e)
}
try {
localVideoTrackInternal?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localVideoTrack.dispose() failed", e)
}
try {
localAudioSource?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localAudioSource.dispose() failed", e)
}
try {
localVideoSource?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localVideoSource.dispose() failed", e)
}
try {
peerConnectionFactory?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: peerConnectionFactory.dispose() failed", e)
}
try {
sharedEglBase?.release()
} catch (e: Exception) {
Log.e(TAG, "cleanup: sharedEglBase.release() failed", e)
}
localAudioTrackInternal = null
localVideoTrackInternal = null
localAudioSource = null
localVideoSource = null
peerConnectionFactory = null
sharedEglBase = null
_remoteVideoTrack.value = null
_remoteVideoTracks.value = emptyMap()
_localVideoTrack.value = null
_isAudioMuted.value = false _isAudioMuted.value = false
_isVideoEnabled.value = false
_isRemoteVideoActive.value = false
_remoteVideoAspectRatio.value = null
_activePeerVideos.value = emptySet()
videoPausedByProximity = false videoPausedByProximity = false
} }
// ---- Remote video monitoring ----
private fun startRemoteVideoMonitor(track: VideoTrack) {
stopRemoteVideoMonitor()
lastRemoteFrameTimeMs.set(System.currentTimeMillis())
track.addSink(remoteFrameSink)
remoteVideoMonitorJob =
scope.launch {
while (true) {
delay(1500)
val elapsed = System.currentTimeMillis() - lastRemoteFrameTimeMs.get()
_isRemoteVideoActive.value = elapsed < 2000
}
}
}
private fun stopRemoteVideoMonitor() {
remoteVideoMonitorJob?.cancel()
remoteVideoMonitorJob = null
groupVideoMonitorJob?.cancel()
groupVideoMonitorJob = null
try {
_remoteVideoTrack.value?.removeSink(remoteFrameSink)
} catch (_: Exception) {
}
}
// ---- Foreground service ---- // ---- Foreground service ----
private fun startForegroundService() { private fun startForegroundService() {
try { try {
val peerName = callManager.currentPeerPubKey() ?: "" val peerName = callManager.currentPeerPubKey() ?: ""
val isVideo = _isVideoEnabled.value val isVideo = mediaManager.isVideoEnabled.value
val intent = val intent =
Intent(context, CallForegroundService::class.java).apply { Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_START action = CallForegroundService.ACTION_START
@@ -951,37 +525,4 @@ class CallController(
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
// ---- Incoming call notification ----
private suspend fun showIncomingCallNotification(callerPubKey: String) {
val callerUser = LocalCache.getUserIfExists(callerPubKey)
val callerName = callerUser?.toBestDisplayName() ?: callerPubKey.take(8) + "..."
val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}"
val callerBitmap =
callerUser?.profilePicture()?.let { pictureUrl ->
withContext(Dispatchers.IO) {
try {
val request =
ImageRequest
.Builder(context)
.data(pictureUrl)
.allowHardware(false)
.build()
val result = ImageLoader(context).execute(request)
(result.image?.asDrawable(context.resources) as? android.graphics.drawable.BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
}
NotificationUtils.sendCallNotification(
callerName = callerName,
callerBitmap = callerBitmap,
uri = uri,
applicationContext = context,
)
}
} }
@@ -0,0 +1,206 @@
/*
* 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.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
import org.webrtc.Camera2Enumerator
import org.webrtc.CameraVideoCapturer
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnectionFactory
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
private const val TAG = "CallMediaManager"
/**
* Owns the shared WebRTC infrastructure: [PeerConnectionFactory], [EglBase],
* local audio/video sources and tracks, and the camera capturer.
*
* [CallController] delegates resource creation and teardown here so that
* it can focus on call orchestration.
*/
class CallMediaManager(
private val context: Context,
) {
var peerConnectionFactory: PeerConnectionFactory? = null
private set
var sharedEglBase: EglBase? = null
private set
var localAudioSource: AudioSource? = null
private set
var localVideoSource: VideoSource? = null
private set
var localAudioTrack: AudioTrack? = null
private set
var localVideoTrack: VideoTrack? = null
private set
private var cameraCapturer: CameraVideoCapturer? = null
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private val _localVideoTrackFlow = MutableStateFlow<VideoTrack?>(null)
val localVideoTrackFlow: StateFlow<VideoTrack?> = _localVideoTrackFlow.asStateFlow()
private val _isVideoEnabled = MutableStateFlow(false)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
fun initialize(callType: CallType) {
if (peerConnectionFactory != null) return
sharedEglBase = EglBase.create()
PeerConnectionFactory.initialize(
PeerConnectionFactory
.InitializationOptions
.builder(context)
.createInitializationOptions(),
)
peerConnectionFactory =
PeerConnectionFactory
.builder()
.setVideoDecoderFactory(DefaultVideoDecoderFactory(sharedEglBase!!.eglBaseContext))
.setVideoEncoderFactory(DefaultVideoEncoderFactory(sharedEglBase!!.eglBaseContext, true, true))
.createPeerConnectionFactory()
localAudioSource = peerConnectionFactory?.createAudioSource(MediaConstraints())
localAudioTrack = peerConnectionFactory?.createAudioTrack("audio0", localAudioSource)
if (callType == CallType.VIDEO) {
createVideoResources()
}
}
fun createVideoResources() {
if (localVideoSource != null) return
val factory = peerConnectionFactory ?: return
localVideoSource = factory.createVideoSource(false)
localVideoTrack = factory.createVideoTrack("video0", localVideoSource)
_localVideoTrackFlow.value = localVideoTrack
_isVideoEnabled.value = true
startCamera()
}
fun startCamera() {
if (cameraCapturer != null) return
val source = localVideoSource ?: return
val egl = sharedEglBase ?: return
val enumerator = Camera2Enumerator(context)
val frontCamera = enumerator.deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
val camera = frontCamera ?: enumerator.deviceNames.firstOrNull() ?: return
val helper = SurfaceTextureHelper.create("CaptureThread", egl.eglBaseContext)
surfaceTextureHelper = helper
cameraCapturer =
enumerator.createCapturer(camera, null)?.also {
it.initialize(helper, context, source.capturerObserver)
it.startCapture(1280, 720, 30)
}
}
fun stopCamera() {
try {
cameraCapturer?.stopCapture()
} catch (_: InterruptedException) {
}
cameraCapturer?.dispose()
cameraCapturer = null
surfaceTextureHelper?.dispose()
surfaceTextureHelper = null
}
fun enableVideo() {
localVideoTrack?.setEnabled(true)
_isVideoEnabled.value = true
_localVideoTrackFlow.value = localVideoTrack
}
fun disableVideo() {
localVideoTrack?.setEnabled(false)
stopCamera()
_isVideoEnabled.value = false
}
fun setAudioMuted(muted: Boolean) {
localAudioTrack?.setEnabled(!muted)
}
fun dispose() {
try {
stopCamera()
} catch (e: Exception) {
Log.e(TAG, "dispose: stopCamera() failed", e)
}
try {
localAudioTrack?.dispose()
} catch (e: Exception) {
Log.e(TAG, "dispose: localAudioTrack.dispose() failed", e)
}
try {
localVideoTrack?.dispose()
} catch (e: Exception) {
Log.e(TAG, "dispose: localVideoTrack.dispose() failed", e)
}
try {
localAudioSource?.dispose()
} catch (e: Exception) {
Log.e(TAG, "dispose: localAudioSource.dispose() failed", e)
}
try {
localVideoSource?.dispose()
} catch (e: Exception) {
Log.e(TAG, "dispose: localVideoSource.dispose() failed", e)
}
try {
peerConnectionFactory?.dispose()
} catch (e: Exception) {
Log.e(TAG, "dispose: peerConnectionFactory.dispose() failed", e)
}
try {
sharedEglBase?.release()
} catch (e: Exception) {
Log.e(TAG, "dispose: sharedEglBase.release() failed", e)
}
localAudioTrack = null
localVideoTrack = null
localAudioSource = null
localVideoSource = null
peerConnectionFactory = null
sharedEglBase = null
_localVideoTrackFlow.value = null
_isVideoEnabled.value = false
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.call package com.vitorpamplona.amethyst.service.call
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -31,8 +31,9 @@ import kotlinx.coroutines.launch
/** /**
* Handles the Reject action from the incoming call notification. * Handles the Reject action from the incoming call notification.
* *
* The Accept action launches [CallActivity] directly via PendingIntent.getActivity * The Accept action launches [com.vitorpamplona.amethyst.ui.call.CallActivity]
* to comply with Android 12+ notification trampoline restrictions. * directly via PendingIntent.getActivity to comply with Android 12+
* notification trampoline restrictions.
*/ */
class CallNotificationReceiver : BroadcastReceiver() { class CallNotificationReceiver : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@@ -44,7 +45,7 @@ class CallNotificationReceiver : BroadcastReceiver() {
ACTION_REJECT_CALL -> { ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context) NotificationUtils.cancelCallNotification(context)
val callManager = ActiveCallHolder.callManager ?: return val callManager = CallSessionBridge.callManager ?: return
GlobalScope.launch { GlobalScope.launch {
callManager.rejectCall() callManager.rejectCall()
} }
@@ -18,18 +18,17 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.call package com.vitorpamplona.amethyst.service.call
import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/** /**
* Holds references to the active call state so that [CallActivity] can access * Process-level singleton that bridges the active call state between
* the same [CallManager] and [CallController] owned by [AccountViewModel] in * the main activity (which owns [AccountViewModel]) and [com.vitorpamplona.amethyst.ui.call.CallActivity]
* the main activity. Both activities run in the same process. * (which runs in its own window but in the same process).
*/ */
object ActiveCallHolder { object CallSessionBridge {
var callManager: CallManager? = null var callManager: CallManager? = null
private set private set
var callController: CallController? = null var callController: CallController? = null
@@ -0,0 +1,197 @@
/*
* 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.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
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
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
import org.webrtc.VideoTrack
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
private const val TAG = "RemoteVideoMonitor"
private const val FRAME_TIMEOUT_MS = 2000L
private const val POLL_INTERVAL_MS = 1500L
/**
* Tracks remote video activity for all peers in a call.
*
* Attaches [VideoSink]s to remote [VideoTrack]s and periodically polls
* whether frames are still arriving. Exposes per-peer activity state
* and a combined "any video active" flag for the UI.
*/
class RemoteVideoMonitor(
private val scope: CoroutineScope,
) {
// Primary remote track (first connected peer) for P2P backward compat
private val _remoteVideoTrack = MutableStateFlow<VideoTrack?>(null)
val remoteVideoTrack: StateFlow<VideoTrack?> = _remoteVideoTrack.asStateFlow()
// All remote tracks keyed by peer pubkey (for group call UI)
private val _remoteVideoTracks = MutableStateFlow<Map<HexKey, VideoTrack>>(emptyMap())
val remoteVideoTracks: StateFlow<Map<HexKey, VideoTrack>> = _remoteVideoTracks.asStateFlow()
private val _isRemoteVideoActive = MutableStateFlow(false)
val isRemoteVideoActive: StateFlow<Boolean> = _isRemoteVideoActive.asStateFlow()
private val _remoteVideoAspectRatio = MutableStateFlow<Float?>(null)
val remoteVideoAspectRatio: StateFlow<Float?> = _remoteVideoAspectRatio.asStateFlow()
// Per-peer video activity
private val _activePeerVideos = MutableStateFlow<Set<HexKey>>(emptySet())
val activePeerVideos: StateFlow<Set<HexKey>> = _activePeerVideos.asStateFlow()
// Primary track monitoring
private val lastRemoteFrameTimeMs = AtomicLong(0L)
private var remoteVideoMonitorJob: Job? = null
private val remoteFrameSink =
VideoSink { frame: VideoFrame ->
lastRemoteFrameTimeMs.set(System.currentTimeMillis())
val w = frame.rotatedWidth
val h = frame.rotatedHeight
if (w > 0 && h > 0) {
_remoteVideoAspectRatio.value = w.toFloat() / h.toFloat()
}
}
// Per-peer monitoring
private val perPeerFrameSinks = ConcurrentHashMap<HexKey, VideoSink>()
private val perPeerLastFrameTimeMs = ConcurrentHashMap<HexKey, AtomicLong>()
private var groupVideoMonitorJob: Job? = null
fun onRemoteVideoTrack(
peerPubKey: HexKey,
track: VideoTrack,
) {
Log.d(TAG) { "Remote video track from ${peerPubKey.take(8)}" }
_remoteVideoTracks.value = _remoteVideoTracks.value + (peerPubKey to track)
if (_remoteVideoTrack.value == null) {
_remoteVideoTrack.value = track
startPrimaryMonitor(track)
}
startPeerMonitor(peerPubKey, track)
}
fun onPeerRemoved(peerPubKey: HexKey) {
stopPeerMonitor(peerPubKey)
val currentTracks = _remoteVideoTracks.value
if (peerPubKey in currentTracks) {
_remoteVideoTracks.value = currentTracks - peerPubKey
if (_remoteVideoTrack.value == currentTracks[peerPubKey]) {
stopPrimaryMonitor()
val nextTrack = _remoteVideoTracks.value.values.firstOrNull()
_remoteVideoTrack.value = nextTrack
if (nextTrack != null) {
startPrimaryMonitor(nextTrack)
}
}
}
}
fun dispose() {
stopPrimaryMonitor()
for (peerPubKey in perPeerFrameSinks.keys.toList()) {
stopPeerMonitor(peerPubKey)
}
_remoteVideoTrack.value = null
_remoteVideoTracks.value = emptyMap()
_isRemoteVideoActive.value = false
_remoteVideoAspectRatio.value = null
_activePeerVideos.value = emptySet()
}
private fun startPrimaryMonitor(track: VideoTrack) {
stopPrimaryMonitor()
lastRemoteFrameTimeMs.set(System.currentTimeMillis())
track.addSink(remoteFrameSink)
remoteVideoMonitorJob =
scope.launch {
while (true) {
delay(POLL_INTERVAL_MS)
val elapsed = System.currentTimeMillis() - lastRemoteFrameTimeMs.get()
_isRemoteVideoActive.value = elapsed < FRAME_TIMEOUT_MS
}
}
}
private fun stopPrimaryMonitor() {
remoteVideoMonitorJob?.cancel()
remoteVideoMonitorJob = null
groupVideoMonitorJob?.cancel()
groupVideoMonitorJob = null
try {
_remoteVideoTrack.value?.removeSink(remoteFrameSink)
} catch (_: Exception) {
}
}
private fun startPeerMonitor(
peerPubKey: HexKey,
track: VideoTrack,
) {
stopPeerMonitor(peerPubKey)
val lastFrameTime = AtomicLong(System.currentTimeMillis())
perPeerLastFrameTimeMs[peerPubKey] = lastFrameTime
val sink = VideoSink { _: VideoFrame -> lastFrameTime.set(System.currentTimeMillis()) }
perPeerFrameSinks[peerPubKey] = sink
track.addSink(sink)
ensureGroupMonitorRunning()
}
private fun stopPeerMonitor(peerPubKey: HexKey) {
val sink = perPeerFrameSinks.remove(peerPubKey) ?: return
perPeerLastFrameTimeMs.remove(peerPubKey)
val track = _remoteVideoTracks.value[peerPubKey]
try {
track?.removeSink(sink)
} catch (_: Exception) {
}
}
private fun ensureGroupMonitorRunning() {
if (groupVideoMonitorJob != null) return
groupVideoMonitorJob =
scope.launch {
while (true) {
delay(POLL_INTERVAL_MS)
val now = System.currentTimeMillis()
val activePeers = mutableSetOf<HexKey>()
for ((peerKey, lastFrame) in perPeerLastFrameTimeMs) {
if (now - lastFrame.get() < FRAME_TIMEOUT_MS) {
activePeers.add(peerKey)
}
}
_activePeerVideos.value = activePeers
val anyActive = activePeers.isNotEmpty()
_isRemoteVideoActive.value = anyActive || (now - lastRemoteFrameTimeMs.get() < FRAME_TIMEOUT_MS)
}
}
}
}
@@ -38,8 +38,11 @@ import coil3.asDrawable
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.allowHardware import coil3.request.allowHardware
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -584,8 +587,8 @@ object NotificationUtils {
) )
val rejectIntent = val rejectIntent =
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver::class.java).apply { Intent(applicationContext, com.vitorpamplona.amethyst.service.call.CallNotificationReceiver::class.java).apply {
action = com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver.ACTION_REJECT_CALL action = com.vitorpamplona.amethyst.service.call.CallNotificationReceiver.ACTION_REJECT_CALL
} }
val rejectPendingIntent = val rejectPendingIntent =
@@ -623,6 +626,40 @@ object NotificationUtils {
notificationManager.cancel("call", CALL_NOTIFICATION_ID) notificationManager.cancel("call", CALL_NOTIFICATION_ID)
} }
suspend fun showIncomingCallNotification(
callerPubKey: String,
context: Context,
) {
val callerUser = LocalCache.getUserIfExists(callerPubKey)
val callerName = callerUser?.toBestDisplayName() ?: callerPubKey.take(8) + "..."
val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}"
val callerBitmap =
callerUser?.profilePicture()?.let { pictureUrl ->
withContext(Dispatchers.IO) {
try {
val request =
ImageRequest
.Builder(context)
.data(pictureUrl)
.allowHardware(false)
.build()
val result = coil3.ImageLoader(context).execute(request)
(result.image?.asDrawable(context.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
}
sendCallNotification(
callerName = callerName,
callerBitmap = callerBitmap,
uri = uri,
applicationContext = context,
)
}
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) {
@@ -39,6 +39,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.StringResSetup import com.vitorpamplona.amethyst.ui.StringResSetup
@@ -75,11 +76,11 @@ class CallActivity : AppCompatActivity() {
) { ) {
when (intent.action) { when (intent.action) {
ACTION_PIP_HANGUP -> { ACTION_PIP_HANGUP -> {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() } GlobalScope.launch { CallSessionBridge.callManager?.hangup() }
} }
ACTION_PIP_TOGGLE_MUTE -> { ACTION_PIP_TOGGLE_MUTE -> {
ActiveCallHolder.callController?.toggleAudioMute() CallSessionBridge.callController?.toggleAudioMute()
updatePipParams() updatePipParams()
} }
} }
@@ -102,9 +103,9 @@ class CallActivity : AppCompatActivity() {
) )
} }
val callManager = ActiveCallHolder.callManager val callManager = CallSessionBridge.callManager
val callController = ActiveCallHolder.callController val callController = CallSessionBridge.callController
val accountViewModel = ActiveCallHolder.accountViewModel val accountViewModel = CallSessionBridge.accountViewModel
if (callManager == null || accountViewModel == null) { if (callManager == null || accountViewModel == null) {
finish() finish()
@@ -153,7 +154,7 @@ class CallActivity : AppCompatActivity() {
com.vitorpamplona.amethyst.service.notifications.NotificationUtils com.vitorpamplona.amethyst.service.notifications.NotificationUtils
.cancelCallNotification(this) .cancelCallNotification(this)
val callManager = ActiveCallHolder.callManager ?: return val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value val state = callManager.state.value
if (state !is CallState.IncomingCall) return if (state !is CallState.IncomingCall) return
@@ -168,8 +169,8 @@ class CallActivity : AppCompatActivity() {
} }
private fun acceptCall() { private fun acceptCall() {
val callController = ActiveCallHolder.callController ?: return val callController = CallSessionBridge.callController ?: return
val callManager = ActiveCallHolder.callManager ?: return val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value val state = callManager.state.value
if (state is CallState.IncomingCall) { if (state is CallState.IncomingCall) {
callController.acceptIncomingCall(state.sdpOffer) callController.acceptIncomingCall(state.sdpOffer)
@@ -199,9 +200,9 @@ class CallActivity : AppCompatActivity() {
// We must NOT hang up when the user simply presses Home from the full-screen // We must NOT hang up when the user simply presses Home from the full-screen
// call UI (that enters PiP via onUserLeaveHint instead). // call UI (that enters PiP via onUserLeaveHint instead).
if (wasInPipMode && !isInPictureInPictureMode) { if (wasInPipMode && !isInPictureInPictureMode) {
val state = ActiveCallHolder.callManager?.state?.value val state = CallSessionBridge.callManager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) { if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() } GlobalScope.launch { CallSessionBridge.callManager?.hangup() }
} }
finishAndRemoveTask() finishAndRemoveTask()
} }
@@ -213,7 +214,7 @@ class CallActivity : AppCompatActivity() {
// Safety net: if the Activity is destroyed while a call is still // Safety net: if the Activity is destroyed while a call is still
// ringing/offering, ensure the call is hung up so audio stops. // ringing/offering, ensure the call is hung up so audio stops.
val manager = ActiveCallHolder.callManager val manager = CallSessionBridge.callManager
when (manager?.state?.value) { when (manager?.state?.value) {
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
GlobalScope.launch { manager.rejectCall() } GlobalScope.launch { manager.rejectCall() }
@@ -243,7 +244,7 @@ class CallActivity : AppCompatActivity() {
private fun enterPipIfActive() { private fun enterPipIfActive() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val callManager = ActiveCallHolder.callManager ?: return val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value val state = callManager.state.value
val isActive = val isActive =
state is CallState.Connected || state is CallState.Connected ||
@@ -283,7 +284,7 @@ class CallActivity : AppCompatActivity() {
} }
private fun computePipAspectRatio(): Rational { private fun computePipAspectRatio(): Rational {
val controller = ActiveCallHolder.callController ?: return Rational(9, 16) val controller = CallSessionBridge.callController ?: return Rational(9, 16)
val isVideoActive = controller.isRemoteVideoActive.value val isVideoActive = controller.isRemoteVideoActive.value
val videoRatio = controller.remoteVideoAspectRatio.value val videoRatio = controller.remoteVideoAspectRatio.value
@@ -302,7 +303,7 @@ class CallActivity : AppCompatActivity() {
val actions = mutableListOf<RemoteAction>() val actions = mutableListOf<RemoteAction>()
// Mute / Unmute toggle // Mute / Unmute toggle
val isMuted = ActiveCallHolder.callController?.isAudioMuted?.value == true val isMuted = CallSessionBridge.callController?.isAudioMuted?.value == true
val muteIntent = val muteIntent =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
this, this,
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.call package com.vitorpamplona.amethyst.ui.call
import android.view.WindowManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -29,9 +28,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -40,66 +37,33 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BluetoothAudio
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd 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.PersonAdd
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.AlertDialog
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Snackbar import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState 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.service.call.CallController
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable @Composable
fun CallScreen( fun CallScreen(
@@ -124,8 +88,6 @@ fun CallScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when (val state = callState) { when (val state = callState) {
is CallState.Idle -> { is CallState.Idle -> {
// Wait briefly — initiateCall runs async and state may not have
// transitioned yet when navigating to this screen
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(500) delay(500)
if (callManager.state.value is CallState.Idle) { if (callManager.state.value is CallState.Idle) {
@@ -235,8 +197,7 @@ fun CallScreen(
action = { action = {
Text( Text(
stringRes(R.string.call_dismiss), stringRes(R.string.call_dismiss),
modifier = modifier = Modifier.padding(8.dp),
Modifier.padding(8.dp),
color = MaterialTheme.colorScheme.inversePrimary, color = MaterialTheme.colorScheme.inversePrimary,
) )
}, },
@@ -380,746 +341,3 @@ private fun IncomingCallUI(
} }
} }
} }
@Composable
private fun ConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
accountViewModel: AccountViewModel,
onHangup: () -> Unit,
onToggleMute: () -> Unit,
onToggleVideo: () -> Unit,
onCycleAudioRoute: () -> Unit,
onInvitePeer: (String) -> 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 emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).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 isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
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()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled ||
remoteVideoTracks.isNotEmpty()
var showAddParticipant by remember { mutableStateOf(false) }
if (showAddParticipant) {
AddParticipantDialog(
accountViewModel = accountViewModel,
existingPeers = state.allPeerPubKeys,
onInvite = { peerPubKey ->
onInvitePeer(peerPubKey)
showAddParticipant = false
},
onDismiss = { showAddParticipant = false },
)
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
val otherMembers =
remember(state.allPeerPubKeys) {
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
}
if (hasActiveVideo) {
// Video call: always show the peer grid with video for active peers, avatar for inactive
PeerVideoGrid(
peerPubKeys = otherMembers,
remoteVideoTracks = remoteVideoTracks,
activePeerVideos = activePeerVideos,
eglBase = callController?.getEglBase(),
accountViewModel = accountViewModel,
modifier = Modifier.fillMaxSize(),
)
// 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)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(16.dp),
mirror = true,
)
}
}
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 14.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 16.dp),
)
if (state.pendingPeerPubKeys.isNotEmpty()) {
Text(
text = stringRes(R.string.call_waiting_for_others),
color = Color.White.copy(alpha = 0.5f),
fontSize = 13.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 38.dp),
)
}
} else {
// Voice call: show avatars and names
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = otherMembers,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
GroupCallNames(
peerPubKeys = otherMembers,
accountViewModel = accountViewModel,
textColor = Color.White,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 16.sp,
)
if (state.pendingPeerPubKeys.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.call_waiting_for_others),
color = Color.White.copy(alpha = 0.5f),
fontSize = 13.sp,
)
}
}
}
// Controls at bottom
Column(
modifier =
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(bottom = 24.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.AutoMirrored.Filled.VolumeOff
AudioRoute.SPEAKER -> Icons.AutoMirrored.Filled.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),
)
}
IconButton(
onClick = { showAddParticipant = true },
modifier = Modifier.size(56.dp),
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = stringRes(R.string.call_add_participant),
tint = Color.White,
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 PeerVideoGrid(
peerPubKeys: Set<String>,
remoteVideoTracks: Map<String, VideoTrack>,
activePeerVideos: Set<String>,
eglBase: org.webrtc.EglBase?,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val peers = remember(peerPubKeys) { peerPubKeys.toList() }
if (peers.size == 1) {
val peerKey = peers[0]
val track = remoteVideoTracks[peerKey]
if (track != null && peerKey in activePeerVideos) {
VideoRenderer(
videoTrack = track,
eglBase = eglBase,
modifier = modifier,
mirror = false,
)
} else {
PeerAvatarCell(
peerPubKey = peerKey,
accountViewModel = accountViewModel,
modifier = modifier,
)
}
} else {
val columns =
when {
peers.size <= 2 -> 1
else -> 2
}
Column(modifier = modifier) {
peers.chunked(columns).forEach { row ->
Row(
modifier = Modifier.weight(1f).fillMaxWidth(),
) {
row.forEach { peerKey ->
val track = remoteVideoTracks[peerKey]
if (track != null && peerKey in activePeerVideos) {
VideoRenderer(
videoTrack = track,
eglBase = eglBase,
modifier = Modifier.weight(1f).fillMaxHeight(),
mirror = false,
)
} else {
PeerAvatarCell(
peerPubKey = peerKey,
accountViewModel = accountViewModel,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
}
repeat(columns - row.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
private fun PeerAvatarCell(
peerPubKey: String,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.background(Color.DarkGray),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 80.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(8.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = Color.White,
)
}
}
}
}
}
@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 PipCallUI(
peerPubKeys: Set<String>,
statusText: String,
accountViewModel: AccountViewModel,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = peerPubKeys,
size = 48.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
}
@Composable
private fun PipConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
accountViewModel: AccountViewModel,
) {
var elapsed by remember { mutableLongStateOf(0L) }
LaunchedEffect(state.startedAtEpoch) {
while (true) {
elapsed = TimeUtils.now() - state.startedAtEpoch
delay(1000)
}
}
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultFalse).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled ||
remoteVideoTracks.isNotEmpty()
val otherMembers =
remember(state.allPeerPubKeys) {
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
if (hasActiveVideo) {
// Video active: show first active peer's video or avatar
val firstActivePeer = otherMembers.firstOrNull { it in activePeerVideos }
val activeTrack = firstActivePeer?.let { remoteVideoTracks[it] }
if (activeTrack != null) {
VideoRenderer(
videoTrack = activeTrack,
eglBase = callController?.getEglBase(),
modifier = Modifier.fillMaxSize(),
mirror = false,
)
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = otherMembers,
size = 48.dp,
accountViewModel = accountViewModel,
)
}
}
} else if (!isRemoteVideoActive) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = otherMembers,
size = 48.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
)
}
}
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
}
}
@Composable
private fun AddParticipantDialog(
accountViewModel: AccountViewModel,
existingPeers: Set<String>,
onInvite: (String) -> Unit,
onDismiss: () -> Unit,
) {
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
var searchText by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.call_add_participant)) },
text = {
Column {
OutlinedTextField(
value = searchText,
onValueChange = {
searchText = it
userSuggestions.processCurrentWord(it)
},
label = { Text(stringRes(R.string.call_search_users)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Box(modifier = Modifier.height(300.dp)) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
if (user.pubkeyHex !in existingPeers) {
onInvite(user.pubkeyHex)
}
},
accountViewModel = accountViewModel,
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.call_dismiss))
}
},
)
}
@Composable
private fun GroupCallPictures(
peerPubKeys: Set<String>,
size: Dp,
accountViewModel: AccountViewModel,
) {
val userList = remember(peerPubKeys) { peerPubKeys.toList() }
val displayCount = minOf(userList.size, 4)
val remaining = userList.size - displayCount
when (userList.size) {
0 -> {}
1 -> {
LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = size,
accountViewModel = accountViewModel,
)
}
}
}
else -> {
Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) {
when (displayCount) {
2 -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(1.5f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(1.5f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterEnd),
)
}
3 -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.TopCenter),
)
BaseUserPicture(
baseUserHex = userList[2],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomEnd),
)
}
else -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopStart),
)
BaseUserPicture(
baseUserHex = userList[2],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomEnd),
)
if (remaining > 0) {
Box(
modifier =
Modifier
.size(size.div(2f))
.align(Alignment.TopEnd)
.background(
MaterialTheme.colorScheme.surfaceVariant,
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = (size.value / 5).sp,
fontWeight = FontWeight.Bold,
)
}
} else {
BaseUserPicture(
baseUserHex = userList[3],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopEnd),
)
}
}
}
}
}
}
}
@Composable
private fun GroupCallNames(
peerPubKeys: Set<String>,
accountViewModel: AccountViewModel,
textColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val userList = remember(peerPubKeys) { peerPubKeys.toList() }
when (userList.size) {
0 -> {}
1 -> {
LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = textColor,
)
}
}
}
else -> {
val displayCount = minOf(userList.size, 2)
val remaining = userList.size - displayCount
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
for (i in 0 until displayCount) {
if (i > 0) {
Text(
text = ", ",
fontWeight = FontWeight.Bold,
color = textColor,
)
}
LoadUser(baseUserHex = userList[i], accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = textColor,
)
}
}
}
if (remaining > 0) {
Text(
text = " +$remaining",
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@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)
}
}
}
@@ -0,0 +1,380 @@
/*
* 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.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.fillMaxHeight
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
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.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
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 org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable
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()
},
)
}
@Composable
fun PeerVideoGrid(
peerPubKeys: Set<String>,
remoteVideoTracks: Map<String, VideoTrack>,
activePeerVideos: Set<String>,
eglBase: org.webrtc.EglBase?,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
val peers = remember(peerPubKeys) { peerPubKeys.toList() }
if (peers.size == 1) {
val peerKey = peers[0]
val track = remoteVideoTracks[peerKey]
if (track != null && peerKey in activePeerVideos) {
VideoRenderer(
videoTrack = track,
eglBase = eglBase,
modifier = modifier,
mirror = false,
)
} else {
PeerAvatarCell(
peerPubKey = peerKey,
accountViewModel = accountViewModel,
modifier = modifier,
)
}
} else {
val columns =
when {
peers.size <= 2 -> 1
else -> 2
}
Column(modifier = modifier) {
peers.chunked(columns).forEach { row ->
Row(
modifier = Modifier.weight(1f).fillMaxWidth(),
) {
row.forEach { peerKey ->
val track = remoteVideoTracks[peerKey]
if (track != null && peerKey in activePeerVideos) {
VideoRenderer(
videoTrack = track,
eglBase = eglBase,
modifier = Modifier.weight(1f).fillMaxHeight(),
mirror = false,
)
} else {
PeerAvatarCell(
peerPubKey = peerKey,
accountViewModel = accountViewModel,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
}
repeat(columns - row.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
}
@Composable
fun PeerAvatarCell(
peerPubKey: String,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.background(Color.DarkGray),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 80.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(8.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = Color.White,
)
}
}
}
}
}
@Composable
fun GroupCallPictures(
peerPubKeys: Set<String>,
size: Dp,
accountViewModel: AccountViewModel,
) {
val userList = remember(peerPubKeys) { peerPubKeys.toList() }
val displayCount = minOf(userList.size, 4)
val remaining = userList.size - displayCount
when (userList.size) {
0 -> {}
1 -> {
LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = size,
accountViewModel = accountViewModel,
)
}
}
}
else -> {
Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) {
when (displayCount) {
2 -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(1.5f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(1.5f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterEnd),
)
}
3 -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.TopCenter),
)
BaseUserPicture(
baseUserHex = userList[2],
size = size.div(1.8f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomEnd),
)
}
else -> {
BaseUserPicture(
baseUserHex = userList[0],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomStart),
)
BaseUserPicture(
baseUserHex = userList[1],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopStart),
)
BaseUserPicture(
baseUserHex = userList[2],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomEnd),
)
if (remaining > 0) {
Box(
modifier =
Modifier
.size(size.div(2f))
.align(Alignment.TopEnd)
.background(
MaterialTheme.colorScheme.surfaceVariant,
CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = (size.value / 5).sp,
fontWeight = FontWeight.Bold,
)
}
} else {
BaseUserPicture(
baseUserHex = userList[3],
size = size.div(2f),
accountViewModel = accountViewModel,
outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopEnd),
)
}
}
}
}
}
}
}
@Composable
fun GroupCallNames(
peerPubKeys: Set<String>,
accountViewModel: AccountViewModel,
textColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val userList = remember(peerPubKeys) { peerPubKeys.toList() }
when (userList.size) {
0 -> {}
1 -> {
LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = textColor,
)
}
}
}
else -> {
val displayCount = minOf(userList.size, 2)
val remaining = userList.size - displayCount
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
for (i in 0 until displayCount) {
if (i > 0) {
Text(
text = ", ",
fontWeight = FontWeight.Bold,
color = textColor,
)
}
LoadUser(baseUserHex = userList[i], accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = textColor,
)
}
}
}
if (remaining > 0) {
Text(
text = " +$remaining",
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
)
}
}
}
}
}
fun formatDuration(seconds: Long): String {
val mins = seconds / 60
val secs = seconds % 60
return "%02d:%02d".format(mins, secs)
}
@Composable
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)
}
}
}
@@ -0,0 +1,379 @@
/*
* 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.BluetoothAudio
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.PersonAdd
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.mutableStateOf
import androidx.compose.runtime.remember
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.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
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.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import org.webrtc.VideoTrack
@Composable
fun ConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
accountViewModel: AccountViewModel,
onHangup: () -> Unit,
onToggleMute: () -> Unit,
onToggleVideo: () -> Unit,
onCycleAudioRoute: () -> Unit,
onInvitePeer: (String) -> 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 emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).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 isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled ||
remoteVideoTracks.isNotEmpty()
var showAddParticipant by remember { mutableStateOf(false) }
if (showAddParticipant) {
AddParticipantDialog(
accountViewModel = accountViewModel,
existingPeers = state.allPeerPubKeys,
onInvite = { peerPubKey ->
onInvitePeer(peerPubKey)
showAddParticipant = false
},
onDismiss = { showAddParticipant = false },
)
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
val otherMembers =
remember(state.allPeerPubKeys) {
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
}
if (hasActiveVideo) {
PeerVideoGrid(
peerPubKeys = otherMembers,
remoteVideoTracks = remoteVideoTracks,
activePeerVideos = activePeerVideos,
eglBase = callController?.getEglBase(),
accountViewModel = accountViewModel,
modifier = Modifier.fillMaxSize(),
)
if (isVideoEnabled) {
localVideoTrack?.let { track ->
VideoRenderer(
videoTrack = track,
eglBase = callController?.getEglBase(),
modifier =
Modifier
.size(120.dp, 160.dp)
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(16.dp),
mirror = true,
)
}
}
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 14.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 16.dp),
)
if (state.pendingPeerPubKeys.isNotEmpty()) {
Text(
text = stringRes(R.string.call_waiting_for_others),
color = Color.White.copy(alpha = 0.5f),
fontSize = 13.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 38.dp),
)
}
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = otherMembers,
size = 120.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(16.dp))
GroupCallNames(
peerPubKeys = otherMembers,
accountViewModel = accountViewModel,
textColor = Color.White,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 16.sp,
)
if (state.pendingPeerPubKeys.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.call_waiting_for_others),
color = Color.White.copy(alpha = 0.5f),
fontSize = 13.sp,
)
}
}
}
// Controls at bottom
CallControls(
isAudioMuted = isAudioMuted,
isVideoEnabled = isVideoEnabled,
currentAudioRoute = currentAudioRoute,
onToggleMute = onToggleMute,
onToggleVideo = onToggleVideo,
onCycleAudioRoute = onCycleAudioRoute,
onAddParticipant = { showAddParticipant = true },
onHangup = onHangup,
modifier =
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(bottom = 24.dp),
)
}
}
@Composable
private fun CallControls(
isAudioMuted: Boolean,
isVideoEnabled: Boolean,
currentAudioRoute: AudioRoute,
onToggleMute: () -> Unit,
onToggleVideo: () -> Unit,
onCycleAudioRoute: () -> Unit,
onAddParticipant: () -> Unit,
onHangup: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
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.AutoMirrored.Filled.VolumeOff
AudioRoute.SPEAKER -> Icons.AutoMirrored.Filled.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),
)
}
IconButton(onClick = onAddParticipant, modifier = Modifier.size(56.dp)) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = stringRes(R.string.call_add_participant),
tint = Color.White,
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 AddParticipantDialog(
accountViewModel: AccountViewModel,
existingPeers: Set<String>,
onInvite: (String) -> Unit,
onDismiss: () -> Unit,
) {
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
var searchText by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.call_add_participant)) },
text = {
Column {
OutlinedTextField(
value = searchText,
onValueChange = {
searchText = it
userSuggestions.processCurrentWord(it)
},
label = { Text(stringRes(R.string.call_search_users)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Box(modifier = Modifier.height(300.dp)) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
if (user.pubkeyHex !in existingPeers) {
onInvite(user.pubkeyHex)
}
},
accountViewModel = accountViewModel,
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.call_dismiss))
}
},
)
}
@@ -0,0 +1,175 @@
/*
* 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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import org.webrtc.VideoTrack
@Composable
fun PipCallUI(
peerPubKeys: Set<String>,
statusText: String,
accountViewModel: AccountViewModel,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = peerPubKeys,
size = 48.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 10.sp,
)
}
}
}
@Composable
fun PipConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
accountViewModel: AccountViewModel,
) {
var elapsed by remember { mutableLongStateOf(0L) }
LaunchedEffect(state.startedAtEpoch) {
while (true) {
elapsed = TimeUtils.now() - state.startedAtEpoch
delay(1000)
}
}
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultFalse).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled ||
remoteVideoTracks.isNotEmpty()
val otherMembers =
remember(state.allPeerPubKeys) {
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
}
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black),
) {
if (hasActiveVideo) {
val firstActivePeer = otherMembers.firstOrNull { it in activePeerVideos }
val activeTrack = firstActivePeer?.let { remoteVideoTracks[it] }
if (activeTrack != null) {
VideoRenderer(
videoTrack = activeTrack,
eglBase = callController?.getEglBase(),
modifier = Modifier.fillMaxSize(),
mirror = false,
)
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = otherMembers,
size = 48.dp,
accountViewModel = accountViewModel,
)
}
}
} else if (!isRemoteVideoActive) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = otherMembers,
size = 48.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
)
}
}
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
}
}
@@ -41,13 +41,13 @@ 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.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
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.actions.paymentTargets.PaymentTargetsScreen import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder
import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.call.CallActivity
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
@@ -184,7 +184,7 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
LaunchedEffect(callState) { LaunchedEffect(callState) {
val state = callState val state = callState
if (state is CallState.IncomingCall) { if (state is CallState.IncomingCall) {
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
CallActivity.launch(context) CallActivity.launch(context)
} }
} }
@@ -229,9 +229,9 @@ class AccountViewModel(
callManager.onPeerLeft = { peerPubKey -> controller.disposePeerSession(peerPubKey) } callManager.onPeerLeft = { peerPubKey -> controller.disposePeerSession(peerPubKey) }
callController = controller callController = controller
// Populate ActiveCallHolder so CallActivity can launch even when the app // Populate CallSessionBridge so CallActivity can launch even when the app
// is in the background (e.g. full-screen incoming call intent). // is in the background (e.g. full-screen incoming call intent).
com.vitorpamplona.amethyst.ui.call.ActiveCallHolder com.vitorpamplona.amethyst.service.call.CallSessionBridge
.set(callManager, controller, this) .set(callManager, controller, this)
} }
@@ -27,7 +27,7 @@ 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 androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -52,13 +52,13 @@ fun ChatroomScreen(
val isCallSupported = roomId.users.size <= 5 val isCallSupported = roomId.users.size <= 5
val startVoiceCall = val startVoiceCall =
rememberCallWithPermission(context) { rememberCallWithPermission(context) {
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE) accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE)
CallActivity.launch(context) CallActivity.launch(context)
} }
val startVideoCall = val startVideoCall =
rememberCallWithPermission(context, isVideo = true) { rememberCallWithPermission(context, isVideo = true) {
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VIDEO) accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VIDEO)
CallActivity.launch(context) CallActivity.launch(context)
} }
@@ -41,6 +41,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class CallManager( class CallManager(
private val signer: NostrSigner, private val signer: NostrSigner,
@@ -50,6 +52,12 @@ class CallManager(
) { ) {
private val factory = WebRtcCallFactory() private val factory = WebRtcCallFactory()
/** Serializes all state-mutating operations. Signaling events can arrive
* from multiple relay coroutines concurrently; without this lock a hangup
* and an answer could race, causing the answer to overwrite an Ended
* transition and leaking WebRTC resources. */
private val stateMutex = Mutex()
private val _state = MutableStateFlow<CallState>(CallState.Idle) private val _state = MutableStateFlow<CallState>(CallState.Idle)
val state: StateFlow<CallState> = _state.asStateFlow() val state: StateFlow<CallState> = _state.asStateFlow()
@@ -69,13 +77,13 @@ class CallManager(
private var timeoutJob: Job? = null private var timeoutJob: Job? = null
private var resetJob: Job? = null private var resetJob: Job? = null
private val processedEventIds = mutableSetOf<String>() private val processedEventIds = LinkedHashSet<String>()
/** Call IDs for which we have seen a hangup, reject, or answer-elsewhere /** Call IDs for which we have seen a hangup, reject, or answer-elsewhere
* signal. Checked before transitioning to [CallState.IncomingCall] so * signal. Checked before transitioning to [CallState.IncomingCall] so
* that stale offer events replayed by relays after an app restart do not * that stale offer events replayed by relays after an app restart do not
* trigger ringing for calls that already ended. */ * trigger ringing for calls that already ended. */
private val completedCallIds = mutableSetOf<String>() private val completedCallIds = LinkedHashSet<String>()
/** Timestamp (epoch seconds) when this CallManager was created. Events /** Timestamp (epoch seconds) when this CallManager was created. Events
* created before this are from a previous app session and should not * created before this are from a previous app session and should not
@@ -90,6 +98,23 @@ class CallManager(
const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout 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 ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting
const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this
const val MAX_PROCESSED_EVENT_IDS = 2_000 // cap dedup set to prevent unbounded growth
const val MAX_COMPLETED_CALL_IDS = 200 // cap completed-call set
}
/** Adds [value] to a [LinkedHashSet], evicting the oldest entries when
* the set exceeds [maxSize]. Insertion-order iteration of LinkedHashSet
* ensures oldest entries are removed first. */
private fun <T> cappedAdd(
set: LinkedHashSet<T>,
value: T,
maxSize: Int,
) {
set.add(value)
while (set.size > maxSize) {
val oldest = set.iterator().next()
set.remove(oldest)
}
} }
private fun isEventTooOld(event: Event): Boolean { private fun isEventTooOld(event: Event): Boolean {
@@ -109,11 +134,11 @@ class CallManager(
* Sets state to Offering. Called by CallController before creating * Sets state to Offering. Called by CallController before creating
* per-peer offers in group calls. * per-peer offers in group calls.
*/ */
fun beginOffering( suspend fun beginOffering(
callId: String, callId: String,
calleePubKeys: Set<HexKey>, calleePubKeys: Set<HexKey>,
callType: CallType, callType: CallType,
) { ) = stateMutex.withLock {
_state.value = CallState.Offering(callId, calleePubKeys, callType) _state.value = CallState.Offering(callId, calleePubKeys, callType)
startTimeout(callId) startTimeout(callId)
} }
@@ -237,15 +262,22 @@ class CallManager(
} }
suspend fun acceptCall(sdpAnswer: String) { suspend fun acceptCall(sdpAnswer: String) {
val current = _state.value val current: CallState.IncomingCall
if (current !is CallState.IncomingCall) { val discovered: Set<HexKey>
Log.d("CallManager") { "acceptCall: state is ${current::class.simpleName}, not IncomingCall — ignoring" } stateMutex.withLock {
return val s = _state.value
} if (s !is CallState.IncomingCall) {
Log.d("CallManager") { "acceptCall: state is ${s::class.simpleName}, not IncomingCall — ignoring" }
return
}
current = s
Log.d("CallManager") { "acceptCall: callId=${current.callId}, transitioning to Connecting, sdpAnswerLength=${sdpAnswer.length}" } Log.d("CallManager") { "acceptCall: callId=${current.callId}, transitioning to Connecting, sdpAnswerLength=${sdpAnswer.length}" }
_state.value = CallState.Connecting(current.callId, current.peerPubKeys() - signer.pubKey, current.callType) _state.value = CallState.Connecting(current.callId, current.peerPubKeys() - signer.pubKey, current.callType)
cancelTimeout() cancelTimeout()
discovered = discoveredCalleePeers.toSet()
discoveredCalleePeers.clear()
}
val allRecipients = current.groupMembers + signer.pubKey val allRecipients = current.groupMembers + signer.pubKey
Log.d("CallManager") { "acceptCall: publishing answer to ${allRecipients.size} recipients" } Log.d("CallManager") { "acceptCall: publishing answer to ${allRecipients.size} recipients" }
@@ -255,8 +287,6 @@ class CallManager(
// Trigger callee-to-callee mesh connections with peers we discovered // Trigger callee-to-callee mesh connections with peers we discovered
// while still ringing (their answers arrived before we accepted). // while still ringing (their answers arrived before we accepted).
val discovered = discoveredCalleePeers.toSet()
discoveredCalleePeers.clear()
if (discovered.isNotEmpty()) { if (discovered.isNotEmpty()) {
Log.d("CallManager") { "acceptCall: triggering mesh setup with ${discovered.size} discovered peers: ${discovered.map { it.take(8) }}" } Log.d("CallManager") { "acceptCall: triggering mesh setup with ${discovered.size} discovered peers: ${discovered.map { it.take(8) }}" }
for (peer in discovered) { for (peer in discovered) {
@@ -266,10 +296,13 @@ class CallManager(
} }
suspend fun rejectCall() { suspend fun rejectCall() {
val current = _state.value val current: CallState.IncomingCall
if (current !is CallState.IncomingCall) return stateMutex.withLock {
val s = _state.value
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED) if (s !is CallState.IncomingCall) return
current = s
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
}
val allRecipients = current.groupMembers + signer.pubKey val allRecipients = current.groupMembers + signer.pubKey
val result = factory.createGroupReject(allRecipients, current.callId, signer = signer) val result = factory.createGroupReject(allRecipients, current.callId, signer = signer)
@@ -455,22 +488,24 @@ class CallManager(
publishEvent(result.wrap) publishEvent(result.wrap)
} }
fun onPeerConnected() { suspend fun onPeerConnected() {
val current = _state.value stateMutex.withLock {
if (current !is CallState.Connecting) { val current = _state.value
Log.d("CallManager") { "onPeerConnected: state is ${current::class.simpleName}, not Connecting — ignoring" } if (current !is CallState.Connecting) {
return Log.d("CallManager") { "onPeerConnected: state is ${current::class.simpleName}, not Connecting — ignoring" }
} return
}
Log.d("CallManager") { "onPeerConnected: Connecting -> Connected! callId=${current.callId}" } Log.d("CallManager") { "onPeerConnected: Connecting -> Connected! callId=${current.callId}" }
_state.value = _state.value =
CallState.Connected( CallState.Connected(
callId = current.callId, callId = current.callId,
peerPubKeys = current.peerPubKeys, peerPubKeys = current.peerPubKeys,
callType = current.callType, callType = current.callType,
startedAtEpoch = TimeUtils.now(), startedAtEpoch = TimeUtils.now(),
pendingPeerPubKeys = current.pendingPeerPubKeys, pendingPeerPubKeys = current.pendingPeerPubKeys,
) )
}
} }
suspend fun invitePeer( suspend fun invitePeer(
@@ -509,31 +544,33 @@ class CallManager(
suspend fun hangup() { suspend fun hangup() {
val peerPubKeys: Set<HexKey> val peerPubKeys: Set<HexKey>
val callId: String val callId: String
when (val current = _state.value) { stateMutex.withLock {
is CallState.Offering -> { when (val current = _state.value) {
peerPubKeys = current.peerPubKeys is CallState.Offering -> {
callId = current.callId peerPubKeys = current.peerPubKeys
callId = current.callId
}
is CallState.Connecting -> {
peerPubKeys = current.peerPubKeys + current.pendingPeerPubKeys
callId = current.callId
}
is CallState.Connected -> {
peerPubKeys = current.allPeerPubKeys
callId = current.callId
}
else -> {
return
}
} }
is CallState.Connecting -> { // Transition immediately so the UI stops ringing/ringback before
peerPubKeys = current.peerPubKeys + current.pendingPeerPubKeys // the (potentially slow) signing + relay publish completes.
callId = current.callId transitionToEnded(callId, peerPubKeys, EndReason.HANGUP)
}
is CallState.Connected -> {
peerPubKeys = current.allPeerPubKeys
callId = current.callId
}
else -> {
return
}
} }
// Transition immediately so the UI stops ringing/ringback before
// the (potentially slow) signing + relay publish completes.
transitionToEnded(callId, peerPubKeys, EndReason.HANGUP)
val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer) val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer)
result.wraps.forEach { publishEvent(it) } result.wraps.forEach { publishEvent(it) }
} }
@@ -613,47 +650,51 @@ class CallManager(
} }
} }
fun onSignalingEvent(event: Event) { suspend fun onSignalingEvent(event: Event) {
if (isEventTooOld(event)) { if (isEventTooOld(event)) {
Log.d("CallManager") { "Discarding old event kind=${event.kind} age=${TimeUtils.now() - event.createdAt}s" } Log.d("CallManager") { "Discarding old event kind=${event.kind} age=${TimeUtils.now() - event.createdAt}s" }
return return
} }
if (!processedEventIds.add(event.id)) return
// Filter out our own ICE candidates and hangups echoed back from relays. stateMutex.withLock {
// These are never useful: ICE candidates are for the remote peer, and if (event.id in processedEventIds) return
// hangups are already handled locally by hangup() → transitionToEnded. cappedAdd(processedEventIds, event.id, MAX_PROCESSED_EVENT_IDS)
// Self-answers and self-rejects are NOT filtered here because they serve
// as "answered/rejected elsewhere" signals when in IncomingCall state.
if (event.pubKey == signer.pubKey && (event is CallIceCandidateEvent || event is CallHangupEvent)) {
Log.d("CallManager") { "Ignoring self-event kind=${event.kind} id=${event.id.take(8)}" }
return
}
Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" } // Filter out our own ICE candidates and hangups echoed back from relays.
// These are never useful: ICE candidates are for the remote peer, and
// Record call-ids from termination signals so that a later offer // hangups are already handled locally by hangup() → transitionToEnded.
// for the same call is recognised as stale (common after app restart // Self-answers and self-rejects are NOT filtered here because they serve
// when relay replays events out of order). // as "answered/rejected elsewhere" signals when in IncomingCall state.
if (event is CallHangupEvent || event is CallRejectEvent) { if (event.pubKey == signer.pubKey && (event is CallIceCandidateEvent || event is CallHangupEvent)) {
val terminatedCallId = Log.d("CallManager") { "Ignoring self-event kind=${event.kind} id=${event.id.take(8)}" }
when (event) { return
is CallHangupEvent -> event.callId()
is CallRejectEvent -> event.callId()
else -> null
}
if (terminatedCallId != null) {
completedCallIds.add(terminatedCallId)
} }
}
when (event) { Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" }
is CallOfferEvent -> onIncomingCallEvent(event)
is CallAnswerEvent -> onCallAnswered(event) // Record call-ids from termination signals so that a later offer
is CallRejectEvent -> onCallRejected(event) // for the same call is recognised as stale (common after app restart
is CallHangupEvent -> onPeerHangup(event) // when relay replays events out of order).
is CallIceCandidateEvent -> onIceCandidate(event) if (event is CallHangupEvent || event is CallRejectEvent) {
is CallRenegotiateEvent -> onRenegotiate(event) val terminatedCallId =
when (event) {
is CallHangupEvent -> event.callId()
is CallRejectEvent -> event.callId()
else -> null
}
if (terminatedCallId != null) {
cappedAdd(completedCallIds, terminatedCallId, MAX_COMPLETED_CALL_IDS)
}
}
when (event) {
is CallOfferEvent -> onIncomingCallEvent(event)
is CallAnswerEvent -> onCallAnswered(event)
is CallRejectEvent -> onCallRejected(event)
is CallHangupEvent -> onPeerHangup(event)
is CallIceCandidateEvent -> onIceCandidate(event)
is CallRenegotiateEvent -> onRenegotiate(event)
}
} }
} }
@@ -704,7 +745,8 @@ class CallManager(
peerPubKeys: Set<HexKey>, peerPubKeys: Set<HexKey>,
reason: EndReason, reason: EndReason,
) { ) {
completedCallIds.add(callId) cappedAdd(completedCallIds, callId, MAX_COMPLETED_CALL_IDS)
discoveredCalleePeers.clear()
_state.value = CallState.Ended(callId, peerPubKeys, reason) _state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout() cancelTimeout()
resetJob?.cancel() resetJob?.cancel()