diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 8fbb80f612..de40d4d75d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -41,6 +41,8 @@ import io.getstream.video.android.core.model.UpdateUserPermissionsData import io.getstream.video.android.core.model.VideoTrack import io.getstream.video.android.core.model.toIceServer import io.getstream.video.android.core.socket.SocketState +import io.getstream.video.android.core.telecom.TelecomCallState +import io.getstream.video.android.core.telecom.TelecomCompat import io.getstream.video.android.core.telecom.TelecomHandler import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import io.getstream.video.android.core.utils.safeCall @@ -444,6 +446,13 @@ public class Call( monitor.start() client.state.setActiveCall(this) + + TelecomCompat.changeCallState( + clientImpl.context, + TelecomCallState.ONGOING, + this, + ) + startCallStatsReporting(result.value.statsOptions.reportingIntervalMs.toLong()) // listen to Signal WS @@ -1038,7 +1047,7 @@ public class Call( state.removeRingingCall() if (TelecomHandler.isSupported(context)) { - telecomHandler?.unregisterCall() +// telecomHandler?.unregisterCall() } else { state.maybeStopForegroundService() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt index 2f578c0c6a..18f0fb4609 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Stable import androidx.core.content.ContextCompat import io.getstream.log.taggedLogger import io.getstream.video.android.core.notifications.internal.service.CallService +import io.getstream.video.android.core.telecom.TelecomCallState import io.getstream.video.android.core.telecom.TelecomCompat import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId @@ -127,38 +128,49 @@ class ClientState(client: StreamVideo) { fun setActiveCall(call: Call) { _activeCall.value = call - removeRingingCall() - TelecomCompat.registerCall( - clientImpl.context, - CallService.TRIGGER_ONGOING_CALL, - call = call, - ) } fun removeActiveCall() { - _activeCall.value = null + _activeCall.value?.let { call -> + TelecomCompat.unregisterCall( + clientImpl.context, + CallService.TRIGGER_ONGOING_CALL, + call, + ) + + _activeCall.value = null + } removeRingingCall() - TelecomCompat.unregisterCall( - clientImpl.context, - CallService.TRIGGER_ONGOING_CALL, - ) } fun addRingingCall(call: Call, ringingState: RingingState) { + logger.d { "[addRingingCall] call: $call, ringingState: $ringingState" } + _ringingCall.value = call - if (ringingState is RingingState.Outgoing) { - TelecomCompat.registerCall( + + TelecomCompat.changeCallState( + clientImpl.context, + if (ringingState is RingingState.Incoming) { + TelecomCallState.INCOMING + } else { + TelecomCallState.OUTGOING + }, + call = call, + ) + } + + fun removeRingingCall() { + _ringingCall.value?.let { call -> + TelecomCompat.unregisterCall( clientImpl.context, CallService.TRIGGER_OUTGOING_CALL, - call = call, + call, ) - } - } - fun removeRingingCall() { - _ringingCall.value = null + _ringingCall.value = null + } } /** diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index 6d8d59d437..0038c45d02 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -455,6 +455,7 @@ class MicrophoneManager( if (canHandleDeviceSwitch()) { audioHandler = TelecomCompat.getAudioHandler( context = mediaManager.context, + call = mediaManager.call, listener = { devices, selected -> logger.i { "[setup] listenForDevices. Selected: ${selected?.name}, available: ${devices.map { it.name }}" diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt index d32371a9c7..a4c7c158a5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoImpl.kt @@ -51,6 +51,7 @@ import io.getstream.video.android.core.socket.ErrorResponse import io.getstream.video.android.core.socket.PersistentSocket import io.getstream.video.android.core.socket.SocketState import io.getstream.video.android.core.sounds.Sounds +import io.getstream.video.android.core.telecom.TelecomCompat import io.getstream.video.android.core.telecom.TelecomHandler import io.getstream.video.android.core.utils.DebugInfo import io.getstream.video.android.core.utils.LatencyResult @@ -1044,7 +1045,10 @@ internal class StreamVideoImpl internal constructor( calls[cid]!! } else { val call = Call(this, type, idOrRandom, user) + calls[cid] = call + TelecomCompat.registerCall(context, call) + call } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt index 58e4d14dc1..af7d6378ea 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt @@ -43,7 +43,6 @@ import io.getstream.video.android.core.notifications.NotificationHandler.Compani import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_NOTIFICATION import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INCOMING_CALL_NOTIFICATION_ID import io.getstream.video.android.core.notifications.internal.DefaultStreamIntentResolver -import io.getstream.video.android.core.notifications.internal.service.CallService import io.getstream.video.android.core.telecom.TelecomCompat import io.getstream.video.android.model.StreamCallId @@ -88,11 +87,11 @@ public open class DefaultNotificationHandler( override fun onRingingCall(callId: StreamCallId, callDisplayName: String) { logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" } + TelecomCompat.registerCall( application, - CallService.TRIGGER_INCOMING_CALL, - callDisplayName, callId = callId, + isTriggeredByNotification = true, ) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomHandler.kt index ae40122ea2..af1414d61a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomHandler.kt @@ -30,6 +30,7 @@ import androidx.core.telecom.CallAttributesCompat import androidx.core.telecom.CallControlScope import androidx.core.telecom.CallEndpointCompat import androidx.core.telecom.CallsManager +import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideo @@ -38,7 +39,10 @@ import io.getstream.video.android.core.dispatchers.DispatcherProvider import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -58,11 +62,13 @@ internal class TelecomHandler private constructor( private val logger by taggedLogger(TELECOM_LOG_TAG) private var streamVideo: StreamVideo? = null - private var currentCall: StreamCall? = null - private val coroutineScope = CoroutineScope(DispatcherProvider.Default) + private val coroutineScope = CoroutineScope(DispatcherProvider.Default + SupervisorJob()) private var callControlScope: CallControlScope? = null private var deviceListener: AvailableDevicesListener? = null + private val calls = mutableMapOf() + private val callsMap = mutableMapOf() + companion object { @Volatile private var instance: TelecomHandler? = null // TODO-Telecom: handle warning @@ -128,107 +134,117 @@ internal class TelecomHandler private constructor( Should check for audio/video permissions in registerCall, similar to CallService? */ - fun registerCall(call: StreamCall) = coroutineScope.launch { - streamVideo?.connectIfNotAlreadyConnected() - call.get() + fun registerCall(call: StreamCall, wasTriggeredByIncomingNotification: Boolean = false) { + logger.d { + "[registerCall] Call ID: ${call.id}, isTriggeredByNotification: $wasTriggeredByIncomingNotification" + } - with(call.telecomCallAttributes) { - logger.d { - "[registerCall] displayName: $displayName, ringingState: ${call.state.ringingState.value}, callType: ${if (callType == 1) "audio" else "video"}" - } + if (wasTriggeredByIncomingNotification) prepareIncomingCall(call) + + if (calls.contains(call.cid)) { + logger.d { "[registerCall] Call already registered, ignoring" } + } else { + calls[call.cid] = TelecomCall( + streamCall = call, + state = TelecomCallState.IDLE, + notificationId = call.cid.hashCode(), + coroutineScope = coroutineScope, + ) + + logger.d { "[registerCall] New call registered" } } + } + + private fun prepareIncomingCall(call: StreamCall) { + logger.d { "[prepareIncomingCall]" } - if (call.cid == currentCall?.cid) { - logger.d { "[registerCall] Updating existing call" } - postNotification() + coroutineScope.launch { + streamVideo?.connectIfNotAlreadyConnected() + } // TODO-Telecom: reanalyze this in context of incoming call + streamVideo?.state?.addRingingCall(call, RingingState.Incoming()) + } + + fun changeCallState(call: StreamCall, newState: TelecomCallState) { + logger.d { "[changeCallState] newState: $newState" } + + val telecomCall = calls[call.cid] + + if (telecomCall == null) { + logger.e { "[changeCallState] Call must be registered first" } } else { - logger.d { "[registerCall] Registering new call" } + telecomCall.state = newState - currentCall = call val telecomToStreamEventBridge = TelecomToStreamEventBridge(call) val streamToTelecomEventBridge = StreamToTelecomEventBridge(call) - safeCall(exceptionLogTag = TELECOM_LOG_TAG) { - postNotification() - - callManager.addCall( - callAttributes = call.telecomCallAttributes, - onAnswer = telecomToStreamEventBridge::onAnswer, - onDisconnect = telecomToStreamEventBridge::onDisconnect, - onSetActive = telecomToStreamEventBridge::onSetActive, - onSetInactive = telecomToStreamEventBridge::onSetInactive, - block = { - callControlScope = this - streamToTelecomEventBridge.onEvent(this) - - combine(availableEndpoints, currentCallEndpoint) { list, device -> - Pair( - list.map { it.toStreamAudioDevice() }, - device.toStreamAudioDevice(), - ) - } - .distinctUntilChanged() - .onEach { deviceListener?.invoke(it.first, it.second) } - .launchIn(this) - }, - ) + coroutineScope.launch { + safeCall(exceptionLogTag = TELECOM_LOG_TAG) { + callManager.addCall( + callAttributes = call.telecomCallAttributes, + onAnswer = telecomToStreamEventBridge::onAnswer, + onDisconnect = telecomToStreamEventBridge::onDisconnect, + onSetActive = telecomToStreamEventBridge::onSetActive, + onSetInactive = telecomToStreamEventBridge::onSetInactive, + block = { + postNotification(telecomCall) + telecomCall.callControlScope = this + streamToTelecomEventBridge.onEvent(this) + + logger.d { "[changeCallState] Added call to Telecom" } + }, + ) + } } } } @SuppressLint("MissingPermission") - private fun postNotification() { + private fun postNotification(telecomCall: TelecomCall) { if (!hasNotificationsPermission()) { logger.e { "[postNotification] POST_NOTIFICATIONS permission missing" } return } else { - currentCall?.let { currentCall -> - logger.d { - "[postNotification] Call ID: ${currentCall.id}, ringingState: ${currentCall.state.ringingState.value}" - } - - streamVideo?.let { streamVideo -> - currentCall.state.ringingState.value.let { ringingState -> - val notification = when (ringingState) { - is RingingState.Incoming -> { - logger.d { "[postNotification] Creating incoming notification" } - - streamVideo.getRingingCallNotification( - ringingState = RingingState.Incoming(), - callId = StreamCallId.fromCallCid(currentCall.cid), - incomingCallDisplayName = currentCall.incomingCallDisplayName, - shouldHaveContentIntent = streamVideo.state.activeCall.value == null, // TODO-Telecom: Compare this to CallService - ) - } - - is RingingState.Outgoing, is RingingState.Active -> { - val isOutgoingCall = ringingState is RingingState.Outgoing + logger.d { "[postNotification] Call ID: ${telecomCall.streamCall.id}, state: ${telecomCall.state}" } + + streamVideo?.let { streamVideo -> + val notification = when (telecomCall.state) { + TelecomCallState.INCOMING -> { + logger.d { "[postNotification] Creating incoming notification" } + + streamVideo.getRingingCallNotification( + ringingState = RingingState.Incoming(), + callId = StreamCallId.fromCallCid(telecomCall.streamCall.cid), + incomingCallDisplayName = telecomCall.streamCall.incomingCallDisplayName, + shouldHaveContentIntent = streamVideo.state.activeCall.value == null, // TODO-Telecom: Compare this to CallService + ) + } - logger.d { - "[postNotification] Creating ${if (isOutgoingCall) "outgoing" else "ongoing"} notification" - } + TelecomCallState.OUTGOING, TelecomCallState.ONGOING -> { + val isOutgoingCall = telecomCall.state == TelecomCallState.OUTGOING - streamVideo.getOngoingCallNotification( - callId = StreamCallId.fromCallCid(currentCall.cid), - isOutgoingCall = isOutgoingCall, - ) - } - - else -> { - logger.e { "[postNotification] Will not post any notification" } - null - } + logger.d { + "[postNotification] Creating ${if (isOutgoingCall) "outgoing" else "ongoing"} notification" } - notification?.let { - logger.d { "[postNotification] Posting notification" } + streamVideo.getOngoingCallNotification( + callId = StreamCallId.fromCallCid(telecomCall.streamCall.cid), + isOutgoingCall = isOutgoingCall, + ) + } - NotificationManagerCompat - .from(context) - .notify(currentCall.cid.hashCode(), it) - } + else -> { + logger.e { "[postNotification] Will not post any notification" } + null } } + + notification?.let { + logger.d { "[postNotification] Posting notification" } + + NotificationManagerCompat + .from(context) + .notify(telecomCall.notificationId, it) + } } } } @@ -240,25 +256,32 @@ internal class TelecomHandler private constructor( ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } - fun unregisterCall() = coroutineScope.launch { + fun unregisterCall(call: StreamCall) = coroutineScope.launch { logger.d { "[unregisterCall]" } - safeCall(exceptionLogTag = TELECOM_LOG_TAG) { - cancelNotification() - - callControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)).let { result -> - logger.d { "[unregisterCall] Disconnect result: $result" } + calls[call.cid]?.let { telecomCall -> + safeCall(exceptionLogTag = TELECOM_LOG_TAG) { + cancelNotification(telecomCall.notificationId) +// telecomCall.callControlScope?.disconnect(DisconnectCause(DisconnectCause.LOCAL)).let { result -> +// logger.d { "[unregisterCall] Disconnect result: $result" } +// } } } } - private fun cancelNotification() { + private fun cancelNotification(notificationId: Int) { logger.d { "[cancelNotification]" } - NotificationManagerCompat.from(context).cancel(currentCall?.cid?.hashCode() ?: 0) + NotificationManagerCompat.from(context).cancel(notificationId) } - fun registerAvailableDevicesListener(listener: AvailableDevicesListener) { - deviceListener = listener + fun registerAvailableDevicesListener(call: StreamCall, listener: AvailableDevicesListener) { + coroutineScope.launch { + delay(9000) + + logger.d { "[registerAvailableDevicesListener] Call ID: ${call.id}" } + + calls[call.cid]?.deviceListener = listener + } } fun selectDevice(device: CallEndpointCompat) { @@ -270,7 +293,7 @@ internal class TelecomHandler private constructor( } fun cleanUp() = runBlocking { - unregisterCall() + calls.forEach { unregisterCall(it.value.streamCall) } coroutineScope.cancel() instance = null } @@ -374,3 +397,72 @@ private fun CallEndpointCompat.toStreamAudioDevice(): StreamAudioDevice = when ( CallEndpointCompat.TYPE_WIRED_HEADSET -> StreamAudioDevice.WiredHeadset(telecomDevice = this) else -> StreamAudioDevice.Earpiece() } + +private data class TelecomCall( + var state: TelecomCallState, + val streamCall: StreamCall, + val notificationId: Int, + val coroutineScope: CoroutineScope, +) { + + private var devices = MutableStateFlow, StreamAudioDevice>?>(null) + + var callControlScope: CallControlScope? = null + set(value) { + if (value != null) { + field = value + + with(value) { + launch { + val combinedEndpoints = + combine(availableEndpoints, currentCallEndpoint) { list, device -> + Pair( + list.map { it.toStreamAudioDevice() }, + device.toStreamAudioDevice(), + ) + } + + combinedEndpoints + .distinctUntilChanged() + .onEach { + StreamLog.d(TELECOM_LOG_TAG) { + "[TelecomCall#callControlScope] Publishing devices: available devices: ${it.first.map { it.name }}, selected device: ${it.second.name}" + } + devices.value = it + } + .launchIn(this) + } + } + } + } + + var deviceListener: AvailableDevicesListener? = null + set(value) { + value?.let { listener -> + StreamLog.d( + TELECOM_LOG_TAG, + ) { "[TelecomCall#deviceListener] Setting deviceListener" } + + field = value + + devices + .onEach { deviceStatus -> + deviceStatus?.let { + StreamLog.d(TELECOM_LOG_TAG) { + "[TelecomCall#deviceListener] Collecting devices & calling listener with: available devices: ${it.first.map { it.name }}, selected device: ${it.second.name}" + } + + listener(it.first, it.second) + } + } + .launchIn(coroutineScope) + } + } +} + +internal enum class TelecomCallState { + IDLE, + INCOMING, + OUTGOING, + ONGOING, +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomUtils.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomUtils.kt index ffce64b31a..adddf62dea 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomUtils.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/telecom/TelecomUtils.kt @@ -32,10 +32,9 @@ internal object TelecomCompat { fun registerCall( context: Context, - trigger: String, - callDisplayName: String = "", call: StreamCall? = null, callId: StreamCallId? = null, + isTriggeredByNotification: Boolean = false, ) { getCallObject(call, callId)?.let { val applicationContext = context.applicationContext @@ -43,13 +42,25 @@ internal object TelecomCompat { val telecomHandler = TelecomHandler.getInstance(applicationContext) if (isTelecomSupported) { - telecomHandler?.registerCall(it) + telecomHandler?.registerCall(it, isTriggeredByNotification) + } + } + } + + fun changeCallState(context: Context, newState: TelecomCallState, call: StreamCall? = null, callId: StreamCallId? = null) { + getCallObject(call, callId)?.let { + val applicationContext = context.applicationContext + val isTelecomSupported = TelecomHandler.isSupported(applicationContext) + val telecomHandler = TelecomHandler.getInstance(applicationContext) + + if (isTelecomSupported) { + telecomHandler?.changeCallState(it, newState) } else { - if (trigger == CallService.TRIGGER_INCOMING_CALL) { + if (newState == TelecomCallState.INCOMING) { CallService.showIncomingCall( applicationContext, StreamCallId.fromCallCid(it.cid), - callDisplayName, + "", ) } else { // TODO-Telecom: Take runForegroundService flag into account here and above? @@ -58,7 +69,11 @@ internal object TelecomCompat { CallService.buildStartIntent( applicationContext, StreamCallId.fromCallCid(it.cid), - trigger, + if (newState == TelecomCallState.OUTGOING) { + CallService.TRIGGER_OUTGOING_CALL + } else { + CallService.TRIGGER_ONGOING_CALL + }, ), ) } @@ -77,19 +92,17 @@ internal object TelecomCompat { fun unregisterCall( context: Context, trigger: String, - call: StreamCall? = null, + call: StreamCall, ) { val applicationContext = context.applicationContext val isTelecomSupported = TelecomHandler.isSupported(applicationContext) val telecomHandler = TelecomHandler.getInstance(applicationContext) if (isTelecomSupported) { - telecomHandler?.unregisterCall() + telecomHandler?.unregisterCall(call) } else { if (trigger == CallService.TRIGGER_INCOMING_CALL) { - call?.let { - CallService.removeIncomingCall(context, StreamCallId.fromCallCid(it.cid)) - } + CallService.removeIncomingCall(context, StreamCallId.fromCallCid(call.cid)) } else { context.stopService(CallService.buildStopIntent(applicationContext)) } @@ -97,7 +110,11 @@ internal object TelecomCompat { } @TargetApi(Build.VERSION_CODES.O) - fun getAudioHandler(context: Context, listener: AvailableDevicesListener): AudioHandler { + fun getAudioHandler( + context: Context, + call: StreamCall, + listener: AvailableDevicesListener, + ): AudioHandler { val applicationContext = context.applicationContext // TODO-Telecom: Abstract out in one place val isTelecomSupported = TelecomHandler.isSupported(applicationContext) val telecomHandler = TelecomHandler.getInstance(applicationContext) @@ -106,7 +123,7 @@ internal object TelecomCompat { // Use Telecom object : AudioHandler { override fun start() { - telecomHandler?.registerAvailableDevicesListener(listener) + telecomHandler?.registerAvailableDevicesListener(call, listener) } override fun stop() { @@ -130,9 +147,6 @@ internal object TelecomCompat { ) } } - - fun selectDevice() { - } } internal typealias StreamCall = io.getstream.video.android.core.Call