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" />
<receiver
android:name=".ui.call.CallNotificationReceiver"
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
</application>
@@ -22,10 +22,6 @@ package com.vitorpamplona.amethyst.service.call
import android.content.Context
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.CallManager
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.SdpType
import com.vitorpamplona.amethyst.commons.call.SignalingState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
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.nipACWebRtcCalls.WebRtcCallFactory
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.webrtc.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.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 java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
private const val TAG = "CallController"
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,
localPubKey: HexKey,
) {
// ---- Per-peer session state (delegated to PeerSessionManager) ----
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
// ---- Shared WebRTC resources ----
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 mediaManager = CallMediaManager(context)
val audioManager = CallAudioManager(context)
val videoMonitor = RemoteVideoMonitor(scope)
private val callFactory = WebRtcCallFactory()
// ---- UI-exposed state ----
private val _localVideoTrack = MutableStateFlow<VideoTrack?>(null)
val localVideoTrack: StateFlow<VideoTrack?> = _localVideoTrack.asStateFlow()
// 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()
val localVideoTrack: StateFlow<VideoTrack?> = mediaManager.localVideoTrackFlow
val remoteVideoTrack: StateFlow<VideoTrack?> = videoMonitor.remoteVideoTrack
val remoteVideoTracks: StateFlow<Map<HexKey, VideoTrack>> = videoMonitor.remoteVideoTracks
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _isRemoteVideoActive = MutableStateFlow(false)
val isRemoteVideoActive: StateFlow<Boolean> = _isRemoteVideoActive.asStateFlow()
private val _remoteVideoAspectRatio = MutableStateFlow<Float?>(null)
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()
val isRemoteVideoActive: StateFlow<Boolean> = videoMonitor.isRemoteVideoActive
val remoteVideoAspectRatio: StateFlow<Float?> = videoMonitor.remoteVideoAspectRatio
val activePeerVideos: StateFlow<Set<HexKey>> = videoMonitor.activePeerVideos
private val _isAudioMuted = MutableStateFlow(false)
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
private val _isVideoEnabled = MutableStateFlow(false)
val isVideoEnabled: StateFlow<Boolean> = _isVideoEnabled.asStateFlow()
val isVideoEnabled: StateFlow<Boolean> = mediaManager.isVideoEnabled
val audioRoute: StateFlow<AudioRoute> = audioManager.audioRoute
val isBluetoothAvailable: StateFlow<Boolean> = audioManager.isBluetoothAvailable
@@ -164,13 +102,9 @@ class CallController(
when (state) {
is CallState.IncomingCall -> {
withContext(Dispatchers.IO) { audioManager.startRinging() }
// Launch notification in a separate coroutine so that
// long-running network I/O (profile picture download)
// 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) }
scope.launch {
NotificationUtils.showIncomingCallNotification(state.callerPubKey, context)
}
}
is CallState.Offering -> {
@@ -186,10 +120,6 @@ class CallController(
}
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.stopRingbackTone()
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
@@ -202,10 +132,6 @@ class CallController(
}
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()
}
}
@@ -214,33 +140,23 @@ class CallController(
scope.launch {
audioManager.isNearEar.collect { nearEar ->
val videoTrack = localVideoTrackInternal ?: return@collect
if (nearEar && _isVideoEnabled.value && !videoPausedByProximity) {
val videoTrack = mediaManager.localVideoTrack ?: return@collect
if (nearEar && mediaManager.isVideoEnabled.value && !videoPausedByProximity) {
videoPausedByProximity = true
videoTrack.setEnabled(false)
stopCamera()
mediaManager.stopCamera()
} else if (!nearEar && videoPausedByProximity) {
videoPausedByProximity = false
videoTrack.setEnabled(true)
startCamera()
mediaManager.startCamera()
}
}
}
}
// ---- Call initiation (caller side) ----
fun initiateGroupCall(
peerPubKeys: Set<String>,
callType: CallType,
) {
initiateCallInternal(peerPubKeys, callType)
}
/**
* Creates a separate PeerConnection (and SDP offer) for each callee,
* establishing full-mesh connectivity for group calls.
*/
private fun initiateCallInternal(
fun initiateGroupCall(
peerPubKeys: Set<String>,
callType: CallType,
) {
@@ -250,29 +166,23 @@ class CallController(
_errorMessage.value = null
try {
withContext(Dispatchers.IO) { initializeSharedResources(callType) }
Log.d(TAG) { "initiateCall: shared resources initialized" }
withContext(Dispatchers.IO) { mediaManager.initialize(callType) }
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize WebRTC", e)
_errorMessage.value = "Failed to start call: ${e.message}"
return@launch
}
// Set state to Offering before creating peer sessions
callManager.beginOffering(callId, peerPubKeys, callType)
// Create a PeerConnection + offer for each callee
for (peerPubKey in peerPubKeys) {
try {
val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter)
Log.d(TAG) { "initiateCall: PeerConnection created for ${peerPubKey.take(8)}" }
webRtcSession.createOffer { sdp ->
Log.d(TAG) { "initiateCall: offer created for ${peerPubKey.take(8)}, sdpLength=${sdp.description.length}" }
scope.launch {
callManager.publishOfferToPeer(peerPubKey, peerPubKeys, callType, callId, sdp.description)
Log.d(TAG) { "initiateCall: offer published for ${peerPubKey.take(8)}" }
}
}
} catch (e: Exception) {
@@ -286,20 +196,14 @@ class CallController(
fun acceptIncomingCall(sdpOffer: String) {
val state = callManager.state.value
if (state !is CallState.IncomingCall) {
Log.d(TAG) { "acceptIncomingCall: state is ${state::class.simpleName}, not IncomingCall — ignoring" }
return
}
if (state !is CallState.IncomingCall) return
val callerPubKey = state.callerPubKey
Log.d(TAG) { "acceptIncomingCall: callId=${state.callId}, callType=${state.callType}, sdpOfferLength=${sdpOffer.length}" }
scope.launch {
_errorMessage.value = null
try {
withContext(Dispatchers.IO) { initializeSharedResources(state.callType) }
Log.d(TAG) { "acceptIncomingCall: shared resources initialized" }
withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) }
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize WebRTC", e)
_errorMessage.value = "Failed to accept call: ${e.message}"
@@ -316,19 +220,13 @@ class CallController(
}
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
val entry = peerSessionMgr.registerSession(callerPubKey, adapter)
Log.d(TAG) { "acceptIncomingCall: setting remote description (OFFER)..." }
peerSessionMgr.registerSession(callerPubKey, adapter)
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
Log.d(TAG) { "acceptIncomingCall: flushing ${entry.pendingIceCandidates.size} pending ICE candidates..." }
peerSessionMgr.flushPendingIceCandidates(callerPubKey)
Log.d(TAG) { "acceptIncomingCall: creating answer..." }
webRtcSession.createAnswer { sdp ->
Log.d(TAG) { "acceptIncomingCall: answer created, sdpLength=${sdp.description.length}, publishing..." }
scope.launch {
callManager.acceptCall(sdp.description)
Log.d(TAG) { "acceptIncomingCall: answer published, state=${callManager.state.value::class.simpleName}" }
}
}
}
@@ -336,27 +234,18 @@ class CallController(
// ---- 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(
peerPubKey: HexKey,
sdpAnswer: String,
) {
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
val action = peerSessionMgr.routeAnswer(peerPubKey, sdpAnswer)
Log.d(TAG) { "onCallAnswerReceived: action=$action" }
when (action) {
AnswerRouteAction.APPLIED -> {
Log.d(TAG) { "Answer applied for ${peerPubKey.take(8)}" }
}
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)
}
@@ -368,16 +257,10 @@ class CallController(
// ---- 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) {
try {
val senderPubKey = event.pubKey
val candidate = IceCandidateData(event.candidateSdp(), event.sdpMid(), event.sdpMLineIndex())
val action = peerSessionMgr.routeIceCandidate(senderPubKey, candidate)
Log.d(TAG) { "ICE candidate from ${senderPubKey.take(8)}: $action" }
peerSessionMgr.routeIceCandidate(event.pubKey, candidate)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse ICE candidate", e)
}
@@ -387,10 +270,8 @@ class CallController(
peerPubKey: HexKey,
candidate: IceCandidate,
) {
Log.d(TAG) { "Local ICE candidate for ${peerPubKey.take(8)}: ${candidate.sdp.take(50)}" }
val callId = callManager.currentCallId() ?: return
val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex)
scope.launch {
val signer = signerProvider()
val result = callFactory.createIceCandidate(candidateJson, peerPubKey, callId, signer)
@@ -400,31 +281,17 @@ class CallController(
// ---- 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) {
if (peerSessionMgr.hasSession(peerPubKey)) {
Log.d(TAG) { "onNewPeerInGroupCall: session already exists for ${peerPubKey.take(8)} — skipping" }
return
}
if (peerSessionMgr.hasSession(peerPubKey)) return
scope.launch {
Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, shouldInitiate=${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)
} else {
Log.d(TAG) { "Waiting for callee-to-callee offer from ${peerPubKey.take(8)} (they have lower pubkey)" }
}
}
}
private suspend fun createAndOfferToPeer(peerPubKey: HexKey) {
if (peerConnectionFactory == null) return
if (mediaManager.peerConnectionFactory == null) return
val webRtcSession =
try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
@@ -432,38 +299,20 @@ class CallController(
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
return
}
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter)
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(
peerPubKey: HexKey,
sdpOffer: String,
) {
if (peerSessionMgr.hasSession(peerPubKey)) {
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}" }
if (peerSessionMgr.hasSession(peerPubKey)) return
scope.launch {
if (peerConnectionFactory == null) {
Log.e(TAG, "Mid-call offer but factory not initialized")
return@launch
}
if (mediaManager.peerConnectionFactory == null) return@launch
val webRtcSession =
try {
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)
return@launch
}
val adapter = WebRtcPeerSessionAdapter(webRtcSession)
peerSessionMgr.registerSession(peerPubKey, adapter)
adapter.setRemoteDescription(SdpType.OFFER, sdpOffer)
peerSessionMgr.flushPendingIceCandidates(peerPubKey)
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) {
val peerPubKey = event.pubKey
val sdpOffer = event.sdpOffer()
Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
scope.launch {
val resolution =
peerSessionMgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry ->
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.createAnswer { sdpAnswer ->
scope.launch {
callManager.sendRenegotiationAnswer(sdpAnswer, peerPubKey)
}
scope.launch { callManager.sendRenegotiationAnswer(sdpAnswer, peerPubKey) }
}
}
@@ -519,12 +357,8 @@ class CallController(
val webRtcSession = webRtcSession(peerPubKey) ?: return
val state = callManager.state.value
if (state !is CallState.Connected && state !is CallState.Connecting) return
Log.d(TAG) { "Starting renegotiation with ${peerPubKey.take(8)}" }
webRtcSession.createOffer { sdp ->
scope.launch {
callManager.sendRenegotiation(sdp.description, peerPubKey)
}
scope.launch { callManager.sendRenegotiation(sdp.description, peerPubKey) }
}
}
@@ -533,31 +367,24 @@ class CallController(
fun toggleAudioMute() {
val muted = !_isAudioMuted.value
_isAudioMuted.value = muted
localAudioTrackInternal?.setEnabled(!muted)
mediaManager.setAudioMuted(muted)
}
fun toggleVideo() {
val enabling = !_isVideoEnabled.value
val enabling = !mediaManager.isVideoEnabled.value
if (enabling) {
if (localVideoTrackInternal == null) {
// Voice → video upgrade: create video source/track and add to all sessions
createVideoResources()
if (mediaManager.localVideoTrack == null) {
mediaManager.createVideoResources()
peerSessionMgr.allSessionKeys().forEach { key ->
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 {
localVideoTrackInternal?.setEnabled(true)
startCamera()
mediaManager.enableVideo()
}
_localVideoTrack.value = localVideoTrackInternal
_isVideoEnabled.value = true
} else {
localVideoTrackInternal?.setEnabled(false)
stopCamera()
_isVideoEnabled.value = false
mediaManager.disableVideo()
}
}
@@ -565,98 +392,24 @@ class CallController(
audioManager.cycleAudioRoute()
}
fun getEglBase(): EglBase? = sharedEglBase
fun getEglBase(): EglBase? = mediaManager.sharedEglBase
fun invitePeer(peerPubKey: String) {
scope.launch {
createAndOfferToPeer(peerPubKey)
}
scope.launch { createAndOfferToPeer(peerPubKey) }
}
fun hangup() {
scope.launch {
callManager.hangup()
}
scope.launch { callManager.hangup() }
}
fun clearError() {
_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 ----
private fun createWebRtcSession(peerPubKey: HexKey): WebRtcCallSession {
Log.d(TAG) { "createWebRtcSession: ${peerPubKey.take(8)}, existing sessions=${peerSessionMgr.allSessionKeys().map { it.take(8) }}" }
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
val factory = mediaManager.peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
val session =
WebRtcCallSession(
peerConnectionFactory = factory,
@@ -664,95 +417,28 @@ class CallController(
onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) },
onPeerConnected = {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" }
callManager.onPeerConnected()
scope.launch { callManager.onPeerConnected() }
if (!foregroundServiceStarted) {
foregroundServiceStarted = true
startForegroundService()
}
},
onRemoteVideoTrack = { track -> onRemoteVideoTrack(peerPubKey, track) },
onRemoteVideoTrack = { track -> videoMonitor.onRemoteVideoTrack(peerPubKey, track) },
onDisconnected = { onPeerDisconnected(peerPubKey) },
onError = { error -> _errorMessage.value = error },
onRenegotiationNeeded = { performRenegotiation(peerPubKey) },
)
try {
session.createPeerConnection()
} catch (e: Exception) {
session.dispose()
throw e
}
// Add shared local tracks to this PeerConnection
localAudioTrackInternal?.let { session.addTrack(it) }
localVideoTrackInternal?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
mediaManager.localAudioTrack?.let { session.addTrack(it) }
mediaManager.localVideoTrack?.let { session.addTrack(it, VIDEO_MAX_BITRATE_BPS) }
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) {
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" }
val allDisconnected =
@@ -762,57 +448,27 @@ class CallController(
peerSessionMgr.getSession(key)?.remoteDescriptionSet != true
}
if (allDisconnected) {
Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" }
scope.launch { callManager.hangup() }
} else {
Log.d(TAG) { "onPeerDisconnected: other peers still active, continuing call" }
}
}
// ---- 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) {
val entry = peerSessionMgr.removeSession(peerPubKey)
if (entry != null) {
Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}" }
try {
entry?.session?.dispose()
entry.session.dispose()
} catch (e: Exception) {
Log.e(TAG, "disposePeerSession: dispose() failed for ${peerPubKey.take(8)}", e)
}
// Clean up per-peer video monitor
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)" }
videoMonitor.onPeerRemoved(peerPubKey)
}
}
// ---- 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 {
audioManager.release()
} catch (e: Exception) {
@@ -825,14 +481,9 @@ class CallController(
}
foregroundServiceStarted = false
NotificationUtils.cancelCallNotification(context)
stopRemoteVideoMonitor()
// Clean up per-peer video monitors
for (peerPubKey in perPeerFrameSinks.keys.toList()) {
stopPeerVideoMonitor(peerPubKey)
}
videoMonitor.dispose()
// Dispose all peer sessions
try {
peerSessionMgr.disposeAll()
} catch (e: Exception) {
@@ -840,95 +491,18 @@ class CallController(
}
peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey)
// Dispose shared resources — each in its own try-catch so one
// 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)
}
mediaManager.dispose()
localAudioTrackInternal = null
localVideoTrackInternal = null
localAudioSource = null
localVideoSource = null
peerConnectionFactory = null
sharedEglBase = null
_remoteVideoTrack.value = null
_remoteVideoTracks.value = emptyMap()
_localVideoTrack.value = null
_isAudioMuted.value = false
_isVideoEnabled.value = false
_isRemoteVideoActive.value = false
_remoteVideoAspectRatio.value = null
_activePeerVideos.value = emptySet()
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 ----
private fun startForegroundService() {
try {
val peerName = callManager.currentPeerPubKey() ?: ""
val isVideo = _isVideoEnabled.value
val isVideo = mediaManager.isVideoEnabled.value
val intent =
Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_START
@@ -951,37 +525,4 @@ class CallController(
} 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
* 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.Context
@@ -31,8 +31,9 @@ import kotlinx.coroutines.launch
/**
* Handles the Reject action from the incoming call notification.
*
* The Accept action launches [CallActivity] directly via PendingIntent.getActivity
* to comply with Android 12+ notification trampoline restrictions.
* The Accept action launches [com.vitorpamplona.amethyst.ui.call.CallActivity]
* directly via PendingIntent.getActivity to comply with Android 12+
* notification trampoline restrictions.
*/
class CallNotificationReceiver : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
@@ -44,7 +45,7 @@ class CallNotificationReceiver : BroadcastReceiver() {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
val callManager = ActiveCallHolder.callManager ?: return
val callManager = CallSessionBridge.callManager ?: return
GlobalScope.launch {
callManager.rejectCall()
}
@@ -18,18 +18,17 @@
* 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
package com.vitorpamplona.amethyst.service.call
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Holds references to the active call state so that [CallActivity] can access
* the same [CallManager] and [CallController] owned by [AccountViewModel] in
* the main activity. Both activities run in the same process.
* Process-level singleton that bridges the active call state between
* the main activity (which owns [AccountViewModel]) and [com.vitorpamplona.amethyst.ui.call.CallActivity]
* (which runs in its own window but in the same process).
*/
object ActiveCallHolder {
object CallSessionBridge {
var callManager: CallManager? = null
private set
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.allowHardware
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.MainActivity
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.withContext
@@ -584,8 +587,8 @@ object NotificationUtils {
)
val rejectIntent =
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver::class.java).apply {
action = com.vitorpamplona.amethyst.ui.call.CallNotificationReceiver.ACTION_REJECT_CALL
Intent(applicationContext, com.vitorpamplona.amethyst.service.call.CallNotificationReceiver::class.java).apply {
action = com.vitorpamplona.amethyst.service.call.CallNotificationReceiver.ACTION_REJECT_CALL
}
val rejectPendingIntent =
@@ -623,6 +626,40 @@ object NotificationUtils {
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 {
val notifications: Array<StatusBarNotification> = activeNotifications
for (notification in notifications) {
@@ -39,6 +39,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.lifecycleScope
import com.vitorpamplona.amethyst.R
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.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.StringResSetup
@@ -75,11 +76,11 @@ class CallActivity : AppCompatActivity() {
) {
when (intent.action) {
ACTION_PIP_HANGUP -> {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
GlobalScope.launch { CallSessionBridge.callManager?.hangup() }
}
ACTION_PIP_TOGGLE_MUTE -> {
ActiveCallHolder.callController?.toggleAudioMute()
CallSessionBridge.callController?.toggleAudioMute()
updatePipParams()
}
}
@@ -102,9 +103,9 @@ class CallActivity : AppCompatActivity() {
)
}
val callManager = ActiveCallHolder.callManager
val callController = ActiveCallHolder.callController
val accountViewModel = ActiveCallHolder.accountViewModel
val callManager = CallSessionBridge.callManager
val callController = CallSessionBridge.callController
val accountViewModel = CallSessionBridge.accountViewModel
if (callManager == null || accountViewModel == null) {
finish()
@@ -153,7 +154,7 @@ class CallActivity : AppCompatActivity() {
com.vitorpamplona.amethyst.service.notifications.NotificationUtils
.cancelCallNotification(this)
val callManager = ActiveCallHolder.callManager ?: return
val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value
if (state !is CallState.IncomingCall) return
@@ -168,8 +169,8 @@ class CallActivity : AppCompatActivity() {
}
private fun acceptCall() {
val callController = ActiveCallHolder.callController ?: return
val callManager = ActiveCallHolder.callManager ?: return
val callController = CallSessionBridge.callController ?: return
val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value
if (state is CallState.IncomingCall) {
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
// call UI (that enters PiP via onUserLeaveHint instead).
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) {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
GlobalScope.launch { CallSessionBridge.callManager?.hangup() }
}
finishAndRemoveTask()
}
@@ -213,7 +214,7 @@ class CallActivity : AppCompatActivity() {
// Safety net: if the Activity is destroyed while a call is still
// ringing/offering, ensure the call is hung up so audio stops.
val manager = ActiveCallHolder.callManager
val manager = CallSessionBridge.callManager
when (manager?.state?.value) {
is CallState.IncomingCall -> {
GlobalScope.launch { manager.rejectCall() }
@@ -243,7 +244,7 @@ class CallActivity : AppCompatActivity() {
private fun enterPipIfActive() {
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 isActive =
state is CallState.Connected ||
@@ -283,7 +284,7 @@ class CallActivity : AppCompatActivity() {
}
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 videoRatio = controller.remoteVideoAspectRatio.value
@@ -302,7 +303,7 @@ class CallActivity : AppCompatActivity() {
val actions = mutableListOf<RemoteAction>()
// Mute / Unmute toggle
val isMuted = ActiveCallHolder.callController?.isAudioMuted?.value == true
val isMuted = CallSessionBridge.callController?.isAudioMuted?.value == true
val muteIntent =
PendingIntent.getBroadcast(
this,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.call
import android.view.WindowManager
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -29,9 +28,7 @@ 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.fillMaxHeight
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
@@ -40,66 +37,33 @@ 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.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.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.R
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.AudioRoute
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.note.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.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.webrtc.RendererCommon
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoTrack
@Composable
fun CallScreen(
@@ -124,8 +88,6 @@ fun CallScreen(
Box(modifier = Modifier.fillMaxSize()) {
when (val state = callState) {
is CallState.Idle -> {
// Wait briefly — initiateCall runs async and state may not have
// transitioned yet when navigating to this screen
LaunchedEffect(Unit) {
delay(500)
if (callManager.state.value is CallState.Idle) {
@@ -235,8 +197,7 @@ fun CallScreen(
action = {
Text(
stringRes(R.string.call_dismiss),
modifier =
Modifier.padding(8.dp),
modifier = Modifier.padding(8.dp),
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 com.vitorpamplona.amethyst.R
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.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
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.components.getActivity
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
@@ -184,7 +184,7 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
LaunchedEffect(callState) {
val state = callState
if (state is CallState.IncomingCall) {
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
CallActivity.launch(context)
}
}
@@ -229,9 +229,9 @@ class AccountViewModel(
callManager.onPeerLeft = { peerPubKey -> controller.disposePeerSession(peerPubKey) }
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).
com.vitorpamplona.amethyst.ui.call.ActiveCallHolder
com.vitorpamplona.amethyst.service.call.CallSessionBridge
.set(callManager, controller, this)
}
@@ -27,7 +27,7 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.ui.call.ActiveCallHolder
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -52,13 +52,13 @@ fun ChatroomScreen(
val isCallSupported = roomId.users.size <= 5
val startVoiceCall =
rememberCallWithPermission(context) {
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE)
CallActivity.launch(context)
}
val startVideoCall =
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)
CallActivity.launch(context)
}
@@ -41,6 +41,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class CallManager(
private val signer: NostrSigner,
@@ -50,6 +52,12 @@ class CallManager(
) {
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)
val state: StateFlow<CallState> = _state.asStateFlow()
@@ -69,13 +77,13 @@ class CallManager(
private var timeoutJob: 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
* signal. Checked before transitioning to [CallState.IncomingCall] so
* that stale offer events replayed by relays after an app restart do not
* 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
* 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 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_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 {
@@ -109,11 +134,11 @@ class CallManager(
* Sets state to Offering. Called by CallController before creating
* per-peer offers in group calls.
*/
fun beginOffering(
suspend fun beginOffering(
callId: String,
calleePubKeys: Set<HexKey>,
callType: CallType,
) {
) = stateMutex.withLock {
_state.value = CallState.Offering(callId, calleePubKeys, callType)
startTimeout(callId)
}
@@ -237,15 +262,22 @@ class CallManager(
}
suspend fun acceptCall(sdpAnswer: String) {
val current = _state.value
if (current !is CallState.IncomingCall) {
Log.d("CallManager") { "acceptCall: state is ${current::class.simpleName}, not IncomingCall — ignoring" }
val current: CallState.IncomingCall
val discovered: Set<HexKey>
stateMutex.withLock {
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}" }
_state.value = CallState.Connecting(current.callId, current.peerPubKeys() - signer.pubKey, current.callType)
cancelTimeout()
discovered = discoveredCalleePeers.toSet()
discoveredCalleePeers.clear()
}
val allRecipients = current.groupMembers + signer.pubKey
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
// while still ringing (their answers arrived before we accepted).
val discovered = discoveredCalleePeers.toSet()
discoveredCalleePeers.clear()
if (discovered.isNotEmpty()) {
Log.d("CallManager") { "acceptCall: triggering mesh setup with ${discovered.size} discovered peers: ${discovered.map { it.take(8) }}" }
for (peer in discovered) {
@@ -266,10 +296,13 @@ class CallManager(
}
suspend fun rejectCall() {
val current = _state.value
if (current !is CallState.IncomingCall) return
val current: CallState.IncomingCall
stateMutex.withLock {
val s = _state.value
if (s !is CallState.IncomingCall) return
current = s
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
}
val allRecipients = current.groupMembers + signer.pubKey
val result = factory.createGroupReject(allRecipients, current.callId, signer = signer)
@@ -455,7 +488,8 @@ class CallManager(
publishEvent(result.wrap)
}
fun onPeerConnected() {
suspend fun onPeerConnected() {
stateMutex.withLock {
val current = _state.value
if (current !is CallState.Connecting) {
Log.d("CallManager") { "onPeerConnected: state is ${current::class.simpleName}, not Connecting — ignoring" }
@@ -472,6 +506,7 @@ class CallManager(
pendingPeerPubKeys = current.pendingPeerPubKeys,
)
}
}
suspend fun invitePeer(
peerPubKey: HexKey,
@@ -509,6 +544,7 @@ class CallManager(
suspend fun hangup() {
val peerPubKeys: Set<HexKey>
val callId: String
stateMutex.withLock {
when (val current = _state.value) {
is CallState.Offering -> {
peerPubKeys = current.peerPubKeys
@@ -533,6 +569,7 @@ class CallManager(
// 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)
result.wraps.forEach { publishEvent(it) }
@@ -613,12 +650,15 @@ class CallManager(
}
}
fun onSignalingEvent(event: Event) {
suspend fun onSignalingEvent(event: Event) {
if (isEventTooOld(event)) {
Log.d("CallManager") { "Discarding old event kind=${event.kind} age=${TimeUtils.now() - event.createdAt}s" }
return
}
if (!processedEventIds.add(event.id)) return
stateMutex.withLock {
if (event.id in processedEventIds) return
cappedAdd(processedEventIds, event.id, MAX_PROCESSED_EVENT_IDS)
// Filter out our own ICE candidates and hangups echoed back from relays.
// These are never useful: ICE candidates are for the remote peer, and
@@ -643,7 +683,7 @@ class CallManager(
else -> null
}
if (terminatedCallId != null) {
completedCallIds.add(terminatedCallId)
cappedAdd(completedCallIds, terminatedCallId, MAX_COMPLETED_CALL_IDS)
}
}
@@ -656,6 +696,7 @@ class CallManager(
is CallRenegotiateEvent -> onRenegotiate(event)
}
}
}
fun currentCallId(): String? =
when (val s = _state.value) {
@@ -704,7 +745,8 @@ class CallManager(
peerPubKeys: Set<HexKey>,
reason: EndReason,
) {
completedCallIds.add(callId)
cappedAdd(completedCallIds, callId, MAX_COMPLETED_CALL_IDS)
discoveredCalleePeers.clear()
_state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout()
resetJob?.cancel()