diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 796a4b8b7..7fff495bb 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -276,7 +276,7 @@ android:exported="false" /> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 071c39fd9..f432a92b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -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(null) - val localVideoTrack: StateFlow = _localVideoTrack.asStateFlow() - - // Primary remote track (first connected peer) for backward-compat with P2P UI - private val _remoteVideoTrack = MutableStateFlow(null) - val remoteVideoTrack: StateFlow = _remoteVideoTrack.asStateFlow() - - // All remote tracks keyed by peer pubkey (for group call UI) - private val _remoteVideoTracks = MutableStateFlow>(emptyMap()) - val remoteVideoTracks: StateFlow> = _remoteVideoTracks.asStateFlow() + val localVideoTrack: StateFlow = mediaManager.localVideoTrackFlow + val remoteVideoTrack: StateFlow = videoMonitor.remoteVideoTrack + val remoteVideoTracks: StateFlow> = videoMonitor.remoteVideoTracks private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() - private val _isRemoteVideoActive = MutableStateFlow(false) - val isRemoteVideoActive: StateFlow = _isRemoteVideoActive.asStateFlow() - private val _remoteVideoAspectRatio = MutableStateFlow(null) - val remoteVideoAspectRatio: StateFlow = _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() - private val perPeerLastFrameTimeMs = ConcurrentHashMap() - private var groupVideoMonitorJob: kotlinx.coroutines.Job? = null - - // Set of peer pubkeys that are actively sending video frames - private val _activePeerVideos = MutableStateFlow>(emptySet()) - val activePeerVideos: StateFlow> = _activePeerVideos.asStateFlow() + val isRemoteVideoActive: StateFlow = videoMonitor.isRemoteVideoActive + val remoteVideoAspectRatio: StateFlow = videoMonitor.remoteVideoAspectRatio + val activePeerVideos: StateFlow> = videoMonitor.activePeerVideos private val _isAudioMuted = MutableStateFlow(false) val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() - private val _isVideoEnabled = MutableStateFlow(false) - val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() + val isVideoEnabled: StateFlow = mediaManager.isVideoEnabled val audioRoute: StateFlow = audioManager.audioRoute val isBluetoothAvailable: StateFlow = 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, - 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, 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" } + peerSessionMgr.resolveRenegotiationGlare(peerPubKey, sdpOffer) { entry -> + applyRenegotiationOffer(entry.session, peerPubKey, sdpOffer) + } } } @@ -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() - 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, - ) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt new file mode 100644 index 000000000..d489943ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt @@ -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(null) + val localVideoTrackFlow: StateFlow = _localVideoTrackFlow.asStateFlow() + + private val _isVideoEnabled = MutableStateFlow(false) + val isVideoEnabled: StateFlow = _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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallNotificationReceiver.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallNotificationReceiver.kt index d9f1594a6..dc255b901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallNotificationReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallNotificationReceiver.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt index 9ea9ccfc8..6889ca83a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ActiveCallHolder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt new file mode 100644 index 000000000..2daa4e450 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt @@ -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(null) + val remoteVideoTrack: StateFlow = _remoteVideoTrack.asStateFlow() + + // All remote tracks keyed by peer pubkey (for group call UI) + private val _remoteVideoTracks = MutableStateFlow>(emptyMap()) + val remoteVideoTracks: StateFlow> = _remoteVideoTracks.asStateFlow() + + private val _isRemoteVideoActive = MutableStateFlow(false) + val isRemoteVideoActive: StateFlow = _isRemoteVideoActive.asStateFlow() + + private val _remoteVideoAspectRatio = MutableStateFlow(null) + val remoteVideoAspectRatio: StateFlow = _remoteVideoAspectRatio.asStateFlow() + + // Per-peer video activity + private val _activePeerVideos = MutableStateFlow>(emptySet()) + val activePeerVideos: StateFlow> = _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() + private val perPeerLastFrameTimeMs = ConcurrentHashMap() + 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() + 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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 997841c3a..2c6f66497 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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 = activeNotifications for (notification in notifications) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 0d9b8ebe6..83788d2de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -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() // Mute / Unmute toggle - val isMuted = ActiveCallHolder.callController?.isAudioMuted?.value == true + val isMuted = CallSessionBridge.callController?.isAudioMuted?.value == true val muteIntent = PendingIntent.getBroadcast( this, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 56e7ac14b..9ed32f92a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -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(null) } - val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(emptyMap()) } - val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(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, - remoteVideoTracks: Map, - activePeerVideos: Set, - 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, - 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>(emptyMap()) } - val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(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, - 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, - 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, - 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) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt new file mode 100644 index 000000000..91444ae05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt @@ -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, + remoteVideoTracks: Map, + activePeerVideos: Set, + 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, + 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, + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt new file mode 100644 index 000000000..75eb73f94 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt @@ -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(null) } + val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(emptyMap()) } + val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(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, + 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)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt new file mode 100644 index 000000000..5c66b5e97 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt @@ -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, + 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>(emptyMap()) } + val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 3a75e252c..a8a6ddcce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 16b1c9fe3..5773e10fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 755184458..397099f7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -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) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 24266dcd6..9593942b7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -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.Idle) val state: StateFlow = _state.asStateFlow() @@ -69,13 +77,13 @@ class CallManager( private var timeoutJob: Job? = null private var resetJob: Job? = null - private val processedEventIds = mutableSetOf() + private val processedEventIds = LinkedHashSet() /** 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() + private val completedCallIds = LinkedHashSet() /** 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 cappedAdd( + set: LinkedHashSet, + 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, 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" } - return - } + val current: CallState.IncomingCall + val discovered: Set + 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() + 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 - - transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED) + 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,22 +488,24 @@ class CallManager( publishEvent(result.wrap) } - fun onPeerConnected() { - val current = _state.value - if (current !is CallState.Connecting) { - Log.d("CallManager") { "onPeerConnected: state is ${current::class.simpleName}, not Connecting — ignoring" } - return - } + 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" } + return + } - Log.d("CallManager") { "onPeerConnected: Connecting -> Connected! callId=${current.callId}" } - _state.value = - CallState.Connected( - callId = current.callId, - peerPubKeys = current.peerPubKeys, - callType = current.callType, - startedAtEpoch = TimeUtils.now(), - pendingPeerPubKeys = current.pendingPeerPubKeys, - ) + Log.d("CallManager") { "onPeerConnected: Connecting -> Connected! callId=${current.callId}" } + _state.value = + CallState.Connected( + callId = current.callId, + peerPubKeys = current.peerPubKeys, + callType = current.callType, + startedAtEpoch = TimeUtils.now(), + pendingPeerPubKeys = current.pendingPeerPubKeys, + ) + } } suspend fun invitePeer( @@ -509,31 +544,33 @@ class CallManager( suspend fun hangup() { val peerPubKeys: Set val callId: String - when (val current = _state.value) { - is CallState.Offering -> { - peerPubKeys = current.peerPubKeys - callId = current.callId + stateMutex.withLock { + when (val current = _state.value) { + is CallState.Offering -> { + peerPubKeys = current.peerPubKeys + callId = current.callId + } + + is CallState.Connecting -> { + peerPubKeys = current.peerPubKeys + current.pendingPeerPubKeys + callId = current.callId + } + + is CallState.Connected -> { + peerPubKeys = current.allPeerPubKeys + callId = current.callId + } + + else -> { + return + } } - is CallState.Connecting -> { - peerPubKeys = current.peerPubKeys + current.pendingPeerPubKeys - callId = current.callId - } - - is CallState.Connected -> { - peerPubKeys = current.allPeerPubKeys - callId = current.callId - } - - else -> { - return - } + // Transition immediately so the UI stops ringing/ringback before + // the (potentially slow) signing + relay publish completes. + transitionToEnded(callId, peerPubKeys, EndReason.HANGUP) } - // 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,47 +650,51 @@ 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 - // Filter out our own ICE candidates and hangups echoed back from relays. - // These are never useful: ICE candidates are for the remote peer, and - // hangups are already handled locally by hangup() → transitionToEnded. - // Self-answers and self-rejects are NOT filtered here because they serve - // as "answered/rejected elsewhere" signals when in IncomingCall state. - if (event.pubKey == signer.pubKey && (event is CallIceCandidateEvent || event is CallHangupEvent)) { - Log.d("CallManager") { "Ignoring self-event kind=${event.kind} id=${event.id.take(8)}" } - return - } + stateMutex.withLock { + if (event.id in processedEventIds) return + cappedAdd(processedEventIds, event.id, MAX_PROCESSED_EVENT_IDS) - Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" } - - // Record call-ids from termination signals so that a later offer - // for the same call is recognised as stale (common after app restart - // when relay replays events out of order). - if (event is CallHangupEvent || event is CallRejectEvent) { - val terminatedCallId = - when (event) { - is CallHangupEvent -> event.callId() - is CallRejectEvent -> event.callId() - else -> null - } - if (terminatedCallId != null) { - completedCallIds.add(terminatedCallId) + // Filter out our own ICE candidates and hangups echoed back from relays. + // These are never useful: ICE candidates are for the remote peer, and + // hangups are already handled locally by hangup() → transitionToEnded. + // Self-answers and self-rejects are NOT filtered here because they serve + // as "answered/rejected elsewhere" signals when in IncomingCall state. + if (event.pubKey == signer.pubKey && (event is CallIceCandidateEvent || event is CallHangupEvent)) { + Log.d("CallManager") { "Ignoring self-event kind=${event.kind} id=${event.id.take(8)}" } + return } - } - when (event) { - is CallOfferEvent -> onIncomingCallEvent(event) - is CallAnswerEvent -> onCallAnswered(event) - is CallRejectEvent -> onCallRejected(event) - is CallHangupEvent -> onPeerHangup(event) - is CallIceCandidateEvent -> onIceCandidate(event) - is CallRenegotiateEvent -> onRenegotiate(event) + Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" } + + // Record call-ids from termination signals so that a later offer + // for the same call is recognised as stale (common after app restart + // when relay replays events out of order). + if (event is CallHangupEvent || event is CallRejectEvent) { + val terminatedCallId = + when (event) { + is CallHangupEvent -> event.callId() + is CallRejectEvent -> event.callId() + else -> null + } + if (terminatedCallId != null) { + cappedAdd(completedCallIds, terminatedCallId, MAX_COMPLETED_CALL_IDS) + } + } + + when (event) { + is CallOfferEvent -> onIncomingCallEvent(event) + is CallAnswerEvent -> onCallAnswered(event) + is CallRejectEvent -> onCallRejected(event) + is CallHangupEvent -> onPeerHangup(event) + is CallIceCandidateEvent -> onIceCandidate(event) + is CallRenegotiateEvent -> onRenegotiate(event) + } } } @@ -704,7 +745,8 @@ class CallManager( peerPubKeys: Set, reason: EndReason, ) { - completedCallIds.add(callId) + cappedAdd(completedCallIds, callId, MAX_COMPLETED_CALL_IDS) + discoveredCalleePeers.clear() _state.value = CallState.Ended(callId, peerPubKeys, reason) cancelTimeout() resetJob?.cancel()