From cd86660caeeb5e8a0506ebf566e3b8010d9adade Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 23:05:06 +0000 Subject: [PATCH 1/4] fix: call resource leaks and race conditions from out-of-order signaling - Clear discoveredCalleePeers on transitionToEnded to prevent stale peers from triggering mesh setup in subsequent calls - Cap processedEventIds and completedCallIds with LRU eviction to prevent unbounded memory growth over long app sessions - Store and release SurfaceTextureHelper in startCamera/stopCamera to prevent GL thread and texture leaks - Wrap stopCamera's stopCapture in try-catch for InterruptedException to ensure camera resources are always released - Add Mutex to CallManager to serialize state mutations, preventing races when signaling events arrive concurrently from multiple relays - Add @Synchronized to CallController's toggleAudioMute, toggleVideo, and cleanup to prevent races with WebRTC callbacks https://claude.ai/code/session_01TTPrYjcz1eYEzdSV5N6rHi --- .../amethyst/service/call/CallController.kt | 25 +- .../amethyst/commons/call/CallManager.kt | 218 +++++++++++------- 2 files changed, 147 insertions(+), 96 deletions(-) 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..14dcbb296 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 @@ -99,6 +99,7 @@ class CallController( private var localAudioTrackInternal: AudioTrack? = null private var localVideoTrackInternal: VideoTrack? = null private var cameraCapturer: CameraVideoCapturer? = null + private var surfaceTextureHelper: SurfaceTextureHelper? = null private val callFactory = WebRtcCallFactory() val audioManager = CallAudioManager(context) @@ -530,12 +531,14 @@ class CallController( // ---- UI toggle controls ---- + @Synchronized fun toggleAudioMute() { val muted = !_isAudioMuted.value _isAudioMuted.value = muted localAudioTrackInternal?.setEnabled(!muted) } + @Synchronized fun toggleVideo() { val enabling = !_isVideoEnabled.value @@ -634,21 +637,24 @@ class CallController( 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( - SurfaceTextureHelper.create("CaptureThread", egl.eglBaseContext), - context, - source.capturerObserver, - ) + it.initialize(helper, context, source.capturerObserver) it.startCapture(1280, 720, 30) } } private fun stopCamera() { - cameraCapturer?.stopCapture() + try { + cameraCapturer?.stopCapture() + } catch (_: InterruptedException) { + } cameraCapturer?.dispose() cameraCapturer = null + surfaceTextureHelper?.dispose() + surfaceTextureHelper = null } // ---- Per-peer PeerConnection creation ---- @@ -664,7 +670,9 @@ 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() @@ -780,7 +788,7 @@ class CallController( 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) } @@ -807,6 +815,7 @@ class CallController( // ---- Cleanup ---- + @Synchronized 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 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() From 737e95b12397b900802007325e2f759d90491f24 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 23:13:11 +0000 Subject: [PATCH 2/4] fix: remove unnecessary @Synchronized from CallController All three annotated methods (toggleAudioMute, toggleVideo, cleanup) are only ever called from the main thread, so the blocking lock was never contended. Remove it to avoid misleading future readers and to prevent accidental main-thread blocking if a call site changes. The Mutex in CallManager is kept because it uses suspending withLock which never blocks the thread, and it correctly serializes the GlobalScope.launch(Dispatchers.Default) hangup/reject paths in CallActivity against the viewModelScope state-collector path. https://claude.ai/code/session_01TTPrYjcz1eYEzdSV5N6rHi --- .../com/vitorpamplona/amethyst/service/call/CallController.kt | 3 --- 1 file changed, 3 deletions(-) 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 14dcbb296..17ecf5979 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 @@ -531,14 +531,12 @@ class CallController( // ---- UI toggle controls ---- - @Synchronized fun toggleAudioMute() { val muted = !_isAudioMuted.value _isAudioMuted.value = muted localAudioTrackInternal?.setEnabled(!muted) } - @Synchronized fun toggleVideo() { val enabling = !_isVideoEnabled.value @@ -815,7 +813,6 @@ class CallController( // ---- Cleanup ---- - @Synchronized 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 From 1d781d1038471626a7cf7f37374623d211c26211 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 23:35:50 +0000 Subject: [PATCH 3/4] refactor: reorganize call code into focused, smaller files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallController.kt (993 lines) split into: - CallController.kt (~380 lines) — call orchestration only - CallMediaManager.kt (~190 lines) — WebRTC factory, EGL, sources, tracks, camera lifecycle - RemoteVideoMonitor.kt (~190 lines) — remote video frame sinks, per-peer activity tracking, monitor jobs CallScreen.kt (1,125 lines) split into: - CallScreen.kt (~290 lines) — state router + simple screens - ConnectedCallUI.kt (~320 lines) — active call UI with controls - CallWidgets.kt (~340 lines) — shared composables (VideoRenderer, PeerVideoGrid, GroupCallPictures, GroupCallNames, etc.) - PipCallUI.kt (~170 lines) — Picture-in-Picture variants Misplaced files moved: - ActiveCallHolder → service/call/CallSessionBridge (renamed: it's a process-level singleton bridging Activity↔ViewModel, not a "holder") - CallNotificationReceiver → service/call/ (BroadcastReceiver, not UI) - showIncomingCallNotification → NotificationUtils (profile picture loading doesn't belong in CallController) https://claude.ai/code/session_01TTPrYjcz1eYEzdSV5N6rHi --- amethyst/src/main/AndroidManifest.xml | 2 +- .../amethyst/service/call/CallController.kt | 573 ++----------- .../amethyst/service/call/CallMediaManager.kt | 206 +++++ .../call/CallNotificationReceiver.kt | 9 +- .../call/CallSessionBridge.kt} | 11 +- .../service/call/RemoteVideoMonitor.kt | 197 +++++ .../notifications/NotificationUtils.kt | 41 +- .../amethyst/ui/call/CallActivity.kt | 29 +- .../amethyst/ui/call/CallScreen.kt | 784 +----------------- .../amethyst/ui/call/CallWidgets.kt | 380 +++++++++ .../amethyst/ui/call/ConnectedCallUI.kt | 379 +++++++++ .../amethyst/ui/call/PipCallUI.kt | 175 ++++ .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +- .../chats/privateDM/ChatroomScreen.kt | 6 +- 15 files changed, 1464 insertions(+), 1336 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallMediaManager.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui => service}/call/CallNotificationReceiver.kt (86%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{ui/call/ActiveCallHolder.kt => service/call/CallSessionBridge.kt} (84%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/RemoteVideoMonitor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt 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 17ecf5979..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,72 +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 var surfaceTextureHelper: SurfaceTextureHelper? = 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 @@ -165,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 -> { @@ -187,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() } @@ -203,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() } } @@ -215,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, ) { @@ -251,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) { @@ -287,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}" @@ -317,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}" } } } } @@ -337,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) } @@ -369,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) } @@ -388,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) @@ -401,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) } @@ -433,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) } @@ -472,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) } } } } @@ -492,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) + } } } @@ -510,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) } } } @@ -520,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) } } } @@ -534,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() } } @@ -566,101 +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 - - 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) - } - } - - private fun stopCamera() { - try { - cameraCapturer?.stopCapture() - } catch (_: InterruptedException) { - } - cameraCapturer?.dispose() - cameraCapturer = null - surfaceTextureHelper?.dispose() - surfaceTextureHelper = 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, @@ -668,97 +417,28 @@ class CallController( onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) }, onPeerConnected = { Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" } - scope.launch { - 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 = @@ -768,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() } 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) { @@ -831,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) { @@ -846,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 @@ -957,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 7b8237a17..bf8bab83e 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) } From c96d98bdbff2fe6ba8eb8609c0d519c55fc81cee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 00:15:59 +0000 Subject: [PATCH 4/4] chore: trigger CI re-run https://claude.ai/code/session_01TTPrYjcz1eYEzdSV5N6rHi