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