diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 062a94484..84bd340b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -23,14 +23,9 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent import android.media.AudioManager -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.notifications.NotificationUtils -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent @@ -52,39 +47,35 @@ private const val TAG = "CallController" class CallController( private val context: Context, - private val callManager: CallManager, + val callManager: CallManager, private val scope: CoroutineScope, private val publishWrap: suspend (GiftWrapEvent) -> Unit, private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, ) { private var webRtcSession: WebRtcCallSession? = null private val callFactory = WebRtcCallFactory() - private var currentCallId: String? = null - private var currentPeerPubKey: HexKey? = null private var remoteDescriptionSet = false private val pendingIceCandidates = CopyOnWriteArrayList() val audioManager = CallAudioManager(context) + // Video tracks exposed to UI private val _remoteVideoTrack = MutableStateFlow(null) val remoteVideoTrack: StateFlow = _remoteVideoTrack.asStateFlow() - private val _localVideoTrack = MutableStateFlow(null) val localVideoTrack: StateFlow = _localVideoTrack.asStateFlow() + // Error state exposed to UI private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + // Audio/video toggle state (UI concerns, not domain state) private val _isAudioMuted = MutableStateFlow(false) val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() - private val _isVideoEnabled = MutableStateFlow(true) val isVideoEnabled: StateFlow = _isVideoEnabled.asStateFlow() - private val _isSpeakerOn = MutableStateFlow(false) val isSpeakerOn: StateFlow = _isSpeakerOn.asStateFlow() - private var networkCallback: ConnectivityManager.NetworkCallback? = null - init { scope.launch { callManager.state.collect { state -> @@ -102,7 +93,6 @@ class CallController( audioManager.stopRingbackTone() audioManager.switchToCallAudioMode() audioManager.acquireProximityWakeLock() - registerNetworkCallback() } is CallState.Connected -> { @@ -120,76 +110,82 @@ class CallController( } fun initiateCall( - peerPubKey: HexKey, + peerPubKey: String, callType: CallType, ) { + val callId = UUID.randomUUID().toString() + _errorMessage.value = null + remoteDescriptionSet = false + pendingIceCandidates.clear() + try { - val callId = UUID.randomUUID().toString() - currentCallId = callId - currentPeerPubKey = peerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() - _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() + } catch (e: Exception) { + Log.e(TAG, "Failed to create WebRTC session", e) + _errorMessage.value = "Failed to start call: ${e.message}" + return + } + + val session = + webRtcSession ?: run { + _errorMessage.value = "Failed to create WebRTC session" + return } - webRtcSession?.createOffer { sdp -> - scope.launch { - callManager.initiateCall(peerPubKey, callType, callId, sdp.description) - } + session.addAudioTrack() + if (callType == CallType.VIDEO) { + session.addVideoTrack() + _localVideoTrack.value = session.getLocalVideoTrack() + } + + session.createOffer { sdp -> + scope.launch { + callManager.initiateCall(peerPubKey, callType, callId, sdp.description) } - } catch (e: Exception) { - Log.e(TAG, "Failed to initiate call", e) - _errorMessage.value = "Failed to start call: ${e.message}" - cleanup() } } fun acceptIncomingCall(sdpOffer: String) { + val state = callManager.state.value + if (state !is CallState.IncomingCall) return + + _errorMessage.value = null + remoteDescriptionSet = false + pendingIceCandidates.clear() + try { - val state = callManager.state.value - if (state !is CallState.IncomingCall) return - - currentCallId = state.callId - currentPeerPubKey = state.callerPubKey - remoteDescriptionSet = false - pendingIceCandidates.clear() - _errorMessage.value = null - createWebRtcSession() - webRtcSession?.addAudioTrack() - if (state.callType == CallType.VIDEO) { - webRtcSession?.addVideoTrack() - _localVideoTrack.value = webRtcSession?.getLocalVideoTrack() - } - - webRtcSession?.setRemoteDescription( - SessionDescription(SessionDescription.Type.OFFER, sdpOffer), - ) - flushPendingIceCandidates() - - webRtcSession?.createAnswer { sdp -> - scope.launch { - callManager.acceptCall(sdp.description) - } - } } catch (e: Exception) { - Log.e(TAG, "Failed to accept call", e) + Log.e(TAG, "Failed to create WebRTC session", e) _errorMessage.value = "Failed to accept call: ${e.message}" - cleanup() + return + } + + val session = + webRtcSession ?: run { + _errorMessage.value = "Failed to create WebRTC session" + return + } + + session.addAudioTrack() + if (state.callType == CallType.VIDEO) { + session.addVideoTrack() + _localVideoTrack.value = session.getLocalVideoTrack() + } + + session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) + flushPendingIceCandidates() + + session.createAnswer { sdp -> + scope.launch { + callManager.acceptCall(sdp.description) + } } } fun onCallAnswerReceived(sdpAnswer: String) { Log.d(TAG) { "Answer received, SDP length=${sdpAnswer.length}, session=${webRtcSession != null}" } - webRtcSession?.setRemoteDescription( - SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), - ) + webRtcSession?.setRemoteDescription(SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer)) flushPendingIceCandidates() } @@ -217,6 +213,7 @@ class CallController( candidates.forEach { session.addIceCandidate(it) } } + // UI toggle controls fun toggleAudioMute() { val muted = !_isAudioMuted.value _isAudioMuted.value = muted @@ -249,7 +246,6 @@ class CallController( fun cleanup() { audioManager.release() - unregisterNetworkCallback() stopForegroundService() NotificationUtils.cancelCallNotification(context) _remoteVideoTrack.value = null @@ -259,80 +255,34 @@ class CallController( _isSpeakerOn.value = false webRtcSession?.dispose() webRtcSession = null - currentCallId = null - currentPeerPubKey = null remoteDescriptionSet = false pendingIceCandidates.clear() } private fun createWebRtcSession() { - val iceServers = IceServerConfig.buildIceServers() - webRtcSession = WebRtcCallSession( context = context, - iceServers = iceServers, + iceServers = IceServerConfig.buildIceServers(), onIceCandidate = { candidate -> onLocalIceCandidate(candidate) }, onPeerConnected = { callManager.onPeerConnected() startForegroundService() }, onRemoteStream = { stream: MediaStream -> - stream.videoTracks?.firstOrNull()?.let { - _remoteVideoTrack.value = it - } - }, - onDisconnected = { - scope.launch { callManager.hangup() } - }, - onError = { error -> - _errorMessage.value = error + stream.videoTracks?.firstOrNull()?.let { _remoteVideoTrack.value = it } }, + onDisconnected = { scope.launch { callManager.hangup() } }, + onError = { error -> _errorMessage.value = error }, ) webRtcSession?.initialize() webRtcSession?.createPeerConnection() } - private fun registerNetworkCallback() { - try { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val request = - NetworkRequest - .Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - val callback = - object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - Log.d(TAG) { "Network available, ICE restart may be needed" } - } - - override fun onLost(network: Network) { - Log.d(TAG) { "Network lost during call" } - } - } - connectivityManager.registerNetworkCallback(request, callback) - networkCallback = callback - } catch (e: Exception) { - Log.e(TAG, "Failed to register network callback", e) - } - } - - private fun unregisterNetworkCallback() { - try { - networkCallback?.let { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - connectivityManager.unregisterNetworkCallback(it) - } - } catch (_: Exception) { - } - networkCallback = null - } - private fun onLocalIceCandidate(candidate: IceCandidate) { Log.d(TAG) { "Local ICE candidate: ${candidate.sdp.take(50)}" } - val callId = currentCallId ?: return - val peerPubKey = currentPeerPubKey ?: return + val callId = callManager.currentCallId() ?: return + val peerPubKey = callManager.currentPeerPubKey() ?: return val candidateJson = CallIceCandidateEvent.serializeCandidate(candidate.sdp, candidate.sdpMid, candidate.sdpMLineIndex) scope.launch { @@ -343,12 +293,17 @@ class CallController( } private fun startForegroundService() { - val intent = - Intent(context, CallForegroundService::class.java).apply { - action = CallForegroundService.ACTION_START - putExtra(CallForegroundService.EXTRA_PEER_NAME, currentPeerPubKey ?: "") - } - context.startForegroundService(intent) + try { + val peerName = callManager.currentPeerPubKey() ?: "" + val intent = + Intent(context, CallForegroundService::class.java).apply { + action = CallForegroundService.ACTION_START + putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName) + } + context.startForegroundService(intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service", e) + } } private fun stopForegroundService() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt index 41f2acbea..5d37e975b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -29,14 +29,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.core.content.ContextCompat -@Composable -fun rememberCallPermissionLauncher(onGranted: () -> Unit) = - rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - if (granted) onGranted() - } - fun hasAudioPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 7334722a7..a53ecd5f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -90,7 +90,8 @@ fun CallScreen( val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() val context = LocalContext.current - val errorMessage by (callController?.errorMessage ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() + val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(null) } + val errorMessage by (callController?.errorMessage ?: emptyStringFlow).collectAsState() BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) { scope.launch { callManager.hangup() } @@ -327,11 +328,14 @@ private fun ConnectedCallUI( } } - val remoteVideoTrack by (callController?.remoteVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() - val localVideoTrack by (callController?.localVideoTrack ?: kotlinx.coroutines.flow.MutableStateFlow(null)).collectAsState() - val isAudioMuted by (callController?.isAudioMuted ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState() - val isVideoEnabled by (callController?.isVideoEnabled ?: kotlinx.coroutines.flow.MutableStateFlow(true)).collectAsState() - val isSpeakerOn by (callController?.isSpeakerOn ?: kotlinx.coroutines.flow.MutableStateFlow(false)).collectAsState() + val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(null) } + val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).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 isSpeakerOn by (callController?.isSpeakerOn ?: defaultFalse).collectAsState() Box( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index acdea1d56..1d45da3d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -204,8 +204,13 @@ class AccountViewModel( var callController: CallController? = null private set + @Synchronized fun initCallController(context: Context) { if (callController != null) return + + // Wire EventProcessor before creating CallController so events aren't dropped + account.newNotesPreProcessor.callManager = callManager + val controller = CallController( context = context.applicationContext, @@ -214,9 +219,10 @@ class AccountViewModel( publishWrap = { wrap -> account.publishCallSignaling(wrap) }, signerProvider = { account.signer }, ) + + // Set callbacks before exposing controller to avoid timing races callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.sdpAnswer()) } callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } - account.newNotesPreProcessor.callManager = callManager callController = controller }