Merge pull request #2168 from vitorpamplona/claude/review-call-screens-6zzLt
Add thread-safe state management to CallManager
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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()
|
||||
}
|
||||
+5
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
|
||||
+59
-17
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user