From f3990a793f6ad7034e2bad9e96a3670364f88328 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 17 Dec 2025 15:03:21 +0530 Subject: [PATCH 01/42] Refactor call service into smaller classes --- .../internal/service/AudioCallService.kt | 11 +- .../internal/service/CallService.kt | 878 +++++------------- .../internal/service/LivestreamCallService.kt | 44 +- .../service/managers/CallLifecycleManager.kt | 107 +++ .../managers/CallNotificationManager.kt | 66 ++ .../service/models/CallIntentParams.kt | 27 + .../internal/service/models/ServiceState.kt | 63 ++ .../service/observers/CallEventObserver.kt | 150 +++ .../observers/NotificationUpdateObserver.kt | 185 ++++ .../service/observers/RingingStateObserver.kt | 149 +++ .../permissions/AudioCallPermissionManager.kt | 30 + .../ForegroundServicePermissionManager.kt | 116 +++ .../LivestreamAudioCallPermissionManager.kt | 29 + .../LivestreamCallPermissionManager.kt | 30 + .../LivestreamViewerPermissionManager.kt | 45 + 15 files changed, 1228 insertions(+), 702 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/CallIntentParams.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/RingingStateObserver.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManager.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/AudioCallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/AudioCallService.kt index 8f21153d6e..8c810da369 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/AudioCallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/AudioCallService.kt @@ -16,18 +16,11 @@ package io.getstream.video.android.core.notifications.internal.service -import android.annotation.SuppressLint -import android.content.pm.ServiceInfo import io.getstream.log.TaggedLogger import io.getstream.log.taggedLogger +import io.getstream.video.android.core.notifications.internal.service.permissions.AudioCallPermissionManager internal class AudioCallService : CallService() { override val logger: TaggedLogger by taggedLogger("AudioCallService") - - override val requiredForegroundTypes: Set - @SuppressLint("InlinedApi") - get() = setOf( - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) + override val permissionManager = AudioCallPermissionManager() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index b560e624e1..0c6ea4744b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -16,43 +16,31 @@ package io.getstream.video.android.core.notifications.internal.service -import android.Manifest import android.annotation.SuppressLint import android.app.Notification import android.app.Service -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import android.media.AudioManager -import android.os.Build import android.os.IBinder -import androidx.annotation.RequiresApi -import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat import androidx.media.session.MediaButtonReceiver -import io.getstream.android.video.generated.models.CallAcceptedEvent -import io.getstream.android.video.generated.models.CallEndedEvent -import io.getstream.android.video.generated.models.CallRejectedEvent -import io.getstream.android.video.generated.models.LocalCallMissedEvent import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call -import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient -import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi -import io.getstream.video.android.core.model.RejectReason import io.getstream.video.android.core.notifications.NotificationConfig import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_CID import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_DISPLAY_NAME import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.handlers.StreamDefaultNotificationHandler -import io.getstream.video.android.core.notifications.internal.receivers.ToggleCameraBroadcastReceiver -import io.getstream.video.android.core.socket.common.scope.ClientScope -import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer +import io.getstream.video.android.core.notifications.internal.service.managers.CallLifecycleManager +import io.getstream.video.android.core.notifications.internal.service.managers.CallNotificationManager +import io.getstream.video.android.core.notifications.internal.service.models.CallIntentParams +import io.getstream.video.android.core.notifications.internal.service.models.ServiceState +import io.getstream.video.android.core.notifications.internal.service.observers.CallEventObserver +import io.getstream.video.android.core.notifications.internal.service.observers.NotificationUpdateObserver +import io.getstream.video.android.core.notifications.internal.service.observers.RingingStateObserver +import io.getstream.video.android.core.notifications.internal.service.permissions.ForegroundServicePermissionManager import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.core.utils.startForegroundWithServiceType import io.getstream.video.android.model.StreamCallId @@ -63,111 +51,28 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.launch /** * A foreground service that is running when there is an active call. */ internal open class CallService : Service() { internal open val logger by taggedLogger("CallService") + internal open val permissionManager = ForegroundServicePermissionManager() - @SuppressLint("InlinedApi") - internal open val requiredForegroundTypes: Set = setOf( - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, - ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) - - /** - * Map each service type to the permission it requires (if any). - * Subclasses can reuse or extend this mapping. - * [ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK] requires Q - * [ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL] requires Q - * [ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA] requires R - * [ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE] requires R - * [ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE] requires UPSIDE_DOWN_CAKE - */ - - @SuppressLint("InlinedApi") - internal open val foregroundTypePermissionsMap: Map = mapOf( - ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA to Manifest.permission.CAMERA, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE to Manifest.permission.RECORD_AUDIO, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK to null, // playback doesn’t need permission - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL to null, - ) - - private fun getServiceTypeForStartingFGService(trigger: String): Int { - return when (trigger) { - CallService.TRIGGER_ONGOING_CALL -> { serviceType } - else -> noPermissionServiceType() - } - } + internal val callLifecycleManager = CallLifecycleManager() + private val notificationManager = CallNotificationManager() + internal val serviceState = ServiceState() open val serviceType: Int @SuppressLint("InlinedApi") get() { - return if (hasAllPermission(baseContext)) { - hasAllPermissionServiceType() + return if (permissionManager.hasAllPermissions(baseContext)) { + permissionManager.allPermissionsServiceType() } else { - noPermissionServiceType() + permissionManager.noPermissionServiceType() } } - private fun hasAllPermissionServiceType(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // or of all requiredForegroundTypes types - requiredForegroundTypes.reduce { acc, type -> acc or type } - } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { - androidQServiceType() - } else { - /** - * Android Pre-Q Service Type (no need to bother) - * We don't start foreground service with type - */ - 0 - } - } - - @SuppressLint("InlinedApi") - internal open fun noPermissionServiceType(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL - } - } - - @SuppressLint("InlinedApi") - internal open fun androidQServiceType() = if (requiredForegroundTypes.contains( - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, - ) - ) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL - } else { - /** - * Existing behavior - * [ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE] requires [Build.VERSION_CODES.UPSIDE_DOWN_CAKE] - */ - ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE - } - - @RequiresApi(Build.VERSION_CODES.R) - internal fun hasAllPermission(context: Context): Boolean { - return requiredForegroundTypes.all { type -> - val permission = foregroundTypePermissionsMap[type] - permission == null || ContextCompat.checkSelfPermission( - context, - permission, - ) == PackageManager.PERMISSION_GRANTED - } - } - - // Data - private var callId: StreamCallId? = null - // Service scope val handler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "[CallService#Scope] Uncaught exception: $exception" } @@ -175,12 +80,6 @@ internal open class CallService : Service() { private val serviceScope: CoroutineScope = CoroutineScope(Dispatchers.IO + handler + SupervisorJob()) - // Camera handling receiver - private val toggleCameraBroadcastReceiver = ToggleCameraBroadcastReceiver(serviceScope) - private var isToggleCameraBroadcastReceiverRegistered = false - - // Call sounds - private var callSoundAndVibrationPlayer: CallSoundAndVibrationPlayer? = null private val serviceNotificationRetriever = ServiceNotificationRetriever() internal companion object { @@ -194,14 +93,14 @@ internal open class CallService : Service() { const val EXTRA_STOP_SERVICE = "io.getstream.video.android.core.stop_service" } - private fun shouldStopServiceFromIntent(intent: Intent?): Boolean { + private fun shouldStopService(intent: Intent?): Boolean { val intentCallId = intent?.streamCallId(INTENT_EXTRA_CALL_CID) val shouldStopService = intent?.getBooleanExtra(EXTRA_STOP_SERVICE, false) ?: false - if (callId != null && callId == intentCallId && shouldStopService) { - logger.d { "shouldStopServiceFromIntent: true, call_cid:${intentCallId?.cid}" } + if (serviceState.currentCallId != null && serviceState.currentCallId == intentCallId && shouldStopService) { + logger.d { "shouldStopService: true, call_cid:${intentCallId?.cid}" } return true } - logger.d { "shouldStopServiceFromIntent: false, call_cid:${intentCallId?.cid}" } + logger.d { "shouldStopServiceFrom: false, call_cid:${intentCallId?.cid}" } return false } @@ -229,8 +128,7 @@ internal open class CallService : Service() { return isCallExpired // message:[handlePushMessage], [showIncomingCall] callId, [reject] #ringing; } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - logger.d { "[onStartCommand], intent = $intent, flags:$flags, startId:$startId" } + private fun logIntentExtras(intent: Intent?) { if (intent != null) { val bundle = intent.extras val keys = bundle?.keySet() @@ -247,146 +145,171 @@ internal open class CallService : Service() { } } } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + logger.d { "[onStartCommand], intent = $intent, flags:$flags, startId:$startId" } + logIntentExtras(intent) - // STOP SERVICE LOGIC STARTS - if (shouldStopServiceFromIntent(intent)) { - stopService() - return START_NOT_STICKY + // Early exit conditions + when { + shouldStopService(intent) -> { + stopServiceGracefully() + return START_NOT_STICKY + } + isIntentForExpiredCall(intent) -> return START_NOT_STICKY } - // STOP SERVICE LOGIC ENDS - if (isIntentForExpiredCall(intent)) { - return START_NOT_STICKY + val params = extractIntentParams(intent) ?: run { + logger.e { "Failed to extract required parameters from intent" } + stopServiceGracefully() + return START_REDELIVER_INTENT } - val trigger = intent?.getStringExtra(TRIGGER_KEY) - val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient + return handleCallIntent(params, intent, flags, startId) + } - val intentCallId = intent?.streamCallId(INTENT_EXTRA_CALL_CID) - val intentCallDisplayName = intent?.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME) + private fun handleCallIntent( + params: CallIntentParams, + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + maybeHandleMediaIntent(intent, params.callId) - logger.i { - "[onStartCommand]. callId: ${intentCallId?.id}, trigger: $trigger, Callservice hashcode: ${hashCode()}" - } - logger.d { - "[onStartCommand] streamVideo: ${streamVideo != null}, intentCallId: ${intentCallId != null}, trigger: $trigger" + // Promote early to foreground + maybePromoteToForegroundService( + params.streamVideo, + params.callId.hashCode(), + params.trigger, + ) + + val call = params.streamVideo.call(params.callId.type, params.callId.id) + + if (!verifyPermissions(params.streamVideo, call, params.callId, params.trigger)) { + stopServiceGracefully() + return START_REDELIVER_INTENT } - maybeHandleMediaIntent(intent, intentCallId) + val (notification, notificationId) = getNotificationPair( + params.trigger, + params.streamVideo, + params.callId, + params.displayName, + ) - val started = if (intentCallId != null && streamVideo != null && trigger != null) { - logger.d { "[onStartCommand] All required parameters available, proceeding with service start" } - // Promote early to foreground service - maybePromoteToForegroundService( - videoClient = streamVideo, - notificationId = intentCallId.hashCode(), - trigger, - ) + val started = handleNotification( + notification, + notificationId, + params.callId, + params.trigger, + call, + ) - val type = intentCallId.type - val id = intentCallId.id - val call = streamVideo.call(type, id) - - val permissionCheckPass = - streamVideo.permissionCheck.checkAndroidPermissionsGroup(applicationContext, call) - if (!permissionCheckPass.first) { - // Crash early with a meaningful message if Call is used without system permissions. - val missingPermissions = permissionCheckPass.second.joinToString(",") - val exception = IllegalStateException( - """ - CallService attempted to start without required permissions $missingPermissions. - Details: call_id:$callId, trigger:$trigger, - This can happen if you call [Call.join()] without the required permissions being granted by the user. - If you are using compose and [LaunchCallPermissions] ensure that you rely on the [onRequestResult] callback - to ensure that the permission is granted prior to calling [Call.join()] or similar. - Optionally you can use [LaunchPermissionRequest] to ensure permissions are granted. - If you are not using the [stream-video-android-ui-compose] library, - ensure that permissions are granted prior calls to [Call.join()]. - You can re-define your permissions and their expected state by overriding the [permissionCheck] in [StreamVideoBuilder] - """.trimIndent(), - ) - if (streamVideo.crashOnMissingPermission) { - throw exception - } else { - logger.e(exception) { "Make sure all the required permissions are granted!" } - } - } + return if (started) { + serviceState.soundPlayer = params.streamVideo.callSoundAndVibrationPlayer + initializeService(params.streamVideo, params.callId, params.trigger) + START_NOT_STICKY + } else { + logger.w { "Foreground service did not start!" } + stopServiceGracefully() + START_REDELIVER_INTENT + } + } - logger.d { - "[onStartCommand] Getting notification for trigger: $trigger, callId: ${intentCallId.id}" - } - val notificationData: Pair = - getNotificationPair(trigger, streamVideo, intentCallId, intentCallDisplayName) + private fun initializeService(streamVideo: StreamVideoClient, callId: StreamCallId, trigger: String) { + callLifecycleManager.initializeCallAndSocket(serviceScope, streamVideo, callId) { + stopServiceGracefully() + } - val notification = notificationData.first - logger.d { - "[onStartCommand] Notification generated: ${notification != null}, notificationId: ${notificationData.second}" - } - if (notification != null) { - if (trigger == TRIGGER_INCOMING_CALL) { - logger.d { "[onStartCommand] Handling incoming call trigger" } - showIncomingCall( - callId = intentCallId, - notificationId = notificationData.second, - notification = notification, - ) - } else { - logger.d { "[onStartCommand] Handling non-incoming call trigger: $trigger" } - callId = intentCallId + if (trigger == TRIGGER_INCOMING_CALL) { + callLifecycleManager.updateRingingCall( + serviceScope, + streamVideo, + callId, + RingingState.Incoming(), + ) + } - call.state.updateNotification(notification) + serviceState.soundPlayer = streamVideo.callSoundAndVibrationPlayer + logger.d { "[initializeService] soundPlayer hashcode: ${serviceState.soundPlayer?.hashCode()}" } - startForegroundWithServiceType( - intentCallId.hashCode(), - notification, - trigger, - getServiceTypeForStartingFGService(trigger), - ) - } + observeCall(callId, streamVideo) + serviceState.registerToggleCameraBroadcastReceiver(this, serviceScope) + } + + private fun handleNotification( + notification: Notification?, + notificationId: Int, + callId: StreamCallId, + trigger: String, + call: Call, + ): Boolean { + if (notification == null) { + return if (trigger == TRIGGER_REMOVE_INCOMING_CALL) { + removeIncomingCall(notificationId) true } else { - if (trigger == TRIGGER_REMOVE_INCOMING_CALL) { - logger.d { "[onStartCommand] Removing incoming call" } - removeIncomingCall(notificationId = notificationData.second) - true - } else { - // Service not started no notification - logger.e { "Could not get notification for trigger: $trigger, callId: ${intentCallId.id}" } - false - } + logger.e { "Could not get notification for trigger: $trigger, callId: ${callId.id}" } + false } - } else { - // Service not started, no call Id or stream video - logger.e { - "Call id or streamVideo or trigger are not available. streamVideo is not null: ${streamVideo != null}, intentCallId is not null: ${intentCallId != null}, trigger: $trigger" - } - false } - if (!started) { - logger.w { "Foreground service did not start!" } - // Call stopSelf() and return START_REDELIVER_INTENT. - // Because of stopSelf() the service is not restarted. - // Because START_REDELIVER_INTENT is returned - // the exception RemoteException: Service did not call startForeground... is not thrown. - stopService() - return START_REDELIVER_INTENT - } else { - initializeCallAndSocket(streamVideo!!, intentCallId!!) + return when (trigger) { + TRIGGER_INCOMING_CALL -> { + showIncomingCall(callId, notificationId, notification) + true + } - if (trigger == TRIGGER_INCOMING_CALL) { - updateRingingCall(streamVideo, intentCallId, RingingState.Incoming()) + else -> { + serviceState.currentCallId = callId + call.state.updateNotification(notification) + startForegroundWithServiceType( + callId.hashCode(), + notification, + trigger, + permissionManager.getServiceType(baseContext, trigger), + ) + true } + } + } - callSoundAndVibrationPlayer = streamVideo.callSoundAndVibrationPlayer + private fun verifyPermissions( + streamVideo: StreamVideoClient, + call: Call, + callId: StreamCallId, + trigger: String, + ): Boolean { + val (hasPermissions, missingPermissions) = + streamVideo.permissionCheck.checkAndroidPermissionsGroup(applicationContext, call) + + if (!hasPermissions) { + val exception = IllegalStateException( + """ + CallService attempted to start without required permissions: ${missingPermissions.joinToString()}. + call_id: $callId, trigger: $trigger + Ensure all required permissions are granted before calling Call.join(). + """.trimIndent(), + ) - logger.d { - "[onStartCommand]. callSoundPlayer's hashcode: ${callSoundAndVibrationPlayer?.hashCode()}, Callservice hashcode: ${hashCode()}" + if (streamVideo.crashOnMissingPermission) { + throw exception + } else { + logger.e(exception) { "Make sure all required permissions are granted!" } } - observeCall(intentCallId, streamVideo) - registerToggleCameraBroadcastReceiver() - return START_NOT_STICKY + return false } + return true + } + + private fun extractIntentParams(intent: Intent?): CallIntentParams? { + val trigger = intent?.getStringExtra(TRIGGER_KEY) ?: return null + val callId = intent.streamCallId(INTENT_EXTRA_CALL_CID) ?: return null + val streamVideo = (StreamVideo.instanceOrNull() as? StreamVideoClient) ?: return null + val displayName = intent.streamCallDisplayName(INTENT_EXTRA_CALL_DISPLAY_NAME) + + return CallIntentParams(streamVideo, callId, trigger, displayName) } private fun maybeHandleMediaIntent(intent: Intent?, callId: StreamCallId?) = safeCall { @@ -438,32 +361,12 @@ internal open class CallService : Service() { notificationId, notification, trigger, - getServiceTypeForStartingFGService(trigger), + permissionManager.getServiceType(baseContext, trigger), ) } } } - private fun justNotify(callId: StreamCallId, notificationId: Int, notification: Notification) { - logger.d { "[justNotify] notificationId: $notificationId" } - if (ActivityCompat.checkSelfPermission( - this, Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - ) { - StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher()?.notify( - callId, - notificationId, - notification, - ) - logger.d { "[justNotify] Notification shown with ID: $notificationId" } - } else { - logger.w { - "[justNotify] Permission not granted, cannot show notification with ID: $notificationId" - } - } - } - - @SuppressLint("MissingPermission") private fun showIncomingCall( callId: StreamCallId, notificationId: Int, @@ -471,12 +374,8 @@ internal open class CallService : Service() { ) { logger.d { "[showIncomingCall] notificationId: $notificationId" } val hasActiveCall = StreamVideo.instanceOrNull()?.state?.activeCall?.value != null - logger.d { "[showIncomingCall] hasActiveCall: $hasActiveCall" } - - if (!hasActiveCall) { // If there isn't another call in progress - // The service was started with startForegroundService() (from companion object), so we need to call startForeground(). - logger.d { "[showIncomingCall] Starting foreground service with notification" } + if (!hasActiveCall) { StreamVideo.instanceOrNull()?.call(callId.type, callId.id) ?.state?.updateNotification(notification) @@ -484,431 +383,89 @@ internal open class CallService : Service() { notificationId, notification, TRIGGER_INCOMING_CALL, - getServiceTypeForStartingFGService(TRIGGER_INCOMING_CALL), + permissionManager.getServiceType(baseContext, TRIGGER_INCOMING_CALL), ).onError { - logger.e { - "[showIncomingCall] Failed to start foreground service, falling back to justNotify: $it" - } - justNotify(callId, notificationId, notification) + logger.e { "[showIncomingCall] Failed to start foreground: $it" } + notificationManager.justNotify(this, callId, notificationId, notification) } } else { - // Else, we show a simple notification (the service was already started as a foreground service). - logger.d { "[showIncomingCall] Service already running, showing simple notification" } - justNotify(callId, notificationId, notification) + notificationManager.justNotify(this, callId, notificationId, notification) } } private fun removeIncomingCall(notificationId: Int) { NotificationManagerCompat.from(this).cancel(notificationId) - - if (callId == null) { - stopService() - } - } - - private fun initializeCallAndSocket( - streamVideo: StreamVideo, - callId: StreamCallId, - ) { - // Update call - serviceScope.launch { - val call = streamVideo.call(callId.type, callId.id) - val update = call.get() - if (update.isFailure) { - update.errorOrNull()?.let { - logger.e { it.message } - } ?: let { - logger.e { "Failed to update call." } - } - stopService() // Failed to update call - return@launch - } - } - - // Monitor coordinator socket - serviceScope.launch { - streamVideo.connectIfNotAlreadyConnected() - } - } - - private fun updateRingingCall( - streamVideo: StreamVideo, - callId: StreamCallId, - ringingState: RingingState, - ) { - serviceScope.launch { - val call = streamVideo.call(callId.type, callId.id) - streamVideo.state.addRingingCall(call, ringingState) + if (serviceState.currentCallId == null) { + stopServiceGracefully() } } private fun observeCall(callId: StreamCallId, streamVideo: StreamVideoClient) { - observeRingingState(callId, streamVideo) - observeCallEvents(callId, streamVideo) - if (streamVideo.enableCallNotificationUpdates) { - observeNotificationUpdates(callId, streamVideo) - } - } - - private fun observeRingingState(callId: StreamCallId, streamVideo: StreamVideoClient) { val call = streamVideo.call(callId.type, callId.id) - call.scope.launch { - call.state.ringingState.collect { - logger.i { "Ringing state: $it" } - - when (it) { - is RingingState.Incoming -> { - if (!it.acceptedByMe) { - logger.d { "[vibrate] Vibration config: ${streamVideo.vibrationConfig}" } - val allowVibrations = try { - val audioManager = this@CallService.getSystemService( - Context.AUDIO_SERVICE, - ) as AudioManager - when (audioManager.ringerMode) { - AudioManager.RINGER_MODE_NORMAL, AudioManager.RINGER_MODE_VIBRATE -> true - else -> false - } - } catch (e: Exception) { - logger.e { "Failed to get audio manager: ${e.message}" } - false - } - if (allowVibrations && streamVideo.vibrationConfig.enabled) { - val pattern = streamVideo.vibrationConfig.vibratePattern - callSoundAndVibrationPlayer?.vibrate(pattern) - } - callSoundAndVibrationPlayer?.playCallSound( - streamVideo.sounds.ringingConfig.incomingCallSoundUri, - streamVideo.sounds.mutedRingingConfig?.playIncomingSoundIfMuted - ?: false, - ) - } else { - callSoundAndVibrationPlayer?.stopCallSound() // Stops sound sooner than Active. More responsive. - } - } - - is RingingState.Outgoing -> { - if (!it.acceptedByCallee) { - callSoundAndVibrationPlayer?.playCallSound( - streamVideo.sounds.ringingConfig.outgoingCallSoundUri, - streamVideo.sounds.mutedRingingConfig?.playOutgoingSoundIfMuted - ?: false, - ) - } else { - callSoundAndVibrationPlayer?.stopCallSound() // Stops sound sooner than Active. More responsive. - } - } - - is RingingState.Active -> { // Handle Active to make it more reliable - callSoundAndVibrationPlayer?.stopCallSound() - } - - is RingingState.RejectedByAll -> { - ClientScope().launch { - call.reject( - source = "RingingState.RejectedByAll", - RejectReason.Decline, - ) - } - callSoundAndVibrationPlayer?.stopCallSound() - stopService() - } - - is RingingState.TimeoutNoAnswer -> { - callSoundAndVibrationPlayer?.stopCallSound() - } - - else -> { - callSoundAndVibrationPlayer?.stopCallSound() - } - } - } - } - } - - private fun observeCallEvents(callId: StreamCallId, streamVideo: StreamVideoClient) { - val call = streamVideo.call(callId.type, callId.id) - /** - * This scope will be cleaned as soon as call is destroyed via rejection/decline - */ - call.scope.launch { - call.events.collect { event -> - logger.i { "Received event in service: $event" } - when (event) { - is CallAcceptedEvent -> { - handleIncomingCallAcceptedByMeOnAnotherDevice( - acceptedByUserId = event.user.id, - myUserId = streamVideo.userId, - callRingingState = call.state.ringingState.value, - ) - } - - is CallRejectedEvent -> { - handleIncomingCallRejectedByMeOrCaller( - call, - rejectedByUserId = event.user.id, - myUserId = streamVideo.userId, - createdByUserId = call.state.createdBy.value?.id, - activeCallExists = streamVideo.state.activeCall.value != null, - ) - } - - is CallEndedEvent -> { - // When call ends for any reason - stopService() - } - - is LocalCallMissedEvent -> handleSlowCallRejectedEvent(call) - } - } - } - call.scope.launch { - call.state.connection.collectLatest { event -> - when (event) { - is RealtimeConnection.Failed -> { - if (call.id == streamVideo.state.ringingCall.value?.id) { - streamVideo.state.removeRingingCall(call) - streamVideo.onCallCleanUp(call) - } - } + RingingStateObserver(call, serviceState.soundPlayer, streamVideo, serviceScope) + .observe { stopServiceGracefully() } - else -> {} - } - } - } - } - - private fun handleIncomingCallAcceptedByMeOnAnotherDevice( - acceptedByUserId: String, - myUserId: String, - callRingingState: RingingState, - ) { - // If accepted event was received, with event user being me, but current device is still ringing, it means the call was accepted on another device - if (acceptedByUserId == myUserId && callRingingState is RingingState.Incoming) { - // So stop ringing on this device - stopService() - } - } - - private fun handleSlowCallRejectedEvent(call: Call) { - val callId = StreamCallId(call.type, call.id) - removeIncomingCall(callId.getNotificationId(NotificationType.Incoming)) - } - - private fun handleIncomingCallRejectedByMeOrCaller( - call: Call, - rejectedByUserId: String, - myUserId: String, - createdByUserId: String?, - activeCallExists: Boolean, - ) { - // If rejected event was received (even from another device), with event user being me OR the caller, remove incoming call / stop service. - if (rejectedByUserId == myUserId || rejectedByUserId == createdByUserId) { - if (activeCallExists) { - val callId = StreamCallId(call.type, call.id) - removeIncomingCall(callId.getNotificationId(NotificationType.Incoming)) - } else { - stopService() - } - } - } - - @OptIn(ExperimentalStreamVideoApi::class) - private fun observeNotificationUpdates(callId: StreamCallId, streamVideo: StreamVideoClient) { - serviceScope.launch { - val call = streamVideo.call(callId.type, callId.id) - logger.d { "Observing notification updates for call: ${call.cid}" } - val notificationUpdateTriggers = - streamVideo.streamNotificationManager.notificationConfig.notificationUpdateTriggers( - call, - ) ?: combine( - call.state.ringingState, - call.state.members, - call.state.remoteParticipants, - call.state.backstage, - ) { ringingState, members, remoteParticipants, backstage -> - listOf(ringingState, members, remoteParticipants, backstage) - }.distinctUntilChanged() - - notificationUpdateTriggers.collectLatest { state -> - val ringingState = call.state.ringingState.value - logger.d { "[observeNotificationUpdates] ringingState: $ringingState" } - val notification = streamVideo.onCallNotificationUpdate( - call = call, - ) - logger.d { "[observeNotificationUpdates] notification: ${notification != null}" } - if (notification != null) { - when (ringingState) { - is RingingState.Active -> { - logger.d { "[observeNotificationUpdates] Showing active call notification" } - startForegroundWithServiceType( - callId.hashCode(), - notification, - TRIGGER_ONGOING_CALL, - getServiceTypeForStartingFGService(TRIGGER_ONGOING_CALL), - ) - } - - is RingingState.Outgoing -> { - logger.d { "[observeNotificationUpdates] Showing outgoing call notification" } - startForegroundWithServiceType( - callId.getNotificationId(NotificationType.Incoming), - notification, - TRIGGER_OUTGOING_CALL, - getServiceTypeForStartingFGService(TRIGGER_OUTGOING_CALL), - ) - } - - is RingingState.Incoming -> { - logger.d { "[observeNotificationUpdates] Showing incoming call notification" } - startForegroundWithServiceType( - callId.getNotificationId(NotificationType.Incoming), - notification, - TRIGGER_INCOMING_CALL, - getServiceTypeForStartingFGService(TRIGGER_INCOMING_CALL), - ) - } - - else -> { - logger.d { "[observeNotificationUpdates] Unhandled ringing state: $ringingState" } - } - } - } else { - logger.w { - "[observeNotificationUpdates] No notification generated for updating." - } - } - } - } - } + CallEventObserver(call, streamVideo) + .observe( + onServiceStop = { stopServiceGracefully() }, + onRemoveIncoming = { + removeIncomingCall( + callId.getNotificationId(NotificationType.Incoming), + ) + }, + ) - private fun registerToggleCameraBroadcastReceiver() { - if (!isToggleCameraBroadcastReceiverRegistered) { - try { - registerReceiver( - toggleCameraBroadcastReceiver, - IntentFilter().apply { - addAction(Intent.ACTION_SCREEN_ON) - addAction(Intent.ACTION_SCREEN_OFF) - addAction(Intent.ACTION_USER_PRESENT) - }, + if (streamVideo.enableCallNotificationUpdates) { + NotificationUpdateObserver( + call, + streamVideo, + serviceScope, + permissionManager, + ) { + notificationId: Int, + notification: Notification, + trigger: String, + foregroundServiceType: Int, + -> + startForegroundWithServiceType( + notificationId, + notification, + trigger, + foregroundServiceType, ) - isToggleCameraBroadcastReceiverRegistered = true - } catch (e: Exception) { - logger.d { "Unable to register ToggleCameraBroadcastReceiver." } } + .observe(baseContext) } } override fun onTimeout(startId: Int) { super.onTimeout(startId) logger.w { "Timeout received from the system, service will stop." } - stopService() + stopServiceGracefully() } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) - - endCall() - stopService() - } - - private fun endCall() { - callId?.let { callId -> - StreamVideo.instanceOrNull()?.let { streamVideo -> - val call = streamVideo.call(callId.type, callId.id) - val ringingState = call.state.ringingState.value - - if (ringingState is RingingState.Outgoing) { - // If I'm calling, end the call for everyone - serviceScope.launch { - call.reject( - "CallService.EndCall", - RejectReason.Custom("Android Service Task Removed"), - ) - logger.i { "[onTaskRemoved] Ended outgoing call for all users." } - } - } else if (ringingState is RingingState.Incoming) { - // If I'm receiving a call... - val memberCount = call.state.members.value.size - logger.i { "[onTaskRemoved] Total members: $memberCount" } - if (memberCount == 2) { - // ...and I'm the only one being called, end the call for both users - serviceScope.launch { - call.reject(source = "memberCount == 2") - logger.i { "[onTaskRemoved] Ended incoming call for both users." } - } - } else { - // ...and there are other users other than me and the caller, end the call just for me - call.leave() - logger.i { "[onTaskRemoved] Ended incoming call for me." } - } - } else { - // If I'm in an ongoing call, end the call for me - call.leave() - logger.i { "[onTaskRemoved] Ended ongoing call for me." } - } - } - } + callLifecycleManager.endCall(serviceScope, serviceState.currentCallId) + stopServiceGracefully() } override fun onDestroy() { - logger.d { "[onDestroy], Callservice hashcode: ${hashCode()}, call_cid: ${callId?.cid}" } - stopService() - callSoundAndVibrationPlayer?.cleanUpAudioResources() + logger.d { + "[onDestroy], Callservice hashcode: ${hashCode()}, call_cid: ${serviceState.currentCallId?.cid}" + } + stopServiceGracefully() + serviceState.soundPlayer?.cleanUpAudioResources() super.onDestroy() } override fun stopService(name: Intent?): Boolean { logger.d { "[stopService(name)], Callservice hashcode: ${hashCode()}" } - stopService() + stopServiceGracefully() return super.stopService(name) } - /** - * Handle all aspects of stopping the service. - * Should be invoke carefully for the calls which are still present in [StreamVideoClient.calls] - * Else stopping service by an expired call can cancel current call's notification and the service itself - */ - private fun stopService() { - // Cancel the notification - val notificationManager = NotificationManagerCompat.from(this) - callId?.let { - val notificationId = callId.hashCode() - notificationManager.cancel(notificationId) - - logger.i { "[stopService]. Cancelled notificationId: $notificationId" } - } - - safeCall { - val handler = streamDefaultNotificationHandler() - handler?.clearMediaSession(callId) - } - - // Optionally cancel any incoming call notification - val incomingNotificationId = callId?.getNotificationId(NotificationType.Incoming) - callId?.let { - notificationManager.cancel(it.getNotificationId(NotificationType.Incoming)) - logger.i { "[stopService]. Cancelled incoming call notificationId: $incomingNotificationId" } - } - - // Camera privacy - unregisterToggleCameraBroadcastReceiver() - - // Call sounds - /** - * Temp Fix!! The observeRingingState scope was getting cancelled and as a result, - * ringing state was not properly updated - */ - callSoundAndVibrationPlayer?.stopCallSound() - - // Stop any jobs - serviceScope.cancel() - - // Optionally (no-op if already stopping) - stopSelf() - } - private fun streamDefaultNotificationHandler(): StreamDefaultNotificationHandler? { val client = StreamVideo.instanceOrNull() as StreamVideoClient val handler = @@ -921,15 +478,22 @@ internal open class CallService : Service() { return client.streamNotificationManager.notificationConfig } - private fun unregisterToggleCameraBroadcastReceiver() { - if (isToggleCameraBroadcastReceiverRegistered) { - try { - unregisterReceiver(toggleCameraBroadcastReceiver) - isToggleCameraBroadcastReceiverRegistered = false - } catch (e: Exception) { - logger.d { "Unable to unregister ToggleCameraBroadcastReceiver." } - } - } + /** + * Handle all aspects of stopping the service. + * Should be invoke carefully for the calls which are still present in [StreamVideoClient.calls] + * Else stopping service by an expired call can cancel current call's notification and the service itself + */ + private fun stopServiceGracefully() { + notificationManager.cancelNotifications(this, serviceState.currentCallId) + serviceState.unregisterToggleCameraBroadcastReceiver(this) + + /** + * Temp Fix!! The observeRingingState scope was getting cancelled and as a result, + * ringing state was not properly updated + */ + serviceState.soundPlayer?.stopCallSound() + serviceScope.cancel() + stopSelf() } // This service does not return a Binder diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/LivestreamCallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/LivestreamCallService.kt index 6290f21572..898232159c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/LivestreamCallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/LivestreamCallService.kt @@ -16,25 +16,20 @@ package io.getstream.video.android.core.notifications.internal.service -import android.annotation.SuppressLint -import android.content.pm.ServiceInfo -import android.os.Build -import androidx.annotation.RequiresApi import io.getstream.log.TaggedLogger import io.getstream.log.taggedLogger +import io.getstream.video.android.core.notifications.internal.service.permissions.ForegroundServicePermissionManager +import io.getstream.video.android.core.notifications.internal.service.permissions.LivestreamAudioCallPermissionManager +import io.getstream.video.android.core.notifications.internal.service.permissions.LivestreamCallPermissionManager +import io.getstream.video.android.core.notifications.internal.service.permissions.LivestreamViewerPermissionManager /** * Due to the nature of the livestream calls, the service that is used is of different type. */ internal open class LivestreamCallService : CallService() { override val logger: TaggedLogger by taggedLogger("LivestreamHostCallService") - - override val requiredForegroundTypes: Set - @SuppressLint("InlinedApi") - get() = setOf( - ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) + override val permissionManager: ForegroundServicePermissionManager = + LivestreamCallPermissionManager() } /** @@ -42,12 +37,7 @@ internal open class LivestreamCallService : CallService() { */ internal open class LivestreamAudioCallService : CallService() { override val logger: TaggedLogger by taggedLogger("LivestreamAudioCallService") - - override val requiredForegroundTypes: Set - @SuppressLint("InlinedApi") - get() = setOf( - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) + override val permissionManager = LivestreamAudioCallPermissionManager() } /** @@ -55,23 +45,5 @@ internal open class LivestreamAudioCallService : CallService() { */ internal class LivestreamViewerService : LivestreamCallService() { override val logger: TaggedLogger by taggedLogger("LivestreamViewerService") - override val requiredForegroundTypes: Set - @SuppressLint("InlinedApi") - get() = setOf( - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, - ) - - @RequiresApi(Build.VERSION_CODES.Q) - override fun androidQServiceType(): Int { - return ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - } - - @SuppressLint("InlinedApi") - override fun noPermissionServiceType(): Int { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL - } - } + override val permissionManager = LivestreamViewerPermissionManager() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt new file mode 100644 index 0000000000..3ca631a624 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.managers + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class CallLifecycleManager { + private val logger by taggedLogger("CallLifecycleManager") + fun initializeCallAndSocket( + scope: CoroutineScope, + streamVideo: StreamVideo, + callId: StreamCallId, + onError: () -> Unit, + ) { + scope.launch { + val call = streamVideo.call(callId.type, callId.id) + val update = call.get() + + if (update.isFailure) { + onError() + return@launch + } + } + + scope.launch { + streamVideo.connectIfNotAlreadyConnected() + } + } + + fun updateRingingCall( + scope: CoroutineScope, + streamVideo: StreamVideo, + callId: StreamCallId, + ringingState: RingingState, + ) { + scope.launch { + val call = streamVideo.call(callId.type, callId.id) + streamVideo.state.addRingingCall(call, ringingState) + } + } + + fun endCall(scope: CoroutineScope, callId: StreamCallId?) { + callId?.let { id -> + StreamVideo.Companion.instanceOrNull()?.let { streamVideo -> + val call = streamVideo.call(id.type, id.id) + val ringingState = call.state.ringingState.value + + when (ringingState) { + is RingingState.Outgoing -> { + scope.launch { + call.reject( + "CallService.EndCall", + RejectReason.Custom("Android Service Task Removed"), + ) + logger.i { "[onTaskRemoved] Ended outgoing call for all users" } + } + } + + is RingingState.Incoming -> { + handleIncomingCallTaskRemoved(scope, call) + } + + else -> { + call.leave() + logger.i { "[onTaskRemoved] Ended ongoing call for me" } + } + } + } + } + } + + private fun handleIncomingCallTaskRemoved(scope: CoroutineScope, call: Call) { + val memberCount = call.state.members.value.size + logger.i { "[onTaskRemoved] Total members: $memberCount" } + + if (memberCount == 2) { + scope.launch { + call.reject(source = "memberCount == 2") + logger.i { "[onTaskRemoved] Ended incoming call for both users" } + } + } else { + call.leave() + logger.i { "[onTaskRemoved] Ended incoming call for me" } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt new file mode 100644 index 0000000000..d902ce6b73 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.managers + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Notification +import android.app.Service +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.handlers.StreamDefaultNotificationHandler +import io.getstream.video.android.core.utils.safeCall +import io.getstream.video.android.model.StreamCallId + +internal class CallNotificationManager { + @SuppressLint("MissingPermission") + fun justNotify( + service: Service, + callId: StreamCallId, + notificationId: Int, + notification: Notification, + ) { + if (ActivityCompat.checkSelfPermission( + service, Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + ) { + StreamVideo.Companion.instanceOrNull()?.getStreamNotificationDispatcher() + ?.notify(callId, notificationId, notification) + } + } + + fun cancelNotifications(service: Service, callId: StreamCallId?) { + val notificationManager = NotificationManagerCompat.from(service) + + callId?.let { + notificationManager.cancel(it.hashCode()) + notificationManager.cancel(it.getNotificationId(NotificationType.Incoming)) + } + + safeCall { + val handler = (StreamVideo.Companion.instanceOrNull() as? StreamVideoClient) + ?.streamNotificationManager + ?.notificationConfig + ?.notificationHandler as? StreamDefaultNotificationHandler + handler?.clearMediaSession(callId) + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/CallIntentParams.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/CallIntentParams.kt new file mode 100644 index 0000000000..ec1a01aa12 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/CallIntentParams.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.models + +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.model.StreamCallId + +internal data class CallIntentParams( + val streamVideo: StreamVideoClient, + val callId: StreamCallId, + val trigger: String, + val displayName: String?, +) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt new file mode 100644 index 0000000000..a2979e5596 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.models + +import android.app.Service +import android.content.Intent +import android.content.IntentFilter +import io.getstream.video.android.core.notifications.internal.receivers.ToggleCameraBroadcastReceiver +import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.CoroutineScope + +internal class ServiceState { + + var currentCallId: StreamCallId? = null + var soundPlayer: CallSoundAndVibrationPlayer? = null + private var toggleCameraBroadcastReceiver: ToggleCameraBroadcastReceiver? = null + private var isReceiverRegistered = false + + internal fun registerToggleCameraBroadcastReceiver(service: Service, scope: CoroutineScope) { + if (!isReceiverRegistered) { + try { + toggleCameraBroadcastReceiver = ToggleCameraBroadcastReceiver(scope) + service.registerReceiver( + toggleCameraBroadcastReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) + }, + ) + isReceiverRegistered = true + } catch (e: Exception) { + // Silently fail + } + } + } + + internal fun unregisterToggleCameraBroadcastReceiver(service: Service) { + if (isReceiverRegistered) { + try { + toggleCameraBroadcastReceiver?.let { service.unregisterReceiver(it) } + isReceiverRegistered = false + } catch (e: Exception) { + // Silently fail + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt new file mode 100644 index 0000000000..68fef440ee --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.observers + +import io.getstream.android.video.generated.models.CallAcceptedEvent +import io.getstream.android.video.generated.models.CallEndedEvent +import io.getstream.android.video.generated.models.CallRejectedEvent +import io.getstream.android.video.generated.models.LocalCallMissedEvent +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideoClient +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +internal class CallEventObserver( + private val call: Call, + private val streamVideo: StreamVideoClient, +) { + + private val logger by taggedLogger("CallEventObserver") + + /** + * Starts observing call events and connection state. + */ + fun observe(onServiceStop: () -> Unit, onRemoveIncoming: () -> Unit) { + observeCallEvents(onServiceStop, onRemoveIncoming) + observeConnectionState(onServiceStop) + } + + /** + * Observes call events (accepted, rejected, ended, missed). + */ + private fun observeCallEvents(onServiceStop: () -> Unit, onRemoveIncoming: () -> Unit) { + call.scope.launch { + call.events.collect { event -> + logger.i { "Received event in service: $event" } + handleCallEvent(event, onServiceStop, onRemoveIncoming) + } + } + } + + /** + * Handles different types of call events. + */ + private fun handleCallEvent( + event: Any, + onServiceStop: () -> Unit, + onRemoveIncoming: () -> Unit, + ) { + when (event) { + is CallAcceptedEvent -> { + handleIncomingCallAcceptedByMeOnAnotherDevice( + event.user.id, + streamVideo.userId, + onServiceStop, + ) + } + is CallRejectedEvent -> { + handleIncomingCallRejectedByMeOrCaller( + rejectedByUserId = event.user.id, + myUserId = streamVideo.userId, + createdByUserId = call.state.createdBy.value?.id, + activeCallExists = streamVideo.state.activeCall.value != null, + onServiceStop = onServiceStop, + onRemoveIncoming = onRemoveIncoming, + ) + } + is CallEndedEvent -> onServiceStop() + is LocalCallMissedEvent -> onRemoveIncoming() + } + } + + /** + * Handles call accepted event - stops service if accepted on another device. + */ + private fun handleIncomingCallAcceptedByMeOnAnotherDevice( + acceptedByUserId: String, + myUserId: String, + onServiceStop: () -> Unit, + ) { + val callRingingState = call.state.ringingState.value + + // If I accepted the call on another device while this device is still ringing + if (acceptedByUserId == myUserId && callRingingState is RingingState.Incoming) { + onServiceStop() + } + } + + /** + * Handles call rejected event. + */ + private fun handleIncomingCallRejectedByMeOrCaller( + rejectedByUserId: String, + myUserId: String, + createdByUserId: String?, + activeCallExists: Boolean, + onServiceStop: () -> Unit, + onRemoveIncoming: () -> Unit, + ) { + // Stop service if rejected by me or by the caller + if (rejectedByUserId == myUserId || rejectedByUserId == createdByUserId) { + if (activeCallExists) { + // Another call is active - just remove incoming notification + onRemoveIncoming() + } else { + // No other call - stop service + onServiceStop() + } + } + } + + /** + * Observes connection state changes. + */ + private fun observeConnectionState(onServiceStop: () -> Unit) { + call.scope.launch { + call.state.connection.collectLatest { event -> + if (event is RealtimeConnection.Failed) { + handleConnectionFailure() + } + } + } + } + + /** + * Handles connection failure for ringing calls. + */ + private fun handleConnectionFailure() { + if (call.id == streamVideo.state.ringingCall.value?.id) { + streamVideo.state.removeRingingCall(call) + streamVideo.onCallCleanUp(call) + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt new file mode 100644 index 0000000000..893346d956 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.observers + +import android.app.Notification +import android.content.Context +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.internal.service.CallService +import io.getstream.video.android.core.notifications.internal.service.permissions.ForegroundServicePermissionManager +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +internal class NotificationUpdateObserver( + private val call: Call, + private val streamVideo: StreamVideoClient, + private val scope: CoroutineScope, + private val permissionManager: ForegroundServicePermissionManager, + val onStartService: ( + notificationId: Int, + notification: Notification, + trigger: String, + foregroundServiceType: Int, + ) -> Unit, +) { + + private val logger by taggedLogger("NotificationUpdateObserver") + + /** + * Starts observing notification update triggers. + */ + @OptIn(ExperimentalStreamVideoApi::class) + fun observe(context: Context) { + scope.launch { + logger.d { "Observing notification updates for call: ${call.cid}" } + + val updateTriggers = getUpdateTriggers() + + updateTriggers.collectLatest { _ -> + updateNotification(context) + } + } + } + + /** + * Gets the flow that triggers notification updates. + */ + @OptIn(ExperimentalStreamVideoApi::class) + private fun getUpdateTriggers() = + streamVideo.streamNotificationManager + .notificationConfig + .notificationUpdateTriggers(call) + ?: createDefaultUpdateTriggers() + + /** + * Creates default update triggers from call state. + */ + private fun createDefaultUpdateTriggers(): Flow> { + return combine( + call.state.ringingState, + call.state.members, + call.state.remoteParticipants, + call.state.backstage, + ) { ringingState, members, remoteParticipants, backstage -> + listOf(ringingState, members, remoteParticipants, backstage) + }.distinctUntilChanged() + } + + /** + * Updates the notification based on current call state. + */ + private suspend fun updateNotification(context: Context) { + val ringingState = call.state.ringingState.value + logger.d { "[updateNotification] ringingState: $ringingState" } + + val notification = streamVideo.onCallNotificationUpdate(call) + logger.d { "[updateNotification] notification: ${notification != null}" } + + if (notification != null) { + showNotificationForState(context, ringingState, notification) + } else { + logger.w { "[updateNotification] No notification generated" } + } + } + + /** + * Shows the appropriate notification based on ringing state. + */ + private fun showNotificationForState( + context: Context, + ringingState: RingingState, + notification: Notification, + ) { + val callId = StreamCallId(call.type, call.id) + + when (ringingState) { + is RingingState.Active -> { + showActiveCallNotification(context, callId, notification) + } + is RingingState.Outgoing -> { + showOutgoingCallNotification(context, callId, notification) + } + is RingingState.Incoming -> { + showIncomingCallNotification(context, callId, notification) + } + else -> { + logger.d { "[updateNotification] Unhandled ringing state: $ringingState" } + } + } + } + + private fun showActiveCallNotification( + context: Context, + callId: StreamCallId, + notification: Notification, + ) { + logger.d { "[updateNotification] Showing active call notification" } + startForegroundWithServiceType( + callId.hashCode(), + notification, + CallService.Companion.TRIGGER_ONGOING_CALL, + permissionManager.getServiceType(context, CallService.Companion.TRIGGER_ONGOING_CALL), + ) + } + + private fun showOutgoingCallNotification( + context: Context, + callId: StreamCallId, + notification: Notification, + ) { + logger.d { "[updateNotification] Showing outgoing call notification" } + startForegroundWithServiceType( + callId.getNotificationId(NotificationType.Incoming), + notification, + CallService.Companion.TRIGGER_OUTGOING_CALL, + permissionManager.getServiceType(context, CallService.Companion.TRIGGER_OUTGOING_CALL), + ) + } + + private fun showIncomingCallNotification( + context: Context, + callId: StreamCallId, + notification: Notification, + ) { + logger.d { "[updateNotification] Showing incoming call notification" } + startForegroundWithServiceType( + callId.getNotificationId(NotificationType.Incoming), + notification, + CallService.Companion.TRIGGER_INCOMING_CALL, + permissionManager.getServiceType(context, CallService.Companion.TRIGGER_INCOMING_CALL), + ) + } + + fun startForegroundWithServiceType( + notificationId: Int, + notification: Notification, + trigger: String, + foregroundServiceType: Int, + ) { + onStartService(notificationId, notification, trigger, foregroundServiceType) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/RingingStateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/RingingStateObserver.kt new file mode 100644 index 0000000000..ea02a7f61e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/RingingStateObserver.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.observers + +import android.content.Context +import android.media.AudioManager +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.core.socket.common.scope.ClientScope +import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class RingingStateObserver( + private val call: Call, + private val soundPlayer: CallSoundAndVibrationPlayer?, + private val streamVideo: StreamVideoClient, + private val scope: CoroutineScope, +) { + private val logger by taggedLogger("RingingStateObserver") + + /** + * Starts observing ringing state changes. + */ + fun observe(onStopService: () -> Unit) { + call.scope.launch { + call.state.ringingState.collect { state -> + logger.i { "Ringing state: $state" } + handleRingingState(state, onStopService) + } + } + } + + /** + * Handles different ringing states. + */ + private fun handleRingingState(state: RingingState, onStopService: () -> Unit) { + when (state) { + is RingingState.Incoming -> handleIncomingState(state) + is RingingState.Outgoing -> handleOutgoingState(state) + is RingingState.Active -> handleActiveState() + is RingingState.RejectedByAll -> handleRejectedByAllState(onStopService) + is RingingState.TimeoutNoAnswer -> handleTimeoutState() + else -> soundPlayer?.stopCallSound() + } + } + + /** + * Handles incoming call state - plays ringtone and vibrates. + */ + private fun handleIncomingState(state: RingingState.Incoming) { + if (!state.acceptedByMe) { + // Start vibration if allowed + if (shouldVibrate()) { + val pattern = streamVideo.vibrationConfig.vibratePattern + soundPlayer?.vibrate(pattern) + } + + // Play incoming call sound + soundPlayer?.playCallSound( + streamVideo.sounds.ringingConfig.incomingCallSoundUri, + streamVideo.sounds.mutedRingingConfig?.playIncomingSoundIfMuted ?: false, + ) + } else { + // Call accepted - stop sounds immediately for better responsiveness + soundPlayer?.stopCallSound() + } + } + + /** + * Handles outgoing call state - plays outgoing ringtone. + */ + private fun handleOutgoingState(state: RingingState.Outgoing) { + if (!state.acceptedByCallee) { + soundPlayer?.playCallSound( + streamVideo.sounds.ringingConfig.outgoingCallSoundUri, + streamVideo.sounds.mutedRingingConfig?.playOutgoingSoundIfMuted ?: false, + ) + } else { + // Call accepted - stop sounds immediately + soundPlayer?.stopCallSound() + } + } + + /** + * Handles active call state - stops all sounds. + */ + private fun handleActiveState() { + soundPlayer?.stopCallSound() + } + + /** + * Handles rejected by all state - rejects call and stops service. + */ + private fun handleRejectedByAllState(onStopService: () -> Unit) { + ClientScope().launch { + call.reject( + source = "RingingState.RejectedByAll", + reason = RejectReason.Decline, + ) + } + soundPlayer?.stopCallSound() + onStopService() + } + + /** + * Handles timeout state - stops sounds. + */ + private fun handleTimeoutState() { + soundPlayer?.stopCallSound() + } + + /** + * Determines if vibration should be triggered based on ringer mode. + */ + private fun shouldVibrate(): Boolean { + if (!streamVideo.vibrationConfig.enabled) return false + + return try { + val audioManager = streamVideo.context.getSystemService( + Context.AUDIO_SERVICE, + ) as AudioManager + audioManager.ringerMode in listOf( + AudioManager.RINGER_MODE_NORMAL, + AudioManager.RINGER_MODE_VIBRATE, + ) + } catch (e: Exception) { + logger.e { "Failed to get audio manager: ${e.message}" } + false + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManager.kt new file mode 100644 index 0000000000..a7a93cf3af --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManager.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.annotation.SuppressLint +import android.content.pm.ServiceInfo + +internal class AudioCallPermissionManager : ForegroundServicePermissionManager() { + + override val requiredForegroundTypes: Set + @SuppressLint("InlinedApi") + get() = setOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManager.kt new file mode 100644 index 0000000000..895c00bfa7 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManager.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import io.getstream.video.android.core.notifications.internal.service.CallService + +internal open class ForegroundServicePermissionManager { + @SuppressLint("InlinedApi") + internal open val requiredForegroundTypes = setOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + + /** + * Map each service type to the permission it requires (if any). + * Subclasses can reuse or extend this mapping. + * [ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK] requires Q + * [ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL] requires Q + * [ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA] requires R + * [ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE] requires R + * [ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE] requires UPSIDE_DOWN_CAKE + */ + @SuppressLint("InlinedApi") + private val typePermissionsMap = mapOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA to Manifest.permission.CAMERA, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE to Manifest.permission.RECORD_AUDIO, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK to null, + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL to null, + ) + + fun getServiceType(context: Context, trigger: String): Int { + return when (trigger) { + CallService.Companion.TRIGGER_ONGOING_CALL -> calculateServiceType(context) + else -> noPermissionServiceType() + } + } + + private fun calculateServiceType(context: Context): Int { + return if (hasAllPermissions(context)) { + allPermissionsServiceType() + } else { + noPermissionServiceType() + } + } + + @SuppressLint("InlinedApi") + internal fun allPermissionsServiceType(): Int = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + requiredForegroundTypes.reduce { acc, type -> acc or type } + Build.VERSION.SDK_INT == Build.VERSION_CODES.Q -> + androidQServiceType() + else -> { + /** + * Android Pre-Q Service Type (no need to bother) + * We don't start foreground service with type + */ + 0 + } + } + + @SuppressLint("InlinedApi") + internal open fun noPermissionServiceType(): Int = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> + ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + else -> + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } + + @SuppressLint("InlinedApi") + internal open fun androidQServiceType(): Int { + return if (requiredForegroundTypes.contains( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + ) + ) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } else { + /** + * Existing behavior + * [ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE] requires [Build.VERSION_CODES.UPSIDE_DOWN_CAKE] + */ + ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + } + } + + @RequiresApi(Build.VERSION_CODES.R) + internal fun hasAllPermissions(context: Context): Boolean { + return requiredForegroundTypes.all { type -> + val permission = typePermissionsMap[type] + permission == null || ContextCompat.checkSelfPermission(context, permission) == + PackageManager.PERMISSION_GRANTED + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManager.kt new file mode 100644 index 0000000000..98e73eacbf --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManager.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.annotation.SuppressLint +import android.content.pm.ServiceInfo + +internal class LivestreamAudioCallPermissionManager : ForegroundServicePermissionManager() { + + override val requiredForegroundTypes: Set + @SuppressLint("InlinedApi") + get() = setOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManager.kt new file mode 100644 index 0000000000..9d9a3a00ef --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManager.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.annotation.SuppressLint +import android.content.pm.ServiceInfo + +internal class LivestreamCallPermissionManager : ForegroundServicePermissionManager() { + + override val requiredForegroundTypes: Set + @SuppressLint("InlinedApi") + get() = setOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManager.kt new file mode 100644 index 0000000000..be8f451271 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManager.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.annotation.SuppressLint +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.annotation.RequiresApi + +internal class LivestreamViewerPermissionManager : ForegroundServicePermissionManager() { + + override val requiredForegroundTypes: Set + @SuppressLint("InlinedApi") + get() = setOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + ) + + @RequiresApi(Build.VERSION_CODES.Q) + override fun androidQServiceType(): Int { + return ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } + + @SuppressLint("InlinedApi") + override fun noPermissionServiceType(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } + } +} From 7b0942cb8bcb69657669d26bc28009ca3d4bd9ce Mon Sep 17 00:00:00 2001 From: rahullohra Date: Thu, 18 Dec 2025 13:56:12 +0530 Subject: [PATCH 02/42] chore: add logging to notification and lifecycle managers --- .../internal/service/managers/CallLifecycleManager.kt | 6 +++--- .../internal/service/managers/CallNotificationManager.kt | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt index 3ca631a624..0f5076c87c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt @@ -92,16 +92,16 @@ internal class CallLifecycleManager { private fun handleIncomingCallTaskRemoved(scope: CoroutineScope, call: Call) { val memberCount = call.state.members.value.size - logger.i { "[onTaskRemoved] Total members: $memberCount" } + logger.i { "[handleIncomingCallTaskRemoved] Total members: $memberCount" } if (memberCount == 2) { scope.launch { call.reject(source = "memberCount == 2") - logger.i { "[onTaskRemoved] Ended incoming call for both users" } + logger.i { "[handleIncomingCallTaskRemoved] Ended incoming call for both users" } } } else { call.leave() - logger.i { "[onTaskRemoved] Ended incoming call for me" } + logger.i { "[handleIncomingCallTaskRemoved] Ended incoming call for me" } } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt index d902ce6b73..5228731cfc 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt @@ -23,14 +23,18 @@ import android.app.Service import android.content.pm.PackageManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat +import io.getstream.log.taggedLogger import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.handlers.StreamDefaultNotificationHandler import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId +import kotlin.getValue internal class CallNotificationManager { + private val logger by taggedLogger("CallNotificationManager") + @SuppressLint("MissingPermission") fun justNotify( service: Service, @@ -48,6 +52,8 @@ internal class CallNotificationManager { } fun cancelNotifications(service: Service, callId: StreamCallId?) { + logger.d { "[cancelNotifications]" } + val notificationManager = NotificationManagerCompat.from(service) callId?.let { From dc7f0773a4184b2527ea04dc52406d10a6bb424b Mon Sep 17 00:00:00 2001 From: rahullohra Date: Thu, 18 Dec 2025 13:58:46 +0530 Subject: [PATCH 03/42] feat: Add logging to CallEventObserver Adds more detailed logging to the `CallEventObserver` to provide better insight into the handling of incoming call events. --- .../internal/service/observers/CallEventObserver.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt index 68fef440ee..8269bd3d98 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt @@ -25,6 +25,7 @@ import io.getstream.video.android.core.Call import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.utils.toUser import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -75,7 +76,7 @@ internal class CallEventObserver( handleIncomingCallRejectedByMeOrCaller( rejectedByUserId = event.user.id, myUserId = streamVideo.userId, - createdByUserId = call.state.createdBy.value?.id, + createdByUserId = event.call.createdBy.toUser().id, activeCallExists = streamVideo.state.activeCall.value != null, onServiceStop = onServiceStop, onRemoveIncoming = onRemoveIncoming, @@ -94,6 +95,7 @@ internal class CallEventObserver( myUserId: String, onServiceStop: () -> Unit, ) { + logger.d { "[handleIncomingCallAcceptedByMeOnAnotherDevice]" } val callRingingState = call.state.ringingState.value // If I accepted the call on another device while this device is still ringing @@ -114,6 +116,10 @@ internal class CallEventObserver( onRemoveIncoming: () -> Unit, ) { // Stop service if rejected by me or by the caller + logger.d { + "[handleIncomingCallRejectedByMeOrCaller] rejectedByUserId == myUserId :${rejectedByUserId == myUserId}, rejectedByUserId == createdByUserId :${rejectedByUserId == createdByUserId}" + } + if (rejectedByUserId == myUserId || rejectedByUserId == createdByUserId) { if (activeCallExists) { // Another call is active - just remove incoming notification From 55f48bb10dee64e6a713c178311b71d512a8c2cf Mon Sep 17 00:00:00 2001 From: rahullohra Date: Thu, 18 Dec 2025 13:59:10 +0530 Subject: [PATCH 04/42] feat: Update createdBy on call rejected event When a `CallRejectedEvent` is received, the `createdBy` field on the call state is now updated with the user who created the call. --- .../src/main/kotlin/io/getstream/video/android/core/CallState.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 945124b12b..515e4f687b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -750,6 +750,7 @@ public class CallState( } is CallRejectedEvent -> { + _createdBy.value = event.call.createdBy.toUser() val new = _rejectedBy.value.toMutableSet() new.add(event.user.id) _rejectedBy.value = new.toSet() From 6f190b741001c58d8190d51058cc0924c296990a Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Dec 2025 14:03:14 +0530 Subject: [PATCH 05/42] chore: add createdByUserId in LocalCallMissedEvent --- .../getstream/video/android/core/CallState.kt | 4 + .../video/android/core/events/LocalEvent.kt | 2 +- .../StreamDefaultNotificationHandler.kt | 7 +- .../core/notifications/internal/Debouncer.kt | 94 +++++++++++++++++++ .../internal/service/IncomingCallPresenter.kt | 2 +- 5 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 515e4f687b..6ad7fb8dc0 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -749,6 +749,10 @@ public class CallState( } } + is CallMissedEvent -> { + _createdBy.value = event.call.createdBy.toUser() + } + is CallRejectedEvent -> { _createdBy.value = event.call.createdBy.toUser() val new = _rejectedBy.value.toMutableSet() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/events/LocalEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/events/LocalEvent.kt index f41dc6b442..c8870cd060 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/events/LocalEvent.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/events/LocalEvent.kt @@ -35,7 +35,7 @@ abstract class LocalEvent : WSCallEvent, VideoEvent() * allowing the SDK to handle it as if a [io.getstream.android.video.generated.models.CallRejectedEvent] had * been received in real time (or to apply adjusted logic if needed). */ -data class LocalCallMissedEvent(val callCid: String) : LocalEvent() { +data class LocalCallMissedEvent(val createdById: String, val callCid: String) : LocalEvent() { override fun getCallCID(): String { return callCid } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index 84ebc3e4a1..2fe65d50d5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt @@ -216,12 +216,17 @@ constructor( payload, ).showNotification(callId, callId.hashCode()) + val createdByUserId = try { + payload["created_by_id"] as String + } catch (ex: Exception) { + "" + } /** * Under poor internet there can be delay in receiving the * [io.getstream.android.video.generated.models.CallRejectedEvent] so we emit [LocalCallMissedEvent] */ StreamVideo.instanceOrNull()?.let { - (it as StreamVideoClient).fireEvent(LocalCallMissedEvent(callId.cid)) + (it as StreamVideoClient).fireEvent(LocalCallMissedEvent(createdByUserId, callId.cid)) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt new file mode 100644 index 0000000000..869527852e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal + +import android.os.Handler +import android.os.Looper +import androidx.core.util.remove +import java.util.concurrent.ConcurrentHashMap + +internal class Debouncer { + + private val handler = Handler(Looper.getMainLooper()) + private var runnable: Runnable? = null + + fun submit(delayMs: Long, action: () -> Unit) { + // Cancel previous run + runnable?.let { handler.removeCallbacks(it) } + + // Schedule new run + runnable = Runnable { action() } + handler.postDelayed(runnable!!, delayMs) + } +} + +internal class Throttler { + + // A thread-safe map to store the last execution time for each key. + // The value is the timestamp (in milliseconds) when the key's cooldown started. + private val lastExecutionTimestamps = ConcurrentHashMap() + + /** + * Submits an action for potential execution, identified by a unique key. + * + * @param key A unique String identifying this action or instruction. + * @param cooldownMs The duration in milliseconds for this key's cooldown period. + * @param action The lambda to execute if the key is not on cooldown. + */ + fun throttleFirst(key: String, cooldownMs: Long, action: () -> Unit) { + val currentTime = System.currentTimeMillis() + val lastExecutionTime = lastExecutionTimestamps[key] + + // Check if the key is not on cooldown. + // This is true if the key has never been used (lastExecutionTime is null) + // or if the cooldown period has passed. + if (lastExecutionTime == null || (currentTime - lastExecutionTime) >= cooldownMs) { + // Update the last execution time for this key to the current time. + lastExecutionTimestamps[key] = currentTime + // Execute the action. + action() + } + // If the key is on cooldown, do nothing. + } + + fun throttleFirst(cooldownMs: Long, action: () -> Unit) { + val key = getKey(action) + throttleFirst(key, cooldownMs, action) + } + + fun getKey(action: () -> Unit): String { + return Thread.currentThread().stackTrace.getOrNull(4)?.let { + "${it.className}#${it.methodName}:${it.lineNumber}" + } ?: "fallback_${action.hashCode()}" + } + + /** + * Manually clears the cooldown for a specific key, allowing its next action to run immediately. + * + * @param key The key to reset. + */ + fun reset(key: String) { + lastExecutionTimestamps.remove(key) + } + + /** + * Clears all active cooldowns. + */ + fun resetAll() { + lastExecutionTimestamps.clear() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt index 068c797eed..612e7293b0 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt @@ -60,7 +60,7 @@ internal class IncomingCallPresenter(private val serviceIntentBuilder: ServiceIn ), ), ) - ComponentName(context, CallService::class.java) + showIncomingCallResult = ShowIncomingCallResult.FG_SERVICE } else { logger.d { "[showIncomingCall] Starting regular service" } From 67ffeae4af7e6a94caf58c358dc1ecf13365d1c1 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Dec 2025 14:24:35 +0530 Subject: [PATCH 06/42] chore: fix missed imports --- .../src/main/kotlin/io/getstream/video/android/core/CallState.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 6ad7fb8dc0..4f4bc6596a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -33,6 +33,7 @@ import io.getstream.android.video.generated.models.CallMemberAddedEvent import io.getstream.android.video.generated.models.CallMemberRemovedEvent import io.getstream.android.video.generated.models.CallMemberUpdatedEvent import io.getstream.android.video.generated.models.CallMemberUpdatedPermissionEvent +import io.getstream.android.video.generated.models.CallMissedEvent import io.getstream.android.video.generated.models.CallModerationBlurEvent import io.getstream.android.video.generated.models.CallParticipantResponse import io.getstream.android.video.generated.models.CallReactionEvent From d164b5719834fa99b611d5c232dfdbbc993f1c8c Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Dec 2025 14:25:27 +0530 Subject: [PATCH 07/42] chore: fix service cancellation from LocalCallMissedEvent --- .../internal/service/observers/CallEventObserver.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt index 8269bd3d98..e0f69e9ee5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt @@ -83,7 +83,16 @@ internal class CallEventObserver( ) } is CallEndedEvent -> onServiceStop() - is LocalCallMissedEvent -> onRemoveIncoming() + is LocalCallMissedEvent -> { + val activeCallExists = streamVideo.state.activeCall.value != null + if (activeCallExists) { + // Another call is active - just remove incoming notification + onRemoveIncoming() + } else { + // No other call - stop service + onServiceStop() + } + } } } From 9ae445518154585438ea11239ae60e6e039d58dc Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Dec 2025 14:29:10 +0530 Subject: [PATCH 08/42] chore: refactor --- .../internal/service/IncomingCallPresenter.kt | 1 - .../internal/service/ServiceIntentBuilder.kt | 52 +++++++++---------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt index 612e7293b0..ede264e3d7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt @@ -18,7 +18,6 @@ package io.getstream.video.android.core.notifications.internal.service import android.Manifest import android.app.Notification -import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt index 1731575e12..f02cd5cab5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt @@ -16,7 +16,6 @@ package io.getstream.video.android.core.notifications.internal.service -import android.app.ActivityManager import android.content.Context import android.content.Intent import io.getstream.log.taggedLogger @@ -68,36 +67,35 @@ internal class ServiceIntentBuilder { return serviceIntent } - fun buildStopIntent(context: Context, stopServiceParam: StopServiceParam): Intent = - safeCallWithDefault(Intent(context, CallService::class.java)) { - val serviceClass = stopServiceParam.callServiceConfiguration.serviceClass - - val intent = if (isServiceRunning(context, serviceClass)) { - Intent(context, serviceClass) - } else { - Intent(context, CallService::class.java) - } - stopServiceParam.call?.let { call -> - logger.d { "[buildStopIntent], call_id:${call.cid}" } - val streamCallId = StreamCallId(call.type, call.id, call.cid) - intent.putExtra(INTENT_EXTRA_CALL_CID, streamCallId) - } - intent.putExtra(EXTRA_STOP_SERVICE, true) + fun buildStopIntent(context: Context, stopServiceParam: StopServiceParam): Intent? { + val serviceClass = stopServiceParam.callServiceConfiguration.serviceClass + val intent = if (isServiceRunning(serviceClass)) { + Intent(context, serviceClass) + } else { + return null } + logger.d { + "Noob [buildStopIntent], class:${intent.component?.shortClassName}, call: ${stopServiceParam.call}" + } + stopServiceParam.call?.let { call -> + logger.d { "[buildStopIntent] call_id:${call.cid}" } + val streamCallId = StreamCallId(call.type, call.id, call.cid) + intent.putExtra(INTENT_EXTRA_CALL_CID, streamCallId) + } + return intent.putExtra(EXTRA_STOP_SERVICE, true) + } - private fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean = + private fun isServiceRunning(serviceClass: Class<*>): Boolean = safeCallWithDefault(true) { - val activityManager = context.getSystemService( - Context.ACTIVITY_SERVICE, - ) as ActivityManager - val runningServices = activityManager.getRunningServices(Int.MAX_VALUE) - for (service in runningServices) { - if (serviceClass.name == service.service.className) { - logger.w { "Service is running: $serviceClass" } - return true + if (CallService.isServiceRunning()) { + val runningServiceName = CallService.runningServiceClassName.filter { + it.contains(serviceClass.simpleName) } + logger.d { "[isServiceRunning], Service is running: $runningServiceName" } + return@safeCallWithDefault runningServiceName.isNotEmpty() + } else { + logger.w { "[isServiceRunning], Service is not running: $serviceClass" } + return@safeCallWithDefault true } - logger.w { "Service is NOT running: $serviceClass" } - return false } } From 299261679f613623c6d30cca09b05f8913f3f08d Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Dec 2025 14:42:43 +0530 Subject: [PATCH 09/42] chore: fix notification id for missed-call --- .../handlers/StreamDefaultNotificationHandler.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index 2fe65d50d5..72545702ce 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt @@ -54,6 +54,7 @@ import io.getstream.video.android.core.notifications.IncomingNotificationData import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_LIVE_CALL import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_MISSED_CALL import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_NOTIFICATION +import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.StreamIntentResolver import io.getstream.video.android.core.notifications.dispatchers.DefaultNotificationDispatcher import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher @@ -205,16 +206,12 @@ constructor( payload: Map, ) { logger.d { "[onMissedCall] #ringing; callId: ${callId.id}" } - val notificationId = callId.hashCode() - val intent = intentResolver.searchMissedCallPendingIntent(callId, notificationId, payload) ?: run { - logger.e { "Couldn't find any activity for $ACTION_MISSED_CALL" } - intentResolver.getDefaultPendingIntent(payload) - } + val notificationId = callId.getNotificationId(NotificationType.Missed) getMissedCallNotification( callId, callDisplayName, payload, - ).showNotification(callId, callId.hashCode()) + ).showNotification(callId, notificationId) val createdByUserId = try { payload["created_by_id"] as String From 5cb22994f868f71657b1b4a4a5fb9301b8d5efb2 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Dec 2025 14:43:16 +0530 Subject: [PATCH 10/42] chore: add logic for time-sensitive services --- .../core/notifications/internal/service/models/ServiceState.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt index a2979e5596..8a8fd5f8b0 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceState.kt @@ -23,6 +23,7 @@ import io.getstream.video.android.core.notifications.internal.receivers.ToggleCa import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer import io.getstream.video.android.model.StreamCallId import kotlinx.coroutines.CoroutineScope +import org.threeten.bp.OffsetDateTime internal class ServiceState { @@ -31,6 +32,8 @@ internal class ServiceState { private var toggleCameraBroadcastReceiver: ToggleCameraBroadcastReceiver? = null private var isReceiverRegistered = false + var startTime: OffsetDateTime? = null + internal fun registerToggleCameraBroadcastReceiver(service: Service, scope: CoroutineScope) { if (!isReceiverRegistered) { try { From 594cc896279e86fa03a138627e1ff7c4a18dabcc Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Dec 2025 21:19:02 +0530 Subject: [PATCH 11/42] chore: temp --- .../io/getstream/video/android/core/Call.kt | 2 +- .../video/android/core/ClientState.kt | 3 +- .../core/notifications/internal/Debouncer.kt | 12 +- .../internal/service/CallService.kt | 123 ++++++++++++++---- .../internal/service/ServiceIntentBuilder.kt | 8 +- .../internal/service/ServiceLauncher.kt | 24 +++- .../observers/NotificationUpdateObserver.kt | 6 +- 7 files changed, 134 insertions(+), 44 deletions(-) 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 c617271814..a591da90a1 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 @@ -1495,7 +1495,7 @@ public class Call( } suspend fun accept(): Result { - logger.d { "[accept] #ringing; no args, call_id:$id" } + logger.d { "Noob, [accept] #ringing; no args, call_id:$id" } state.acceptedOnThisDevice = true clientImpl.state.removeRingingCall(this) 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 692d900f13..f3d49efd92 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 @@ -228,6 +228,7 @@ class ClientState(private val client: StreamVideo) { * This depends on the flag in [StreamVideoBuilder] called `runForegroundServiceForCalls` */ internal fun maybeStartForegroundService(call: Call, trigger: String) { + logger.d { "Noob, [maybeStartForegroundService], trigger: $trigger" } when (trigger) { CallService.TRIGGER_ONGOING_CALL -> serviceLauncher.showOnGoingCall( call, @@ -253,7 +254,7 @@ class ClientState(private val client: StreamVideo) { if (callConfig.runCallServiceInForeground) { val context = streamVideoClient.context - logger.d { "Building stop intent for call_id: ${call.cid}" } + logger.d { "Noob, Building stop intent for call_id: ${call.cid}" } val serviceLauncher = ServiceLauncher(context) serviceLauncher.stopService(call) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt index 869527852e..da9d9d6d53 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt @@ -18,7 +18,7 @@ package io.getstream.video.android.core.notifications.internal import android.os.Handler import android.os.Looper -import androidx.core.util.remove +import io.getstream.log.taggedLogger import java.util.concurrent.ConcurrentHashMap internal class Debouncer { @@ -36,7 +36,9 @@ internal class Debouncer { } } -internal class Throttler { +internal object Throttler { + + private val logger by taggedLogger("Throttler") // A thread-safe map to store the last execution time for each key. // The value is the timestamp (in milliseconds) when the key's cooldown started. @@ -51,12 +53,14 @@ internal class Throttler { */ fun throttleFirst(key: String, cooldownMs: Long, action: () -> Unit) { val currentTime = System.currentTimeMillis() - val lastExecutionTime = lastExecutionTimestamps[key] + val lastExecutionTime = lastExecutionTimestamps[key] ?: 0L + val timeDiff = currentTime - lastExecutionTime // Check if the key is not on cooldown. // This is true if the key has never been used (lastExecutionTime is null) // or if the cooldown period has passed. - if (lastExecutionTime == null || (currentTime - lastExecutionTime) >= cooldownMs) { + logger.d { "[throttleFirst], timeDiff: $timeDiff, current: $currentTime, lastExecutionTime: $lastExecutionTime, key:$key, hashcode: ${hashCode()}" } + if (lastExecutionTime == 0L || (timeDiff) >= cooldownMs) { // Update the last execution time for this key to the current time. lastExecutionTimestamps[key] = currentTime // Execute the action. diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 0c6ea4744b..6616a35505 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -21,7 +21,6 @@ import android.app.Notification import android.app.Service import android.content.Intent import android.os.IBinder -import androidx.core.app.NotificationManagerCompat import androidx.media.session.MediaButtonReceiver import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call @@ -33,6 +32,8 @@ import io.getstream.video.android.core.notifications.NotificationHandler.Compani import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_DISPLAY_NAME import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.handlers.StreamDefaultNotificationHandler +import io.getstream.video.android.core.notifications.internal.Debouncer +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.EXTRA_STOP_SERVICE import io.getstream.video.android.core.notifications.internal.service.managers.CallLifecycleManager import io.getstream.video.android.core.notifications.internal.service.managers.CallNotificationManager import io.getstream.video.android.core.notifications.internal.service.models.CallIntentParams @@ -51,6 +52,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import java.util.Stack +import kotlin.math.absoluteValue /** * A foreground service that is running when there is an active call. @@ -62,6 +67,7 @@ internal open class CallService : Service() { internal val callLifecycleManager = CallLifecycleManager() private val notificationManager = CallNotificationManager() internal val serviceState = ServiceState() + private var startId: Stack? = null open val serviceType: Int @SuppressLint("InlinedApi") @@ -73,13 +79,6 @@ internal open class CallService : Service() { } } - // Service scope - val handler = CoroutineExceptionHandler { _, exception -> - logger.e(exception) { "[CallService#Scope] Uncaught exception: $exception" } - } - private val serviceScope: CoroutineScope = - CoroutineScope(Dispatchers.IO + handler + SupervisorJob()) - private val serviceNotificationRetriever = ServiceNotificationRetriever() internal companion object { @@ -91,16 +90,37 @@ internal open class CallService : Service() { const val TRIGGER_OUTGOING_CALL = "outgoing_call" const val TRIGGER_ONGOING_CALL = "ongoing_call" const val EXTRA_STOP_SERVICE = "io.getstream.video.android.core.stop_service" + + @Volatile + public var runningServiceClassName: HashSet = HashSet() + + fun isServiceRunning(): Boolean = runningServiceClassName.isNotEmpty() + private val logger by taggedLogger("CallService") + + val handler = CoroutineExceptionHandler { _, exception -> + logger.e(exception) { "[CallService#Scope] Uncaught exception: $exception" } + } + val serviceScope: CoroutineScope = + CoroutineScope(Dispatchers.IO.limitedParallelism(1) + handler + SupervisorJob()) + } + + override fun onCreate() { + super.onCreate() + serviceState.startTime = OffsetDateTime.now() + runningServiceClassName.add(this::class.java.simpleName) } private fun shouldStopService(intent: Intent?): Boolean { val intentCallId = intent?.streamCallId(INTENT_EXTRA_CALL_CID) val shouldStopService = intent?.getBooleanExtra(EXTRA_STOP_SERVICE, false) ?: false + logger.d { + "[shouldStopService]: service hashcode: ${this.hashCode()}, serviceState.currentCallId!=null : ${serviceState.currentCallId != null}, serviceState.currentCallId == intentCallId && shouldStopService : ${serviceState.currentCallId == intentCallId && shouldStopService}" + } if (serviceState.currentCallId != null && serviceState.currentCallId == intentCallId && shouldStopService) { - logger.d { "shouldStopService: true, call_cid:${intentCallId?.cid}" } + logger.d { "[shouldStopService]: true, call_cid:${intentCallId?.cid}" } return true } - logger.d { "shouldStopServiceFrom: false, call_cid:${intentCallId?.cid}" } + logger.d { "[shouldStopService]: false, call_cid:${intentCallId?.cid}" } return false } @@ -149,6 +169,11 @@ internal open class CallService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { logger.d { "[onStartCommand], intent = $intent, flags:$flags, startId:$startId" } + if (this.startId == null) { + this.startId = Stack() + } + this.startId?.push(startId) + logIntentExtras(intent) // Early exit conditions @@ -157,6 +182,7 @@ internal open class CallService : Service() { stopServiceGracefully() return START_NOT_STICKY } + isIntentForExpiredCall(intent) -> return START_NOT_STICKY } @@ -178,11 +204,11 @@ internal open class CallService : Service() { maybeHandleMediaIntent(intent, params.callId) // Promote early to foreground - maybePromoteToForegroundService( - params.streamVideo, - params.callId.hashCode(), - params.trigger, - ) +// maybePromoteToForegroundService( +// params.streamVideo, +// params.callId.hashCode(), +// params.trigger, +// ) val call = params.streamVideo.call(params.callId.type, params.callId.id) @@ -217,9 +243,13 @@ internal open class CallService : Service() { } } - private fun initializeService(streamVideo: StreamVideoClient, callId: StreamCallId, trigger: String) { + private fun initializeService( + streamVideo: StreamVideoClient, + callId: StreamCallId, + trigger: String, + ) { callLifecycleManager.initializeCallAndSocket(serviceScope, streamVideo, callId) { - stopServiceGracefully() +// stopServiceGracefully() } if (trigger == TRIGGER_INCOMING_CALL) { @@ -254,7 +284,7 @@ internal open class CallService : Service() { false } } - + serviceState.currentCallId = callId return when (trigger) { TRIGGER_INCOMING_CALL -> { showIncomingCall(callId, notificationId, notification) @@ -262,7 +292,6 @@ internal open class CallService : Service() { } else -> { - serviceState.currentCallId = callId call.state.updateNotification(notification) startForegroundWithServiceType( callId.hashCode(), @@ -394,7 +423,10 @@ internal open class CallService : Service() { } private fun removeIncomingCall(notificationId: Int) { - NotificationManagerCompat.from(this).cancel(notificationId) + logger.d { + "[removeIncomingCall] notificationId: $notificationId, serviceState.currentCallId: ${serviceState.currentCallId}" + } +// NotificationManagerCompat.from(this).cancel(notificationId) if (serviceState.currentCallId == null) { stopServiceGracefully() } @@ -441,28 +473,33 @@ internal open class CallService : Service() { override fun onTimeout(startId: Int) { super.onTimeout(startId) - logger.w { "Timeout received from the system, service will stop." } + logger.w { "[onTimeout] Timeout received from the system, service will stop." } stopServiceGracefully() } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) + logger.w { "[onTaskRemoved]" } callLifecycleManager.endCall(serviceScope, serviceState.currentCallId) stopServiceGracefully() } override fun onDestroy() { logger.d { - "[onDestroy], Callservice hashcode: ${hashCode()}, call_cid: ${serviceState.currentCallId?.cid}" + "Noob, [onDestroy], Callservice hashcode: ${hashCode()}, call_cid: ${serviceState.currentCallId?.cid}" } - stopServiceGracefully() + runningServiceClassName.remove(this::class.java.simpleName) serviceState.soundPlayer?.cleanUpAudioResources() super.onDestroy() } + fun printLastStackFrames(count: Int = 10) { + val stack = Thread.currentThread().stackTrace + logger.d { stack.takeLast(count).joinToString("\n") } + } + override fun stopService(name: Intent?): Boolean { - logger.d { "[stopService(name)], Callservice hashcode: ${hashCode()}" } - stopServiceGracefully() + logger.d { "Noob, [stopService(name)], Callservice hashcode: ${hashCode()}" } return super.stopService(name) } @@ -483,7 +520,34 @@ internal open class CallService : Service() { * Should be invoke carefully for the calls which are still present in [StreamVideoClient.calls] * Else stopping service by an expired call can cancel current call's notification and the service itself */ + val debouncer = Debouncer() private fun stopServiceGracefully() { +// printLastStackFrames(10) + + serviceState.startTime?.let { startTime -> + + val currentTime = OffsetDateTime.now() + val duration = Duration.between(startTime, currentTime) + val differenceInSeconds = duration.seconds.absoluteValue + val debouncerThresholdTime = 2_000L + logger.d { "[stopServiceGracefully] differenceInSeconds: $differenceInSeconds" } + if (differenceInSeconds >= debouncerThresholdTime) { + internalStopServiceGracefully() + } else { + debouncer.submit(2_000L) { + internalStopServiceGracefully() + } + } + } + +// + } + + private fun internalStopServiceGracefully() { +// printLastStackFrames(4) + logger.d { "Noob, [internalStopServiceGracefully]" } + + stopForeground(STOP_FOREGROUND_REMOVE) notificationManager.cancelNotifications(this, serviceState.currentCallId) serviceState.unregisterToggleCameraBroadcastReceiver(this) @@ -493,6 +557,15 @@ internal open class CallService : Service() { */ serviceState.soundPlayer?.stopCallSound() serviceScope.cancel() + +// stopService(Intent(this, this::class.java)) +// startId?.let { stack -> +// while (stack.isNotEmpty()) { +// val idToStop = stack.pop() +// logger.d { "[internalStopServiceGracefully] Stopping service for startId: $idToStop" } +// stopSelf(idToStop) +// } +// } stopSelf() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt index f02cd5cab5..55b69fa64b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt @@ -32,11 +32,11 @@ import io.getstream.video.android.model.StreamCallId internal class ServiceIntentBuilder { - private val logger by taggedLogger("TelecomIntentBuilder") + private val logger by taggedLogger("ServiceIntentBuilder") fun buildStartIntent(context: Context, startService: StartServiceParam): Intent { val serviceClass = startService.callServiceConfiguration.serviceClass - logger.i { "Resolved service class: $serviceClass" } + logger.i { "[buildStartIntent], Resolved service class: $serviceClass" } val serviceIntent = Intent(context, serviceClass) serviceIntent.putExtra(INTENT_EXTRA_CALL_CID, startService.callId) @@ -68,6 +68,7 @@ internal class ServiceIntentBuilder { } fun buildStopIntent(context: Context, stopServiceParam: StopServiceParam): Intent? { + logger.d { "[buildStopIntent]" } val serviceClass = stopServiceParam.callServiceConfiguration.serviceClass val intent = if (isServiceRunning(serviceClass)) { Intent(context, serviceClass) @@ -75,10 +76,9 @@ internal class ServiceIntentBuilder { return null } logger.d { - "Noob [buildStopIntent], class:${intent.component?.shortClassName}, call: ${stopServiceParam.call}" + "[buildStopIntent], class:${intent.component?.shortClassName}, call_id: ${stopServiceParam.call?.cid}" } stopServiceParam.call?.let { call -> - logger.d { "[buildStopIntent] call_id:${call.cid}" } val streamCallId = StreamCallId(call.type, call.id, call.cid) intent.putExtra(INTENT_EXTRA_CALL_CID, streamCallId) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt index 460aef60b1..8b830e45a1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt @@ -44,6 +44,7 @@ import io.getstream.video.android.core.Call import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.internal.Throttler import io.getstream.video.android.core.notifications.internal.VideoPushDelegate.Companion.DEFAULT_CALL_TEXT import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_REMOVE_INCOMING_CALL import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper @@ -229,29 +230,38 @@ internal class ServiceLauncher(val context: Context) { } fun stopService(call: Call) { - stopCallServiceInternal(call) + logger.d { "[stopService]" } + // noob enable later + Throttler.throttleFirst(1000) { + logger.d { "[stopService], inside throttler.throttleFirst, time in ms: ${System.currentTimeMillis()}" } + stopCallServiceInternal(call) + } } private fun stopCallServiceInternal(call: Call) { + logger.d { "[stopCallServiceInternal]"} val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient streamVideo?.let { streamVideoClient -> val callConfig = streamVideoClient.callServiceConfigRegistry.get(call.type) if (callConfig.runCallServiceInForeground) { val context = streamVideoClient.context - val serviceIntent = serviceIntentBuilder.buildStopIntent( context, StopServiceParam(call, callConfig), ) - logger.d { "Building stop intent for call_id: ${call.cid}" } - serviceIntent.extras?.let { - logBundle(it) + serviceIntent?.let { + logger.d { + "Building stop intent, class: ${serviceIntent.component?.className} for call_id: ${call.cid}" + } + serviceIntent.extras?.let { + logBundle(it) + } + context.startService(serviceIntent) +// context.stopService(serviceIntent) } - context.startService(serviceIntent) } } } - private fun logBundle(bundle: Bundle) { val keys = bundle.keySet() if (keys != null) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt index 893346d956..9327d9d0e2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt @@ -138,8 +138,10 @@ internal class NotificationUpdateObserver( notification: Notification, ) { logger.d { "[updateNotification] Showing active call notification" } +// val notificationId = callId.hashCode() + val notificationId = callId.getNotificationId(NotificationType.Ongoing) startForegroundWithServiceType( - callId.hashCode(), + notificationId, // todo rahul - correct this notification, CallService.Companion.TRIGGER_ONGOING_CALL, permissionManager.getServiceType(context, CallService.Companion.TRIGGER_ONGOING_CALL), @@ -153,7 +155,7 @@ internal class NotificationUpdateObserver( ) { logger.d { "[updateNotification] Showing outgoing call notification" } startForegroundWithServiceType( - callId.getNotificationId(NotificationType.Incoming), + callId.getNotificationId(NotificationType.Outgoing), // todo rahul - correct this notification, CallService.Companion.TRIGGER_OUTGOING_CALL, permissionManager.getServiceType(context, CallService.Companion.TRIGGER_OUTGOING_CALL), From 215fc02b2cc8446b2cd81359f953c2432b73010b Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 23 Dec 2025 12:56:21 +0530 Subject: [PATCH 12/42] feat: Introduce local call events for better state handling Introduces `LocalCallAcceptedEvent` and `LocalCallRejectedEvent` to represent call state changes originating from the local device. These new local events are now used in `CallEventObserver` instead of the generic `CallAcceptedEvent` and `CallRejectedEvent`, providing a clearer distinction for handling call lifecycle states like accepting a call. Additionally, `CallState` now fires these local events, improving the accuracy of event-driven logic. --- .../android/util/StreamVideoInitHelper.kt | 5 +- .../api/stream-video-android-core.api | 56 +++++++++++++-- .../models/LocalCallAcceptedEvent.kt | 68 ++++++++++++++++++ .../models/LocalCallRejectedEvent.kt | 71 +++++++++++++++++++ .../io/getstream/video/android/core/Call.kt | 7 +- .../getstream/video/android/core/CallState.kt | 27 ++++++- .../video/android/core/ClientState.kt | 30 ++++++-- .../DefaultNotificationDispatcher.kt | 2 +- .../StreamDefaultNotificationHandler.kt | 3 +- .../core/notifications/internal/Debouncer.kt | 4 +- .../internal/service/CallService.kt | 16 +++-- .../internal/service/ServiceLauncher.kt | 9 ++- .../service/observers/CallEventObserver.kt | 11 ++- .../observers/NotificationUpdateObserver.kt | 3 +- 14 files changed, 274 insertions(+), 38 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedEvent.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedEvent.kt diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index 0bd321a37f..c5ddfd22a7 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -228,7 +228,10 @@ object StreamVideoInitHelper { val callServiceConfigRegistry = CallServiceConfigRegistry() callServiceConfigRegistry.apply { register(DefaultCallConfigurations.getLivestreamGuestCallServiceConfig()) - register(CallType.AudioCall.name) { enableTelecom(true) } + register( + CallType.AudioCall.name, + DefaultCallConfigurations.audioCall.copy(enableTelecom = true), + ) register(CallType.AnyMarker.name) { setModerationConfig( ModerationConfig( diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index f4a89cf5d4..2450397cf0 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -3557,15 +3557,62 @@ public final class io/getstream/android/video/generated/models/ListTranscription public fun toString ()Ljava/lang/String; } +public final class io/getstream/android/video/generated/models/LocalCallAcceptedEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { + public fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lorg/threeten/bp/OffsetDateTime; + public final fun component3 ()Lio/getstream/android/video/generated/models/CallResponse; + public final fun component4 ()Lio/getstream/android/video/generated/models/UserResponse; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallAcceptedEvent; + public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallAcceptedEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallAcceptedEvent; + public fun equals (Ljava/lang/Object;)Z + public final fun getCall ()Lio/getstream/android/video/generated/models/CallResponse; + public fun getCallCID ()Ljava/lang/String; + public final fun getCallCid ()Ljava/lang/String; + public final fun getCreatedAt ()Lorg/threeten/bp/OffsetDateTime; + public fun getEventType ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public final fun getUser ()Lio/getstream/android/video/generated/models/UserResponse; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/android/video/generated/models/LocalCallMissedEvent : io/getstream/android/video/generated/models/LocalEvent { - public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent; + public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallMissedEvent;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent; + public fun equals (Ljava/lang/Object;)Z + public fun getCallCID ()Ljava/lang/String; + public final fun getCallCid ()Ljava/lang/String; + public final fun getCreatedById ()Ljava/lang/String; + public fun getEventType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/android/video/generated/models/LocalCallRejectedEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { + public fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent; - public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallMissedEvent;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallMissedEvent; + public final fun component2 ()Lorg/threeten/bp/OffsetDateTime; + public final fun component3 ()Lio/getstream/android/video/generated/models/CallResponse; + public final fun component4 ()Lio/getstream/android/video/generated/models/UserResponse; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallRejectedEvent; + public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallRejectedEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallRejectedEvent; public fun equals (Ljava/lang/Object;)Z + public final fun getCall ()Lio/getstream/android/video/generated/models/CallResponse; public fun getCallCID ()Ljava/lang/String; public final fun getCallCid ()Ljava/lang/String; + public final fun getCreatedAt ()Lorg/threeten/bp/OffsetDateTime; public fun getEventType ()Ljava/lang/String; + public final fun getReason ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public final fun getUser ()Lio/getstream/android/video/generated/models/UserResponse; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -7775,7 +7822,7 @@ public final class io/getstream/video/android/core/CallState { public final fun updateFromResponse (Lio/getstream/android/video/generated/models/StartHLSBroadcastingResponse;)V public final fun updateFromResponse (Lio/getstream/android/video/generated/models/StopLiveResponse;)V public final fun updateFromResponse (Lio/getstream/android/video/generated/models/UpdateCallResponse;)V - public final fun updateNotification (Landroid/app/Notification;)V + public final fun updateNotification (ILandroid/app/Notification;)V public final fun updateParticipant (Lio/getstream/video/android/core/ParticipantState;)V public final fun updateParticipantSortingOrder (Ljava/util/Comparator;)V public final fun updateParticipantVisibility (Ljava/lang/String;Lio/getstream/video/android/core/model/VisibilityOnScreenState;)V @@ -7882,6 +7929,7 @@ public final class io/getstream/video/android/core/ClientState { public final fun removeRingingCall ()V public final fun removeRingingCall (Lio/getstream/video/android/core/Call;)V public final fun setActiveCall (Lio/getstream/video/android/core/Call;)V + public final fun transitionToAcceptCall (Lio/getstream/video/android/core/Call;)V } public abstract interface class io/getstream/video/android/core/ConnectionState { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedEvent.kt new file mode 100644 index 0000000000..208a0b3980 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedEvent.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport" +) + +package io.getstream.android.video.generated.models + +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.collections.* +import kotlin.io.* +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.threeten.bp.OffsetDateTime + +/** + * This event is sent when a user accepts a notification to join a call. + */ + +data class LocalCallAcceptedEvent ( + @Json(name = "call_cid") + val callCid: String, + + @Json(name = "created_at") + val createdAt: OffsetDateTime, + + @Json(name = "call") + val call: CallResponse, + + @Json(name = "user") + val user: UserResponse, + + @Json(name = "type") + val type: String +) +: VideoEvent(), WSCallEvent +{ + + override fun getEventType(): String { + return type + } + + override fun getCallCID(): String { + return callCid + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedEvent.kt new file mode 100644 index 0000000000..c327777a06 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedEvent.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport" +) + +package io.getstream.android.video.generated.models + +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.collections.* +import kotlin.io.* +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.threeten.bp.OffsetDateTime + +/** + * This event is sent when a user rejects a notification to join a call. + */ + +data class LocalCallRejectedEvent ( + @Json(name = "call_cid") + val callCid: String, + + @Json(name = "created_at") + val createdAt: OffsetDateTime, + + @Json(name = "call") + val call: CallResponse, + + @Json(name = "user") + val user: UserResponse, + + @Json(name = "type") + val type: String, + + @Json(name = "reason") + val reason: String? = null +) +: VideoEvent(), WSCallEvent +{ + + override fun getEventType(): String { + return type + } + + override fun getCallCID(): String { + return callCid + } +} 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 a591da90a1..1e745e722a 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 @@ -462,6 +462,9 @@ public class Call( } response.onSuccess { + if (ring) { + client.state._ringingCall.value = this + } state.updateFromResponse(it) if (ring) { client.state.addRingingCall(this, RingingState.Outgoing()) @@ -907,7 +910,6 @@ public class Call( } private fun leave(disconnectionReason: Throwable?) = atomicLeave { - val callId = id session?.leaveWithReason(disconnectionReason?.message ?: "user") leaveTimeoutAfterDisconnect?.cancel() network.unsubscribe(listener) @@ -1498,8 +1500,7 @@ public class Call( logger.d { "Noob, [accept] #ringing; no args, call_id:$id" } state.acceptedOnThisDevice = true - clientImpl.state.removeRingingCall(this) - clientImpl.state.maybeStopForegroundService(call = this) + clientImpl.state.transitionToAcceptCall(this) return clientImpl.accept(type, id) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 4f4bc6596a..a30ea6e8e2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -64,7 +64,9 @@ import io.getstream.android.video.generated.models.GetOrCreateCallResponse import io.getstream.android.video.generated.models.GoLiveResponse import io.getstream.android.video.generated.models.HealthCheckEvent import io.getstream.android.video.generated.models.JoinCallResponse +import io.getstream.android.video.generated.models.LocalCallAcceptedEvent import io.getstream.android.video.generated.models.LocalCallMissedEvent +import io.getstream.android.video.generated.models.LocalCallRejectedEvent import io.getstream.android.video.generated.models.MemberResponse import io.getstream.android.video.generated.models.MuteUsersResponse import io.getstream.android.video.generated.models.OwnCapability @@ -700,6 +702,7 @@ public class CallState( */ internal val atomicNotification: AtomicReference = AtomicReference(null) + internal var notificationId: Int? = null @InternalStreamVideoApi internal var jetpackTelecomRepository: JetpackTelecomRepository? = null @@ -748,6 +751,15 @@ public class CallState( // Then leave the call on this device if (!acceptedOnThisDevice) call.leave() } + call.fireEvent( + LocalCallAcceptedEvent( + event.callCid, + event.createdAt, + event.call, + event.user, + event.type, + ), + ) } is CallMissedEvent -> { @@ -769,6 +781,16 @@ public class CallState( } }, ) + call.fireEvent( + LocalCallRejectedEvent( + event.callCid, + event.createdAt, + event.call, + event.user, + event.type, + event.reason, + ), + ) } is LocalCallMissedEvent -> { @@ -1651,8 +1673,9 @@ public class CallState( _rejectActionBundle.value = bundle } - fun updateNotification(notification: Notification) { - atomicNotification.set(notification) + fun updateNotification(notificationId: Int, notification: Notification) { + this.notificationId = notificationId + this.atomicNotification.set(notification) } } 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 f3d49efd92..6cd23bde17 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 @@ -163,13 +163,22 @@ class ClientState(private val client: StreamVideo) { fun setActiveCall(call: Call) { this._activeCall.value = call - removeRingingCall(call) - call.scope.launch { - /** - * Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service - */ - delay(500L) - maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + val ringingState = call.state.ringingState.value + when (ringingState) { + is RingingState.Incoming -> { + transitionToAcceptCall(call) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } + else -> { + removeRingingCall(call) + call.scope.launch { + /** + * Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service + */ + delay(500L) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } + } } } @@ -223,6 +232,13 @@ class ClientState(private val client: StreamVideo) { } } + fun transitionToAcceptCall(call: Call) { + if (call.id == ringingCall.value?.id) { + (client as StreamVideoClient).callSoundAndVibrationPlayer.stopCallSound() + _ringingCall.value = null + } + } + /** * Start a foreground service that manages the call even when the UI is gone. * This depends on the flag in [StreamVideoBuilder] called `runForegroundServiceForCalls` diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt index 79240d79d0..eb4ef41025 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt @@ -34,7 +34,7 @@ class DefaultNotificationDispatcher( override fun notify(streamCallId: StreamCallId, id: Int, notification: Notification) { logger.d { "[notify] callId: ${streamCallId.id}, notificationId: $id" } StreamVideo.instanceOrNull()?.call(streamCallId.type, streamCallId.id) - ?.state?.updateNotification(notification) + ?.state?.updateNotification(id, notification) notificationManager.notify(id, notification) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index 72545702ce..3066458402 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt @@ -52,7 +52,6 @@ import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver import io.getstream.video.android.core.notifications.IncomingNotificationAction import io.getstream.video.android.core.notifications.IncomingNotificationData import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_LIVE_CALL -import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_MISSED_CALL import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_NOTIFICATION import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.StreamIntentResolver @@ -259,7 +258,7 @@ constructor( payload: Map, ): Notification? { logger.d { "[getMissedCallNotification] callId: ${callId.id}, callDisplayName: $callDisplayName" } - val notificationId = callId.hashCode() + val notificationId = callId.getNotificationId(NotificationType.Missed) val intent = intentResolver.searchMissedCallPendingIntent(callId, notificationId, payload) ?: intentResolver.getDefaultPendingIntent(payload) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt index da9d9d6d53..5e8dddbab2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt @@ -59,7 +59,9 @@ internal object Throttler { // Check if the key is not on cooldown. // This is true if the key has never been used (lastExecutionTime is null) // or if the cooldown period has passed. - logger.d { "[throttleFirst], timeDiff: $timeDiff, current: $currentTime, lastExecutionTime: $lastExecutionTime, key:$key, hashcode: ${hashCode()}" } + logger.d { + "[throttleFirst], timeDiff: $timeDiff, current: $currentTime, lastExecutionTime: $lastExecutionTime, key:$key, hashcode: ${hashCode()}" + } if (lastExecutionTime == 0L || (timeDiff) >= cooldownMs) { // Update the last execution time for this key to the current time. lastExecutionTimestamps[key] = currentTime diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 6616a35505..0b99789f6b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -277,7 +277,7 @@ internal open class CallService : Service() { ): Boolean { if (notification == null) { return if (trigger == TRIGGER_REMOVE_INCOMING_CALL) { - removeIncomingCall(notificationId) + removeIncomingCall(notificationId, call) true } else { logger.e { "Could not get notification for trigger: $trigger, callId: ${callId.id}" } @@ -292,9 +292,10 @@ internal open class CallService : Service() { } else -> { - call.state.updateNotification(notification) + val notificationId = callId.hashCode() + call.state.updateNotification(notificationId, notification) startForegroundWithServiceType( - callId.hashCode(), + notificationId, notification, trigger, permissionManager.getServiceType(baseContext, trigger), @@ -406,7 +407,7 @@ internal open class CallService : Service() { if (!hasActiveCall) { StreamVideo.instanceOrNull()?.call(callId.type, callId.id) - ?.state?.updateNotification(notification) + ?.state?.updateNotification(notificationId, notification) startForegroundWithServiceType( notificationId, @@ -422,12 +423,12 @@ internal open class CallService : Service() { } } - private fun removeIncomingCall(notificationId: Int) { + private fun removeIncomingCall(notificationId: Int, call: Call) { logger.d { - "[removeIncomingCall] notificationId: $notificationId, serviceState.currentCallId: ${serviceState.currentCallId}" + "[removeIncomingCall] notificationId: $notificationId, serviceState.currentCallId?.cid == call.cid: ${serviceState.currentCallId?.cid == call.cid}" } // NotificationManagerCompat.from(this).cancel(notificationId) - if (serviceState.currentCallId == null) { + if (serviceState.currentCallId?.cid == call.cid) { stopServiceGracefully() } } @@ -444,6 +445,7 @@ internal open class CallService : Service() { onRemoveIncoming = { removeIncomingCall( callId.getNotificationId(NotificationType.Incoming), + call, ) }, ) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt index 8b830e45a1..999ebc1aac 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt @@ -202,8 +202,9 @@ internal class ServiceLauncher(val context: Context) { callId: StreamCallId, ) { notification?.let { + val notificationId = callId.getNotificationId(NotificationType.Incoming) streamVideo.call(callId.type, callId.id) - .state.updateNotification(notification) + .state.updateNotification(notificationId, notification) } } @@ -233,13 +234,15 @@ internal class ServiceLauncher(val context: Context) { logger.d { "[stopService]" } // noob enable later Throttler.throttleFirst(1000) { - logger.d { "[stopService], inside throttler.throttleFirst, time in ms: ${System.currentTimeMillis()}" } + logger.d { + "[stopService], inside throttler.throttleFirst, time in ms: ${System.currentTimeMillis()}" + } stopCallServiceInternal(call) } } private fun stopCallServiceInternal(call: Call) { - logger.d { "[stopCallServiceInternal]"} + logger.d { "[stopCallServiceInternal]" } val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient streamVideo?.let { streamVideoClient -> val callConfig = streamVideoClient.callServiceConfigRegistry.get(call.type) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt index e0f69e9ee5..6d22b6631f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt @@ -16,10 +16,10 @@ package io.getstream.video.android.core.notifications.internal.service.observers -import io.getstream.android.video.generated.models.CallAcceptedEvent import io.getstream.android.video.generated.models.CallEndedEvent -import io.getstream.android.video.generated.models.CallRejectedEvent +import io.getstream.android.video.generated.models.LocalCallAcceptedEvent import io.getstream.android.video.generated.models.LocalCallMissedEvent +import io.getstream.android.video.generated.models.LocalCallRejectedEvent import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call import io.getstream.video.android.core.RealtimeConnection @@ -50,7 +50,6 @@ internal class CallEventObserver( private fun observeCallEvents(onServiceStop: () -> Unit, onRemoveIncoming: () -> Unit) { call.scope.launch { call.events.collect { event -> - logger.i { "Received event in service: $event" } handleCallEvent(event, onServiceStop, onRemoveIncoming) } } @@ -65,14 +64,14 @@ internal class CallEventObserver( onRemoveIncoming: () -> Unit, ) { when (event) { - is CallAcceptedEvent -> { + is LocalCallAcceptedEvent -> { handleIncomingCallAcceptedByMeOnAnotherDevice( event.user.id, streamVideo.userId, onServiceStop, ) } - is CallRejectedEvent -> { + is LocalCallRejectedEvent -> { handleIncomingCallRejectedByMeOrCaller( rejectedByUserId = event.user.id, myUserId = streamVideo.userId, @@ -109,7 +108,7 @@ internal class CallEventObserver( // If I accepted the call on another device while this device is still ringing if (acceptedByUserId == myUserId && callRingingState is RingingState.Incoming) { - onServiceStop() + onServiceStop() // noob 1 } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt index 9327d9d0e2..c8f25f0ba1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt @@ -139,7 +139,8 @@ internal class NotificationUpdateObserver( ) { logger.d { "[updateNotification] Showing active call notification" } // val notificationId = callId.hashCode() - val notificationId = callId.getNotificationId(NotificationType.Ongoing) + val notificationId = + call.state.notificationId ?: callId.getNotificationId(NotificationType.Ongoing) startForegroundWithServiceType( notificationId, // todo rahul - correct this notification, From 58afb529e7d361f7747420f3235295707a28bcc1 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 23 Dec 2025 13:29:54 +0530 Subject: [PATCH 13/42] feat: Add reason when leaving call from service Adds a reason when leaving a call from the `CallLifecycleManager`. This provides more context for why the call was ended, distinguishing between different scenarios such as the task being removed for an ongoing or incoming call. --- .../internal/service/managers/CallLifecycleManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt index 0f5076c87c..ba100c48a0 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt @@ -82,7 +82,7 @@ internal class CallLifecycleManager { } else -> { - call.leave() + call.leave("call-service-end-call-unknown") logger.i { "[onTaskRemoved] Ended ongoing call for me" } } } @@ -100,7 +100,7 @@ internal class CallLifecycleManager { logger.i { "[handleIncomingCallTaskRemoved] Ended incoming call for both users" } } } else { - call.leave() + call.leave("call-service-end-call-incoming") logger.i { "[handleIncomingCallTaskRemoved] Ended incoming call for me" } } } From b0702cf94f1497ba3d8884db9e4004e389de5582 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:20:34 +0530 Subject: [PATCH 14/42] refactor: Rename CallService components and add tests Renames several components within the `CallService` infrastructure to improve clarity by prefixing them with "CallService". This includes managers and observers. The events `LocalCallAcceptedEvent` and `LocalCallRejectedEvent` are renamed to `LocalCallAcceptedPostEvent` and `LocalCallRejectedPostEvent` respectively to better reflect their purpose. Additionally, this change introduces a comprehensive suite of unit tests for the `CallService` and its related components, covering: - Lifecycle and notification management - Event, ringing state, and notification update observers - Foreground service permission handling - Debouncer and Throttler utilities --- ...Event.kt => LocalCallAcceptedPostEvent.kt} | 11 +- ...Event.kt => LocalCallRejectedPostEvent.kt} | 11 +- .../getstream/video/android/core/CallState.kt | 8 +- .../internal/service/CallService.kt | 26 +- ...ager.kt => CallServiceLifecycleManager.kt} | 4 +- ...r.kt => CallServiceNotificationManager.kt} | 4 +- ...bserver.kt => CallServiceEventObserver.kt} | 10 +- ... CallServiceNotificationUpdateObserver.kt} | 2 +- ....kt => CallServiceRingingStateObserver.kt} | 2 +- .../StreamDefaultNotificationHandlerTest.kt | 13 +- .../notifications/internal/DebouncerTest.kt | 95 +++++++ .../notifications/internal/ThrottlerTest.kt | 98 +++++++ .../service/ServiceIntentBuilderTest.kt | 2 +- .../CallServiceLifecycleManagerTest.kt | 197 ++++++++++++++ .../CallServiceNotificationManagerTest.kt | 188 ++++++++++++++ .../service/models/ServiceStateTest.kt | 124 +++++++++ .../observers/CallServiceEventObserverTest.kt | 240 ++++++++++++++++++ ...llServiceNotificationUpdateObserverTest.kt | 203 +++++++++++++++ .../CallServiceRingingStateObserverTest.kt | 231 +++++++++++++++++ .../AudioCallPermissionManagerTest.kt | 45 ++++ .../ForegroundServicePermissionManagerTest.kt | 144 +++++++++++ ...ivestreamAudioCallPermissionManagerTest.kt | 42 +++ .../LivestreamCallPermissionManagerTest.kt | 45 ++++ .../LivestreamViewerPermissionManagerTest.kt | 89 +++++++ 24 files changed, 1780 insertions(+), 54 deletions(-) rename stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/{LocalCallAcceptedEvent.kt => LocalCallAcceptedPostEvent.kt} (81%) rename stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/{LocalCallRejectedEvent.kt => LocalCallRejectedPostEvent.kt} (81%) rename stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/{CallLifecycleManager.kt => CallServiceLifecycleManager.kt} (96%) rename stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/{CallNotificationManager.kt => CallServiceNotificationManager.kt} (95%) rename stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/{CallEventObserver.kt => CallServiceEventObserver.kt} (97%) rename stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/{NotificationUpdateObserver.kt => CallServiceNotificationUpdateObserver.kt} (99%) rename stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/{RingingStateObserver.kt => CallServiceRingingStateObserver.kt} (99%) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/DebouncerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/ThrottlerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserverTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserverTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManagerTest.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt similarity index 81% rename from stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedEvent.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt index 208a0b3980..59d0d70c87 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedEvent.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt @@ -23,23 +23,14 @@ package io.getstream.android.video.generated.models -import kotlin.collections.List -import kotlin.collections.Map -import kotlin.collections.* -import kotlin.io.* -import com.squareup.moshi.FromJson import com.squareup.moshi.Json -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonWriter -import com.squareup.moshi.ToJson import org.threeten.bp.OffsetDateTime /** * This event is sent when a user accepts a notification to join a call. */ -data class LocalCallAcceptedEvent ( +data class LocalCallAcceptedPostEvent ( @Json(name = "call_cid") val callCid: String, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt similarity index 81% rename from stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedEvent.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt index c327777a06..4e14d4eebe 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedEvent.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt @@ -23,23 +23,14 @@ package io.getstream.android.video.generated.models -import kotlin.collections.List -import kotlin.collections.Map -import kotlin.collections.* -import kotlin.io.* -import com.squareup.moshi.FromJson import com.squareup.moshi.Json -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonWriter -import com.squareup.moshi.ToJson import org.threeten.bp.OffsetDateTime /** * This event is sent when a user rejects a notification to join a call. */ -data class LocalCallRejectedEvent ( +data class LocalCallRejectedPostEvent ( @Json(name = "call_cid") val callCid: String, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 16e8731f97..569fd397e9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -64,9 +64,9 @@ import io.getstream.android.video.generated.models.GetOrCreateCallResponse import io.getstream.android.video.generated.models.GoLiveResponse import io.getstream.android.video.generated.models.HealthCheckEvent import io.getstream.android.video.generated.models.JoinCallResponse -import io.getstream.android.video.generated.models.LocalCallAcceptedEvent +import io.getstream.android.video.generated.models.LocalCallAcceptedPostEvent import io.getstream.android.video.generated.models.LocalCallMissedEvent -import io.getstream.android.video.generated.models.LocalCallRejectedEvent +import io.getstream.android.video.generated.models.LocalCallRejectedPostEvent import io.getstream.android.video.generated.models.MemberResponse import io.getstream.android.video.generated.models.MuteUsersResponse import io.getstream.android.video.generated.models.OwnCapability @@ -752,7 +752,7 @@ public class CallState( if (!acceptedOnThisDevice) call.leave("accepted-on-another-device") } call.fireEvent( - LocalCallAcceptedEvent( + LocalCallAcceptedPostEvent( event.callCid, event.createdAt, event.call, @@ -782,7 +782,7 @@ public class CallState( }, ) call.fireEvent( - LocalCallRejectedEvent( + LocalCallRejectedPostEvent( event.callCid, event.createdAt, event.call, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 0b99789f6b..b8c55829e7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -34,13 +34,13 @@ import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.handlers.StreamDefaultNotificationHandler import io.getstream.video.android.core.notifications.internal.Debouncer import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.EXTRA_STOP_SERVICE -import io.getstream.video.android.core.notifications.internal.service.managers.CallLifecycleManager -import io.getstream.video.android.core.notifications.internal.service.managers.CallNotificationManager +import io.getstream.video.android.core.notifications.internal.service.managers.CallServiceLifecycleManager +import io.getstream.video.android.core.notifications.internal.service.managers.CallServiceNotificationManager import io.getstream.video.android.core.notifications.internal.service.models.CallIntentParams import io.getstream.video.android.core.notifications.internal.service.models.ServiceState -import io.getstream.video.android.core.notifications.internal.service.observers.CallEventObserver -import io.getstream.video.android.core.notifications.internal.service.observers.NotificationUpdateObserver -import io.getstream.video.android.core.notifications.internal.service.observers.RingingStateObserver +import io.getstream.video.android.core.notifications.internal.service.observers.CallServiceEventObserver +import io.getstream.video.android.core.notifications.internal.service.observers.CallServiceNotificationUpdateObserver +import io.getstream.video.android.core.notifications.internal.service.observers.CallServiceRingingStateObserver import io.getstream.video.android.core.notifications.internal.service.permissions.ForegroundServicePermissionManager import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.core.utils.startForegroundWithServiceType @@ -64,8 +64,8 @@ internal open class CallService : Service() { internal open val logger by taggedLogger("CallService") internal open val permissionManager = ForegroundServicePermissionManager() - internal val callLifecycleManager = CallLifecycleManager() - private val notificationManager = CallNotificationManager() + internal val callServiceLifecycleManager = CallServiceLifecycleManager() + private val notificationManager = CallServiceNotificationManager() internal val serviceState = ServiceState() private var startId: Stack? = null @@ -248,12 +248,12 @@ internal open class CallService : Service() { callId: StreamCallId, trigger: String, ) { - callLifecycleManager.initializeCallAndSocket(serviceScope, streamVideo, callId) { + callServiceLifecycleManager.initializeCallAndSocket(serviceScope, streamVideo, callId) { // stopServiceGracefully() } if (trigger == TRIGGER_INCOMING_CALL) { - callLifecycleManager.updateRingingCall( + callServiceLifecycleManager.updateRingingCall( serviceScope, streamVideo, callId, @@ -436,10 +436,10 @@ internal open class CallService : Service() { private fun observeCall(callId: StreamCallId, streamVideo: StreamVideoClient) { val call = streamVideo.call(callId.type, callId.id) - RingingStateObserver(call, serviceState.soundPlayer, streamVideo, serviceScope) + CallServiceRingingStateObserver(call, serviceState.soundPlayer, streamVideo, serviceScope) .observe { stopServiceGracefully() } - CallEventObserver(call, streamVideo) + CallServiceEventObserver(call, streamVideo) .observe( onServiceStop = { stopServiceGracefully() }, onRemoveIncoming = { @@ -451,7 +451,7 @@ internal open class CallService : Service() { ) if (streamVideo.enableCallNotificationUpdates) { - NotificationUpdateObserver( + CallServiceNotificationUpdateObserver( call, streamVideo, serviceScope, @@ -482,7 +482,7 @@ internal open class CallService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) logger.w { "[onTaskRemoved]" } - callLifecycleManager.endCall(serviceScope, serviceState.currentCallId) + callServiceLifecycleManager.endCall(serviceScope, serviceState.currentCallId) stopServiceGracefully() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManager.kt similarity index 96% rename from stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManager.kt index ba100c48a0..c40dd4010c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManager.kt @@ -25,8 +25,8 @@ import io.getstream.video.android.model.StreamCallId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -internal class CallLifecycleManager { - private val logger by taggedLogger("CallLifecycleManager") +internal class CallServiceLifecycleManager { + private val logger by taggedLogger("CallServiceLifecycleManager") fun initializeCallAndSocket( scope: CoroutineScope, streamVideo: StreamVideo, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt similarity index 95% rename from stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt index 5228731cfc..1e00cca94a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallNotificationManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt @@ -32,8 +32,8 @@ import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId import kotlin.getValue -internal class CallNotificationManager { - private val logger by taggedLogger("CallNotificationManager") +internal class CallServiceNotificationManager { + private val logger by taggedLogger("CallServiceNotificationManager") @SuppressLint("MissingPermission") fun justNotify( diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt similarity index 97% rename from stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt index 6d22b6631f..ff3831c3ac 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallEventObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt @@ -17,9 +17,9 @@ package io.getstream.video.android.core.notifications.internal.service.observers import io.getstream.android.video.generated.models.CallEndedEvent -import io.getstream.android.video.generated.models.LocalCallAcceptedEvent +import io.getstream.android.video.generated.models.LocalCallAcceptedPostEvent import io.getstream.android.video.generated.models.LocalCallMissedEvent -import io.getstream.android.video.generated.models.LocalCallRejectedEvent +import io.getstream.android.video.generated.models.LocalCallRejectedPostEvent import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call import io.getstream.video.android.core.RealtimeConnection @@ -29,7 +29,7 @@ import io.getstream.video.android.core.utils.toUser import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -internal class CallEventObserver( +internal class CallServiceEventObserver( private val call: Call, private val streamVideo: StreamVideoClient, ) { @@ -64,14 +64,14 @@ internal class CallEventObserver( onRemoveIncoming: () -> Unit, ) { when (event) { - is LocalCallAcceptedEvent -> { + is LocalCallAcceptedPostEvent -> { handleIncomingCallAcceptedByMeOnAnotherDevice( event.user.id, streamVideo.userId, onServiceStop, ) } - is LocalCallRejectedEvent -> { + is LocalCallRejectedPostEvent -> { handleIncomingCallRejectedByMeOrCaller( rejectedByUserId = event.user.id, myUserId = streamVideo.userId, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt similarity index 99% rename from stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt index c8f25f0ba1..310ef0ad0c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -internal class NotificationUpdateObserver( +internal class CallServiceNotificationUpdateObserver( private val call: Call, private val streamVideo: StreamVideoClient, private val scope: CoroutineScope, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/RingingStateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserver.kt similarity index 99% rename from stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/RingingStateObserver.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserver.kt index ea02a7f61e..1af96dc856 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/RingingStateObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserver.kt @@ -28,7 +28,7 @@ import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -internal class RingingStateObserver( +internal class CallServiceRingingStateObserver( private val call: Call, private val soundPlayer: CallSoundAndVibrationPlayer?, private val streamVideo: StreamVideoClient, diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt index ae67e1c950..cdea058dfe 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt @@ -30,6 +30,7 @@ import io.getstream.video.android.core.ClientState import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.StreamIntentResolver import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig @@ -278,10 +279,11 @@ class StreamDefaultNotificationHandlerTest { testHandler.onMissedCall(testCallId, callDisplayName, payload) // Then - Verify intent resolver call with correct notification ID + val notificationId = testCallId.getNotificationId(NotificationType.Missed) verify { mockIntentResolver.searchMissedCallPendingIntent( testCallId, - testCallId.hashCode(), + notificationId, payload, ) } @@ -296,10 +298,10 @@ class StreamDefaultNotificationHandlerTest { } // Verify notification manager is called to show notification - verify { mockNotificationManager.notify(testCallId.hashCode(), any()) } + verify { mockNotificationManager.notify(notificationId, any()) } } - @Test + @Test // failed fun `onMissedCall falls back to default intent when specific intent not found`() { // Given val callDisplayName = "Bob Wilson" @@ -331,12 +333,13 @@ class StreamDefaultNotificationHandlerTest { // When testHandler.onMissedCall(testCallId, callDisplayName, payload) + val notificationId = testCallId.getNotificationId(NotificationType.Missed) // Then - Verify fallback to default intent verify { mockIntentResolver.searchMissedCallPendingIntent( testCallId, - testCallId.hashCode(), + notificationId, payload, ) } @@ -352,7 +355,7 @@ class StreamDefaultNotificationHandlerTest { } // Verify notification manager is called - verify { mockNotificationManager.notify(testCallId.hashCode(), any()) } + verify { mockNotificationManager.notify(notificationId, any()) } } @Test diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/DebouncerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/DebouncerTest.kt new file mode 100644 index 0000000000..aaad1ca7f5 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/DebouncerTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal + +import android.os.Build +import android.os.Looper +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class DebouncerTest { + + private lateinit var debouncer: Debouncer + private val mainLooper = Looper.getMainLooper() + + @Before + fun setup() { + debouncer = Debouncer() + } + + @Test + fun `action executes after delay`() { + var executed = false + + debouncer.submit(1_000) { + executed = true + } + + // Advance time less than delay + Shadows.shadowOf(mainLooper).idleFor(999, TimeUnit.MILLISECONDS) + assertFalse(executed) + + // Advance to full delay + Shadows.shadowOf(mainLooper).idleFor(1, TimeUnit.MILLISECONDS) + assertTrue(executed) + } + + @Test + fun `previous action is cancelled when new submit happens`() { + var firstExecuted = false + var secondExecuted = false + + debouncer.submit(1_000) { + firstExecuted = true + } + + // Submit again before delay expires + debouncer.submit(1_000) { + secondExecuted = true + } + + Shadows.shadowOf(mainLooper).idleFor(1_000, TimeUnit.MILLISECONDS) + + assertFalse(firstExecuted) + assertTrue(secondExecuted) + } + + @Test + fun `only last submitted action runs`() { + var executedCount = 0 + + repeat(5) { + debouncer.submit(1_000) { + executedCount++ + } + } + + Shadows.shadowOf(mainLooper).idleFor(1_000, TimeUnit.MILLISECONDS) + + assertEquals(1, executedCount) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/ThrottlerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/ThrottlerTest.kt new file mode 100644 index 0000000000..fd36d64b30 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/ThrottlerTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal + +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ThrottlerTest { + + @Before + fun setup() { + Throttler.resetAll() + } + + @Test + fun `first throttle call executes immediately`() { + var executed = false + + Throttler.throttleFirst("key", 1_000) { + executed = true + } + + assertTrue(executed) + } + + @Test + fun `second call within cooldown does not execute`() { + var count = 0 + + Throttler.throttleFirst("key", 10_000) { + count++ + } + + Throttler.throttleFirst("key", 10_000) { + count++ + } + + assertEquals(1, count) + } + + @Test + fun `reset allows execution again`() { + var count = 0 + + Throttler.throttleFirst("key", 10_000) { + count++ + } + + Throttler.reset("key") + + Throttler.throttleFirst("key", 10_000) { + count++ + } + + assertEquals(2, count) + } + + @Test + fun `resetAll clears all cooldowns`() { + var count = 0 + + Throttler.throttleFirst("key1", 10_000) { count++ } + Throttler.throttleFirst("key2", 10_000) { count++ } + + Throttler.resetAll() + + Throttler.throttleFirst("key1", 10_000) { count++ } + Throttler.throttleFirst("key2", 10_000) { count++ } + + assertEquals(4, count) + } + + @Test + fun `throttleFirst without key throttles same call site`() { + var count = 0 + + Throttler.throttleFirst(10_000) { count++ } + Throttler.throttleFirst(10_000) { count++ } + + assertEquals(1, count) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt index 01633a882e..7a50946b5b 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt @@ -142,7 +142,7 @@ class ServiceIntentBuilderTest { // Then assertNotNull(intent) - assertEquals(CallService::class.java.name, intent.component?.className) + assertEquals(CallService::class.java.name, intent?.component?.className) } // diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt new file mode 100644 index 0000000000..e513dadb5b --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.managers + +import io.getstream.android.video.generated.models.GetCallResponse +import io.getstream.result.Error +import io.getstream.result.Result +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.MemberState +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.model.StreamCallId +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import kotlin.test.Test + +class CallServiceLifecycleManagerTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var sut: CallServiceLifecycleManager + + private val streamVideo: StreamVideo = mockk(relaxed = true) + private val call: Call = mockk(relaxed = true) + private val callState: CallState = mockk(relaxed = true) + private val ringingStateFlow = MutableStateFlow(RingingState.Idle) + private val membersFlow = MutableStateFlow>(emptyList()) + + private val callId = StreamCallId("default", "call-123") + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + sut = CallServiceLifecycleManager() + + every { streamVideo.call(any(), any()) } returns call + every { call.state } returns callState + every { callState.ringingState } returns ringingStateFlow + every { callState.members } returns membersFlow + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initializeCallAndSocket calls onError when call get fails`() = testScope.runTest { + val onError = mockk<() -> Unit>(relaxed = true) + + coEvery { call.get() } returns Result.Failure(Error.GenericError("boom")) + + sut.initializeCallAndSocket( + scope = this, + streamVideo = streamVideo, + callId = callId, + onError = onError, + ) + + advanceUntilIdle() + + verify { onError.invoke() } + coVerify { streamVideo.connectIfNotAlreadyConnected() } + } + + @Test + fun `initializeCallAndSocket does not call onError on success`() = testScope.runTest { + val onError = mockk<() -> Unit>(relaxed = true) + val getCallResponseSuccess = mockk>(relaxed = true) + coEvery { call.get() } returns getCallResponseSuccess + + sut.initializeCallAndSocket(this, streamVideo, callId, onError) + + advanceUntilIdle() + + verify(exactly = 0) { onError.invoke() } + coVerify { streamVideo.connectIfNotAlreadyConnected() } + } + + @Test + fun `updateRingingCall adds ringing call to state`() = testScope.runTest { + val ringingState = RingingState.Incoming() + + sut.updateRingingCall( + scope = this, + streamVideo = streamVideo, + callId = callId, + ringingState = ringingState, + ) + + advanceUntilIdle() + + verify { + streamVideo.state.addRingingCall(call, ringingState) + } + } + + @Test + fun `endCall rejects outgoing call`() = testScope.runTest { + ringingStateFlow.value = RingingState.Outgoing() + + mockkObject(StreamVideo.Companion) + every { StreamVideo.instanceOrNull() } returns streamVideo + + sut.endCall(this, callId) + + advanceUntilIdle() + + coVerify { + call.reject(any(), any()) + } + } + + @Test + fun `endCall rejects incoming call when member count is 2`() = testScope.runTest { + ringingStateFlow.value = RingingState.Incoming() + membersFlow.value = listOf(mockk(), mockk()) + + mockkObject(StreamVideo.Companion) + every { StreamVideo.instanceOrNull() } returns streamVideo + + sut.endCall(this, callId) + + advanceUntilIdle() + + coVerify { call.reject(source = "memberCount == 2") } + } + + @Test + fun `endCall leaves incoming call when member count is not 2`() = testScope.runTest { + ringingStateFlow.value = RingingState.Incoming() + membersFlow.value = listOf(mockk()) + + mockkObject(StreamVideo.Companion) + every { StreamVideo.instanceOrNull() } returns streamVideo + + sut.endCall(this, callId) + + advanceUntilIdle() + + verify { call.leave("call-service-end-call-incoming") } + } + + @Test + fun `endCall leaves call for unknown ringing state`() = testScope.runTest { + ringingStateFlow.value = RingingState.Active + + mockkObject(StreamVideo.Companion) + every { StreamVideo.instanceOrNull() } returns streamVideo + + sut.endCall(this, callId) + + advanceUntilIdle() + + verify { call.leave("call-service-end-call-unknown") } + } + + @Test + fun `endCall does nothing when callId is null`() = testScope.runTest { + sut.endCall(this, null) + + advanceUntilIdle() + + verify { streamVideo wasNot Called } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt new file mode 100644 index 0000000000..bba3e3d594 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.managers + +import android.Manifest +import android.app.Notification +import android.app.Service +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationConfig +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher +import io.getstream.video.android.core.notifications.handlers.CompatibilityStreamNotificationHandler +import io.getstream.video.android.core.notifications.internal.StreamNotificationManager +import io.getstream.video.android.model.StreamCallId +import io.mockk.Called +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test + +@RunWith(RobolectricTestRunner::class) +class CallServiceNotificationManagerTest { + private lateinit var sut: CallServiceNotificationManager + private lateinit var context: Context + + private val service: Service = mockk(relaxed = true) + private val notification: Notification = mockk() + private val callId = StreamCallId("default", "call-123") + +// private val dispatcher: StreamNotificationDispatcher = mockk(relaxed = true) + private val notificationDispatcher: NotificationDispatcher = mockk(relaxed = true) + private val notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true) + + @Before + fun setup() { + context = mockk(relaxed = true) + sut = CallServiceNotificationManager() + + mockkStatic(ActivityCompat::class) + mockkStatic(ContextCompat::class) + mockkStatic(NotificationManagerCompat::class) + mockkObject(StreamVideo.Companion) + + every { NotificationManagerCompat.from(service) } returns notificationManagerCompat + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `justNotify dispatches notification when permission is granted`() { + every { + ContextCompat.checkSelfPermission(any(), Manifest.permission.POST_NOTIFICATIONS) + } returns PackageManager.PERMISSION_GRANTED + + val streamVideo = mockk(relaxed = true) + every { StreamVideo.instanceOrNull() } returns streamVideo + every { streamVideo.getStreamNotificationDispatcher() } returns notificationDispatcher + + sut.justNotify( + service = service, + callId = callId, + notificationId = 1001, + notification = notification, + ) + + verify { + notificationDispatcher.notify(callId, 1001, notification) + } + } + + @Test + fun `justNotify does nothing when permission is denied`() { + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + } returns PackageManager.PERMISSION_DENIED + + val streamVideo = mockk(relaxed = true) + every { StreamVideo.instanceOrNull() } returns streamVideo + every { streamVideo.getStreamNotificationDispatcher() } returns notificationDispatcher + + sut.justNotify( + service = service, + callId = callId, + notificationId = 1001, + notification = notification, + ) + + verify { notificationDispatcher wasNot Called } + } + + @Test + fun `justNotify is safe when StreamVideo instance is null`() { + every { + ActivityCompat.checkSelfPermission(any(), any()) + } returns PackageManager.PERMISSION_GRANTED + + every { StreamVideo.instanceOrNull() } returns null + + sut.justNotify(service, callId, 1001, notification) + + // Should not crash + } + + @Test + fun `cancelNotifications cancels call notifications`() { + sut.cancelNotifications(service, callId) + + verify { + notificationManagerCompat.cancel(callId.hashCode()) + notificationManagerCompat.cancel( + callId.getNotificationId(NotificationType.Incoming), + ) + } + } + + @Test + fun `cancelNotifications clears media session`() { + val handler = mockk(relaxed = true) + + val notificationConfig = mockk { + every { notificationHandler } returns handler + } + + val streamNotificationManager = mockk { + every { this@mockk.notificationConfig } returns notificationConfig + } + + val streamVideoClient = mockk { + every { this@mockk.streamNotificationManager } returns streamNotificationManager + } + + every { StreamVideo.instanceOrNull() } returns streamVideoClient + + sut.cancelNotifications(service, callId) + + verify { + handler.clearMediaSession(callId) + } + } + + @Test + fun `cancelNotifications is safe when callId is null`() { + sut.cancelNotifications(service, null) + + verify { notificationManagerCompat wasNot Called } + } + + @Test + fun `cancelNotifications is safe when StreamVideo is null`() { + every { StreamVideo.instanceOrNull() } returns null + + sut.cancelNotifications(service, callId) + + verify { + notificationManagerCompat.cancel(any()) + } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt new file mode 100644 index 0000000000..c115538916 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.models + +import android.app.Service +import android.content.IntentFilter +import io.getstream.video.android.core.notifications.internal.receivers.ToggleCameraBroadcastReceiver +import io.mockk.Called +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import kotlin.test.Test + +class ServiceStateTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val service: Service = mockk(relaxed = true) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() { + val sut = ServiceState() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + sut.unregisterToggleCameraBroadcastReceiver(service) + + verify { + service.unregisterReceiver(any()) + } + } + + @Test + fun `registerToggleCameraBroadcastReceiver registers receiver`() { + val sut = ServiceState() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + + verify(exactly = 1) { + service.registerReceiver( + any(), + any(), + ) + } + } + + @Test + fun `registerToggleCameraBroadcastReceiver does not re-register if already registered`() { + val sut = ServiceState() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + sut.registerToggleCameraBroadcastReceiver(service, testScope) + + verify(exactly = 1) { + service.registerReceiver(any(), any()) + } + } + + @Test + fun `registerToggleCameraBroadcastReceiver swallows exception`() { + val sut = ServiceState() + every { + service.registerReceiver(any(), any()) + } throws RuntimeException("boom") + + sut.registerToggleCameraBroadcastReceiver(service, testScope) + + // Should not crash + } + + @Test + fun `unregisterToggleCameraBroadcastReceiver does nothing if not registered`() { + val sut = ServiceState() + sut.unregisterToggleCameraBroadcastReceiver(service) + + verify { + service wasNot Called + } + } + + @Test + fun `unregisterToggleCameraBroadcastReceiver swallows exception`() { + val sut = ServiceState() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + + every { + service.unregisterReceiver(any()) + } throws IllegalArgumentException("not registered") + + sut.unregisterToggleCameraBroadcastReceiver(service) + + // Should not crash + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt new file mode 100644 index 0000000000..ee5757b24c --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.observers + +import io.getstream.android.video.generated.models.CallEndedEvent +import io.getstream.android.video.generated.models.LocalCallAcceptedPostEvent +import io.getstream.android.video.generated.models.LocalCallMissedEvent +import io.getstream.android.video.generated.models.LocalCallRejectedPostEvent +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.ClientState +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideoClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertTrue + +class CallServiceEventObserverTest { + + private val dispatcher = StandardTestDispatcher() + private val testScope = TestScope(dispatcher) + + private lateinit var call: Call + private lateinit var streamVideo: StreamVideoClient + private val streamState = mockk(relaxed = true) + private lateinit var observer: CallServiceEventObserver + + private val eventsFlow = MutableSharedFlow(replay = 1) + private val connectionFlow = + MutableStateFlow(RealtimeConnection.Connected) + + private val ringingStateFlow = + MutableStateFlow(RingingState.Idle) + + private val activeCallFlow = MutableStateFlow(null) + private val ringingCallFlow = MutableStateFlow(null) + + private lateinit var onServiceStop: () -> Unit + var onServiceStopInvoked = false + + private lateinit var onRemoveIncoming: () -> Unit + var onRemoveIncomingInvoked = false + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + + call = mockk(relaxed = true) { + every { scope } returns testScope + every { events } returns eventsFlow + every { id } returns "call-1" + } + + val callState = mockk(relaxed = true) { + every { ringingState } returns ringingStateFlow + every { connection } returns connectionFlow + } + + every { call.state } returns callState + + with(streamState) { + every { activeCall } returns activeCallFlow + every { ringingCall } returns ringingCallFlow + } + + streamVideo = mockk(relaxed = true) { + every { userId } returns "me" + every { state } returns streamState + } + + onServiceStop = { + onServiceStopInvoked = true + } + onRemoveIncoming = { + onRemoveIncomingInvoked = true + } + + observer = CallServiceEventObserver(call, streamVideo) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `accepted by me on another device while ringing stops service`() = runTest { + ringingStateFlow.value = RingingState.Incoming() + + observer.observe(onServiceStop, onRemoveIncoming) + + eventsFlow.emit( + LocalCallAcceptedPostEvent( + "", + mockk(), + mockk(relaxed = true), + mockk(relaxed = true) { + every { id } returns "me" + }, + "", + ), + ) + + advanceUntilIdle() + assertTrue(onServiceStopInvoked) + } + + @Test + fun `rejected by me with no active call stops service`() = runTest { + activeCallFlow.value = null + + observer.observe(onServiceStop, onRemoveIncoming) + + eventsFlow.emit( + LocalCallRejectedPostEvent( + "", + mockk(relaxed = true), + mockk(relaxed = true) { + every { createdBy } returns mockk(relaxed = true) + }, + mockk(relaxed = true) { + every { id } returns "me" + }, + "", + "", + ), + ) + + advanceUntilIdle() + assertTrue(onServiceStopInvoked) + } + + @Test + fun `rejected by caller with active call removes incoming`() = runTest { + activeCallFlow.value = mockk() + + observer.observe(onServiceStop, onRemoveIncoming) + + eventsFlow.emit( + LocalCallRejectedPostEvent( + "", + mockk(relaxed = true), + mockk(relaxed = true) { + every { createdBy } returns mockk(relaxed = true) + }, + mockk(relaxed = true), + "", + "", + ), + ) + + advanceUntilIdle() + assertFalse(onServiceStopInvoked) + assertTrue(onRemoveIncomingInvoked) + } + + @Test // next + fun `call ended stops service`() = runTest { + observer.observe(onServiceStop, onRemoveIncoming) + + eventsFlow.emit(CallEndedEvent("", mockk(), mockk(), "")) + + advanceUntilIdle() + assertTrue(onServiceStopInvoked) + } + + @Test + fun `missed call with active call removes incoming`() = runTest { + activeCallFlow.value = mockk() + + observer.observe(onServiceStop, onRemoveIncoming) + + eventsFlow.emit(LocalCallMissedEvent("", "")) + + advanceUntilIdle() + assertTrue(onRemoveIncomingInvoked) + assertFalse(onServiceStopInvoked) + } + + @Test + fun `missed call with no active call stops service`() = runTest { + activeCallFlow.value = null + + observer.observe(onServiceStop, onRemoveIncoming) + + eventsFlow.emit(LocalCallMissedEvent("", "")) + + advanceUntilIdle() + + assertTrue(onServiceStopInvoked) + } + + @Test + fun `connection failure for ringing call cleans up`() = runTest { + ringingCallFlow.value = call + + observer.observe(onServiceStop, onRemoveIncoming) + + connectionFlow.value = RealtimeConnection.Failed(Throwable("network")) + + advanceUntilIdle() + + verify { + streamState.removeRingingCall(call) + streamVideo.onCallCleanUp(call) + } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserverTest.kt new file mode 100644 index 0000000000..478479e0d4 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserverTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.observers + +import android.app.Notification +import android.content.Context +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.ClientState +import io.getstream.video.android.core.MemberState +import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.NotificationType +import io.getstream.video.android.core.notifications.internal.service.CallService +import io.getstream.video.android.core.notifications.internal.service.permissions.ForegroundServicePermissionManager +import io.getstream.video.android.model.StreamCallId +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class CallServiceNotificationUpdateObserverTest { + + private val dispatcher = StandardTestDispatcher() + private val testScope = TestScope(dispatcher) + + private lateinit var call: Call + private lateinit var callState: CallState + private lateinit var streamVideo: StreamVideoClient + private lateinit var streamState: ClientState + private lateinit var permissionManager: ForegroundServicePermissionManager + private lateinit var observer: CallServiceNotificationUpdateObserver + + private val context: Context = mockk(relaxed = true) + private val notification: Notification = mockk() + + // StateFlows + private val ringingStateFlow = MutableStateFlow(RingingState.Idle) + private val membersFlow = MutableStateFlow(emptyList()) + private val remoteParticipantsFlow = MutableStateFlow(emptyList()) + private val backstageFlow = MutableStateFlow(false) + + // Captured callback + private var startArgs: Quadruple? = null + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + + callState = mockk { + every { ringingState } returns ringingStateFlow + every { members } returns membersFlow + every { remoteParticipants } returns remoteParticipantsFlow + every { backstage } returns backstageFlow + every { notificationId } returns null + } + + call = mockk { + every { id } returns "call-1" + every { type } returns "default" + every { cid } returns "default:call-1" + every { state } returns callState + } + + streamState = mockk(relaxed = true) + + streamVideo = mockk { + every { state } returns streamState + coEvery { onCallNotificationUpdate(call) } returns notification + every { streamNotificationManager } returns mockk { + every { notificationConfig } returns mockk { + every { notificationUpdateTriggers(call) } returns null + } + } + } + + permissionManager = mockk { + every { getServiceType(any(), any()) } returns 42 + } + + observer = CallServiceNotificationUpdateObserver( + call = call, + streamVideo = streamVideo, + scope = testScope.backgroundScope, + permissionManager = permissionManager, + onStartService = { id, notif, trigger, type -> + startArgs = Quadruple(id, notif, trigger, type) + }, + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + data class Quadruple( + val first: A, + val second: B, + val third: C, + val fourth: D, + ) + + @Test + fun `incoming ringing state starts incoming foreground notification`() = runTest { + observer.observe(context) +// advanceUntilIdle() + + ringingStateFlow.value = RingingState.Incoming() + advanceUntilIdle() + advanceTimeBy(100L) + + val args = startArgs!! + assertEquals( + StreamCallId("default", "call-1") + .getNotificationId(NotificationType.Incoming), + args.first, + ) + assertEquals(notification, args.second) + assertEquals(CallService.TRIGGER_INCOMING_CALL, args.third) + assertEquals(42, args.fourth) + } + + @Test + fun `outgoing ringing state starts outgoing foreground notification`() = runTest { + observer.observe(context) + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Outgoing() + advanceUntilIdle() + advanceTimeBy(100L) + + val args = startArgs!! + assertEquals( + StreamCallId("default", "call-1") + .getNotificationId(NotificationType.Outgoing), + args.first, + ) + assertEquals(CallService.TRIGGER_OUTGOING_CALL, args.third) + } + + @Test + fun `active ringing state starts ongoing foreground notification`() = runTest { + observer.observe(context) + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Active + advanceUntilIdle() + advanceTimeBy(100L) + + val args = startArgs!! + assertEquals( + StreamCallId("default", "call-1") + .getNotificationId(NotificationType.Ongoing), + args.first, + ) + assertEquals(CallService.TRIGGER_ONGOING_CALL, args.third) + } + + @Test + fun `no notification generated does not start foreground service`() = runTest { + coEvery { streamVideo.onCallNotificationUpdate(call) } returns null + + observer.observe(context) + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Incoming() + advanceUntilIdle() + advanceTimeBy(100L) + + assertNull(startArgs) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserverTest.kt new file mode 100644 index 0000000000..fbf03dfe53 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserverTest.kt @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.observers + +import android.content.Context +import android.media.AudioManager +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer +import io.getstream.video.android.core.sounds.MutedRingingConfig +import io.getstream.video.android.core.sounds.RingingCallVibrationConfig +import io.getstream.video.android.core.sounds.RingingConfig +import io.getstream.video.android.core.sounds.Sounds +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CallServiceRingingStateObserverTest { + + private val dispatcher = StandardTestDispatcher() + private val testScope = TestScope(dispatcher) + + private lateinit var call: Call + private lateinit var callState: CallState + private lateinit var soundPlayer: CallSoundAndVibrationPlayer + private lateinit var streamVideo: StreamVideoClient + private lateinit var observer: CallServiceRingingStateObserver + + private val ringingStateFlow = + MutableStateFlow(RingingState.Idle) + + private val onStopServiceInvoked = mutableListOf() + + private val testContext: Context = mockk() + private val audioManager: AudioManager = mockk() + + @Before + fun setup() { + Dispatchers.setMain(dispatcher) + + callState = mockk { + every { this@mockk.ringingState } returns ringingStateFlow + } + + call = mockk { + every { this@mockk.scope } returns testScope + every { this@mockk.state } returns callState + coEvery { this@mockk.reject(any(), any()) } returns mockk(relaxed = true) + } + + soundPlayer = mockk(relaxed = true) + + every { testContext.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + every { audioManager.ringerMode } returns AudioManager.RINGER_MODE_NORMAL + + val vibrationConfig = mockk { + every { this@mockk.enabled } returns true + every { this@mockk.vibratePattern } returns longArrayOf(0, 100) + } + + val ringingConfig = mockk { + every { this@mockk.incomingCallSoundUri } returns mockk() + every { this@mockk.outgoingCallSoundUri } returns mockk() + } + + val mutedConfig = mockk { + every { this@mockk.playIncomingSoundIfMuted } returns true + every { this@mockk.playOutgoingSoundIfMuted } returns true + } + + val sounds = mockk { + every { this@mockk.ringingConfig } returns ringingConfig + every { mutedRingingConfig } returns mutedConfig + } + + streamVideo = mockk { + every { this@mockk.context } returns testContext + every { this@mockk.vibrationConfig } returns vibrationConfig + every { this@mockk.sounds } returns sounds + } + + observer = CallServiceRingingStateObserver( + call = call, + soundPlayer = soundPlayer, + streamVideo = streamVideo, + scope = testScope.backgroundScope, + ) + } + + @Test + fun `incoming not accepted plays sound and vibrates`() = runTest { + observer.observe { onStopServiceInvoked.add(Unit) } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Incoming(acceptedByMe = false) + advanceUntilIdle() + advanceTimeBy(100L) + verify { + soundPlayer.vibrate(any()) + soundPlayer.playCallSound(any(), true) + } + } + + @Test + fun `incoming accepted stops sound`() = runTest { + observer.observe { } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Incoming(acceptedByMe = true) + advanceUntilIdle() + advanceTimeBy(100L) + verify { soundPlayer.stopCallSound() } + } + + @Test + fun `outgoing not accepted plays outgoing sound`() = runTest { + observer.observe { } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Outgoing(acceptedByCallee = false) + advanceUntilIdle() + + verify { + soundPlayer.playCallSound(any(), true) + } + } + + @Test + fun `outgoing accepted stops sound`() = runTest { + observer.observe { } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Outgoing(acceptedByCallee = true) + advanceUntilIdle() + + verify { soundPlayer.stopCallSound() } + } + + @Test + fun `active call stops sound`() = runTest { + observer.observe { } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Active + advanceUntilIdle() + + verify { soundPlayer.stopCallSound() } + } + + @Test + fun `rejected by all rejects call and stops service`() = runTest { + observer.observe { onStopServiceInvoked.add(Unit) } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.RejectedByAll + advanceUntilIdle() + + coVerify { + call.reject( + source = "RingingState.RejectedByAll", + reason = RejectReason.Decline, + ) + } + + verify { soundPlayer.stopCallSound() } + assertEquals(1, onStopServiceInvoked.size) + } + + @Test + fun `timeout no answer stops sound`() = runTest { + observer.observe { } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.TimeoutNoAnswer + advanceUntilIdle() + advanceTimeBy(100L) + verify { soundPlayer.stopCallSound() } + } + + @Test + fun `incoming does not vibrate when vibration disabled`() = runTest { + every { streamVideo.vibrationConfig.enabled } returns false + + observer.observe { } + advanceUntilIdle() + + ringingStateFlow.value = RingingState.Incoming(acceptedByMe = false) + advanceUntilIdle() + advanceTimeBy(100L) + verify(exactly = 0) { soundPlayer.vibrate(any()) } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManagerTest.kt new file mode 100644 index 0000000000..780f2d1d68 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/AudioCallPermissionManagerTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.content.pm.ServiceInfo +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +class AudioCallPermissionManagerTest { + + private lateinit var manager: AudioCallPermissionManager + + @Before + fun setup() { + manager = AudioCallPermissionManager() + } + + @Test + fun `requiredForegroundTypes contains phone call and microphone`() { + val types = manager.requiredForegroundTypes + + assertEquals( + setOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ), + types, + ) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManagerTest.kt new file mode 100644 index 0000000000..d6a677eab8 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/ForegroundServicePermissionManagerTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.Manifest +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import io.getstream.video.android.core.notifications.internal.service.CallService +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplication +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class ForegroundServicePermissionManagerTest { + + private lateinit var context: Context + private lateinit var manager: ForegroundServicePermissionManager + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + manager = ForegroundServicePermissionManager() + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) + fun `ongoing call with permissions returns combined service type`() { + ShadowApplication.getInstance().grantPermissions( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ) + + val type = manager.getServiceType( + context, + CallService.TRIGGER_ONGOING_CALL, + ) + + val expected = + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or + ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + + assertEquals(expected, type) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) + fun `ongoing call without permissions falls back to no-permission service type`() { + // No permissions granted + + val type = manager.getServiceType( + context, + CallService.TRIGGER_ONGOING_CALL, + ) + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + type, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.Q]) + fun `android Q uses phone call service type`() { + val type = manager.allPermissionsServiceType() + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + type, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.P]) + fun `pre Q returns zero service type`() { + val type = manager.allPermissionsServiceType() + assertEquals(0, type) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + fun `no permission uses short service on android 14`() { + val type = manager.noPermissionServiceType() + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE, + type, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `no permission uses phone call service below android 14`() { + val type = manager.noPermissionServiceType() + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + type, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) + fun `hasAllPermissions returns true when all permissions granted`() { + ShadowApplication.getInstance().grantPermissions( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + ) + + assertTrue(manager.hasAllPermissions(context)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) + fun `hasAllPermissions returns false when permission missing`() { + ShadowApplication.getInstance().grantPermissions( + Manifest.permission.CAMERA, + ) + + assertFalse(manager.hasAllPermissions(context)) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManagerTest.kt new file mode 100644 index 0000000000..6289b8eb95 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamAudioCallPermissionManagerTest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.content.pm.ServiceInfo +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +class LivestreamAudioCallPermissionManagerTest { + + private lateinit var manager: LivestreamAudioCallPermissionManager + + @Before + fun setup() { + manager = LivestreamAudioCallPermissionManager() + } + + @Test + fun `requiredForegroundTypes contains only microphone`() { + val types = manager.requiredForegroundTypes + + assertEquals( + setOf(ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE), + types, + ) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManagerTest.kt new file mode 100644 index 0000000000..367f998ed7 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamCallPermissionManagerTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.content.pm.ServiceInfo +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +class LivestreamCallPermissionManagerTest { + + private lateinit var manager: LivestreamCallPermissionManager + + @Before + fun setup() { + manager = LivestreamCallPermissionManager() + } + + @Test + fun `requiredForegroundTypes contains camera and microphone only`() { + val types = manager.requiredForegroundTypes + + assertEquals( + setOf( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ), + types, + ) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManagerTest.kt new file mode 100644 index 0000000000..e4cd50bad4 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/permissions/LivestreamViewerPermissionManagerTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service.permissions + +import android.content.pm.ServiceInfo +import android.os.Build +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class LivestreamViewerPermissionManagerTest { + + private lateinit var manager: LivestreamViewerPermissionManager + + @Before + fun setup() { + manager = LivestreamViewerPermissionManager() + } + + @Test + fun `requiredForegroundTypes contains only media playback`() { + assertEquals( + setOf(ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), + manager.requiredForegroundTypes, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.Q]) + fun `androidQServiceType returns media playback on Q`() { + val type = manager.androidQServiceType() + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + type, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.Q]) + fun `noPermissionServiceType returns media playback on Q`() { + val type = manager.noPermissionServiceType() + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + type, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) + fun `noPermissionServiceType returns media playback above Q`() { + val type = manager.noPermissionServiceType() + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + type, + ) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.P]) + fun `noPermissionServiceType returns phone call below Q`() { + val type = manager.noPermissionServiceType() + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + type, + ) + } +} From 3527dd261acc2628c613c3a6167d55e61e659842 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:20:48 +0530 Subject: [PATCH 15/42] refactor: Rename local call events Renames `LocalCallAcceptedEvent` and `LocalCallRejectedEvent` to `LocalCallAcceptedPostEvent` and `LocalCallRejectedPostEvent` respectively, to better reflect that they are events sent *after* the action has occurred. --- .../api/stream-video-android-core.api | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 1b9e832643..769a0d6ffa 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -3557,15 +3557,15 @@ public final class io/getstream/android/video/generated/models/ListTranscription public fun toString ()Ljava/lang/String; } -public final class io/getstream/android/video/generated/models/LocalCallAcceptedEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { +public final class io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { public fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lorg/threeten/bp/OffsetDateTime; public final fun component3 ()Lio/getstream/android/video/generated/models/CallResponse; public final fun component4 ()Lio/getstream/android/video/generated/models/UserResponse; public final fun component5 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallAcceptedEvent; - public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallAcceptedEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallAcceptedEvent; + public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallAcceptedPostEvent; + public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallAcceptedPostEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallAcceptedPostEvent; public fun equals (Ljava/lang/Object;)Z public final fun getCall ()Lio/getstream/android/video/generated/models/CallResponse; public fun getCallCID ()Ljava/lang/String; @@ -3593,7 +3593,7 @@ public final class io/getstream/android/video/generated/models/LocalCallMissedEv public fun toString ()Ljava/lang/String; } -public final class io/getstream/android/video/generated/models/LocalCallRejectedEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { +public final class io/getstream/android/video/generated/models/LocalCallRejectedPostEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { public fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; @@ -3602,8 +3602,8 @@ public final class io/getstream/android/video/generated/models/LocalCallRejected public final fun component4 ()Lio/getstream/android/video/generated/models/UserResponse; public final fun component5 ()Ljava/lang/String; public final fun component6 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallRejectedEvent; - public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallRejectedEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallRejectedEvent; + public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallRejectedPostEvent; + public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallRejectedPostEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallRejectedPostEvent; public fun equals (Ljava/lang/Object;)Z public final fun getCall ()Lio/getstream/android/video/generated/models/CallResponse; public fun getCallCID ()Ljava/lang/String; From 5a149404e351e8c5c566aea47a03b79be832510c Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:27:05 +0530 Subject: [PATCH 16/42] feat: Add notificationId to updateNotification Adds `notificationId` as a parameter to the `updateNotification` function. This allows for updating the notification with a specific ID. The previous `updateNotification` function without the ID has been deprecated. --- .../main/kotlin/io/getstream/video/android/core/CallState.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 569fd397e9..edfe6ba51a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -1673,6 +1673,11 @@ public class CallState( _rejectActionBundle.value = bundle } + @Deprecated("Use updateNotification(Int, Notification) instead") + fun updateNotification(notification: Notification) { + atomicNotification.set(notification) + } + fun updateNotification(notificationId: Int, notification: Notification) { this.notificationId = notificationId this.atomicNotification.set(notification) From 776b4b510d9568c88afca5e0beef0daaefef0145 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:29:24 +0530 Subject: [PATCH 17/42] feat: Add `updateNotification` without notification ID Adds an overloaded version of the `updateNotification` method that doesn't require a notification ID, simplifying its usage. --- stream-video-android-core/api/stream-video-android-core.api | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 769a0d6ffa..966520e6d2 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7826,6 +7826,7 @@ public final class io/getstream/video/android/core/CallState { public final fun updateFromResponse (Lio/getstream/android/video/generated/models/StopLiveResponse;)V public final fun updateFromResponse (Lio/getstream/android/video/generated/models/UpdateCallResponse;)V public final fun updateNotification (ILandroid/app/Notification;)V + public final fun updateNotification (Landroid/app/Notification;)V public final fun updateParticipant (Lio/getstream/video/android/core/ParticipantState;)V public final fun updateParticipantSortingOrder (Ljava/util/Comparator;)V public final fun updateParticipantVisibility (Ljava/lang/String;Lio/getstream/video/android/core/model/VisibilityOnScreenState;)V From 697b82a67602e76bee6e2c75f32eb5fe278aaff3 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:34:01 +0530 Subject: [PATCH 18/42] feat: Make `transitionToAcceptCall` internal The `transitionToAcceptCall` function in `ClientState` is now marked as `internal`, restricting its usage to within the same module. This change also updates the public API definition. --- stream-video-android-core/api/stream-video-android-core.api | 1 - .../main/kotlin/io/getstream/video/android/core/ClientState.kt | 2 +- .../handlers/StreamDefaultNotificationHandlerTest.kt | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 966520e6d2..cc2183fb2e 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7933,7 +7933,6 @@ public final class io/getstream/video/android/core/ClientState { public final fun removeRingingCall ()V public final fun removeRingingCall (Lio/getstream/video/android/core/Call;)V public final fun setActiveCall (Lio/getstream/video/android/core/Call;)V - public final fun transitionToAcceptCall (Lio/getstream/video/android/core/Call;)V } public abstract interface class io/getstream/video/android/core/ConnectionState { 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 6cd23bde17..c69209625b 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 @@ -232,7 +232,7 @@ class ClientState(private val client: StreamVideo) { } } - fun transitionToAcceptCall(call: Call) { + internal fun transitionToAcceptCall(call: Call) { if (call.id == ringingCall.value?.id) { (client as StreamVideoClient).callSoundAndVibrationPlayer.stopCallSound() _ringingCall.value = null diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt index cdea058dfe..484ef6e3d5 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt @@ -301,7 +301,7 @@ class StreamDefaultNotificationHandlerTest { verify { mockNotificationManager.notify(notificationId, any()) } } - @Test // failed + @Test fun `onMissedCall falls back to default intent when specific intent not found`() { // Given val callDisplayName = "Bob Wilson" From 8346b9e4a0e634a79e2e6ba57d5b15bb05e6909d Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:37:22 +0530 Subject: [PATCH 19/42] fix: Remove stopService call from ServiceLauncher Removes an unnecessary `stopService` call that was being made immediately after `startService` in `ServiceLauncher`. --- .../core/notifications/internal/service/ServiceIntentBuilder.kt | 1 + .../core/notifications/internal/service/ServiceLauncher.kt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt index 55b69fa64b..7bcfae9aff 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt @@ -85,6 +85,7 @@ internal class ServiceIntentBuilder { return intent.putExtra(EXTRA_STOP_SERVICE, true) } + // TODO Rahul: Do before merge, check what was the problem with older method private fun isServiceRunning(serviceClass: Class<*>): Boolean = safeCallWithDefault(true) { if (CallService.isServiceRunning()) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt index 999ebc1aac..76774d6c61 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt @@ -260,7 +260,6 @@ internal class ServiceLauncher(val context: Context) { logBundle(it) } context.startService(serviceIntent) -// context.stopService(serviceIntent) } } } From 94138cca5cf0679bc21292d4ce84c474e7ed590f Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:39:20 +0530 Subject: [PATCH 20/42] chore: Add a TODO comment Adds a TODO comment to `DefaultNotificationDispatcher.kt` to ensure a new fix is verified before merging. --- .../notifications/dispatchers/DefaultNotificationDispatcher.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt index eb4ef41025..dfd7bff9d7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt @@ -24,6 +24,7 @@ import io.getstream.log.taggedLogger import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.model.StreamCallId +//TODO Rahul: Do before merge, ensure this is not broken with new fix class DefaultNotificationDispatcher( val notificationManager: NotificationManagerCompat, ) : NotificationDispatcher { From c60b687e3a87ac8c5cd3d8f205954a63244da0fb Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 02:39:55 +0530 Subject: [PATCH 21/42] chore: Add a TODO comment Adds a TODO comment to `DefaultNotificationDispatcher.kt` to ensure a new fix is verified before merging. --- .../notifications/dispatchers/DefaultNotificationDispatcher.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt index dfd7bff9d7..be282ddb2e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt @@ -24,7 +24,7 @@ import io.getstream.log.taggedLogger import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.model.StreamCallId -//TODO Rahul: Do before merge, ensure this is not broken with new fix +// TODO Rahul: Do before merge, ensure this is not broken with new fix class DefaultNotificationDispatcher( val notificationManager: NotificationManagerCompat, ) : NotificationDispatcher { From 8452835a6730ac98778e2389b0351edd74a4d9a3 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 05:03:50 +0530 Subject: [PATCH 22/42] test: Use RobolectricTestRunner in ServiceStateTest Adds the `RobolectricTestRunner` to `ServiceStateTest` to address test failures that occurred when running all tests together. The `unregisterToggleCameraBroadcastReceiver` test has been moved to the end of the file as it was failing for unknown reasons, and a `println` has been added for debugging. --- .../service/models/ServiceStateTest.kt | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt index c115538916..fe2b7638ed 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt @@ -31,8 +31,11 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner import kotlin.test.Test +@RunWith(RobolectricTestRunner::class) class ServiceStateTest { private val testDispatcher = StandardTestDispatcher() @@ -51,17 +54,6 @@ class ServiceStateTest { unmockkAll() } - @Test - fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() { - val sut = ServiceState() - sut.registerToggleCameraBroadcastReceiver(service, testScope) - sut.unregisterToggleCameraBroadcastReceiver(service) - - verify { - service.unregisterReceiver(any()) - } - } - @Test fun `registerToggleCameraBroadcastReceiver registers receiver`() { val sut = ServiceState() @@ -108,6 +100,21 @@ class ServiceStateTest { } } + /** + * Ignored because of unknown reason of failure, it will fail when running all tests + */ + @Test + fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() { + println("[unregisterToggleCameraBroadcastReceiver unregisters receiver]") + val sut = ServiceState() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + sut.unregisterToggleCameraBroadcastReceiver(service) + + verify { + service.unregisterReceiver(any()) + } + } + @Test fun `unregisterToggleCameraBroadcastReceiver swallows exception`() { val sut = ServiceState() From ab9b2bed7944f7818128abf7c41d2078ea633b51 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 13:24:54 +0530 Subject: [PATCH 23/42] chore: Improve documentation and make local events internal --- .../api/stream-video-android-core.api | 45 ------------------- .../models/LocalCallAcceptedPostEvent.kt | 4 +- .../models/LocalCallRejectedPostEvent.kt | 4 +- .../io/getstream/video/android/core/Call.kt | 3 ++ .../dispatchers/NotificationDispatcher.kt | 9 ++++ .../internal/service/CallService.kt | 31 ------------- .../service/ServiceNotificationRetriever.kt | 19 ++++++++ 7 files changed, 35 insertions(+), 80 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index cc2183fb2e..10a65b7a2d 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -3557,27 +3557,6 @@ public final class io/getstream/android/video/generated/models/ListTranscription public fun toString ()Ljava/lang/String; } -public final class io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { - public fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lorg/threeten/bp/OffsetDateTime; - public final fun component3 ()Lio/getstream/android/video/generated/models/CallResponse; - public final fun component4 ()Lio/getstream/android/video/generated/models/UserResponse; - public final fun component5 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallAcceptedPostEvent; - public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallAcceptedPostEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallAcceptedPostEvent; - public fun equals (Ljava/lang/Object;)Z - public final fun getCall ()Lio/getstream/android/video/generated/models/CallResponse; - public fun getCallCID ()Ljava/lang/String; - public final fun getCallCid ()Ljava/lang/String; - public final fun getCreatedAt ()Lorg/threeten/bp/OffsetDateTime; - public fun getEventType ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public final fun getUser ()Lio/getstream/android/video/generated/models/UserResponse; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public final class io/getstream/android/video/generated/models/LocalCallMissedEvent : io/getstream/android/video/generated/models/LocalEvent { public fun (Ljava/lang/String;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; @@ -3593,30 +3572,6 @@ public final class io/getstream/android/video/generated/models/LocalCallMissedEv public fun toString ()Ljava/lang/String; } -public final class io/getstream/android/video/generated/models/LocalCallRejectedPostEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { - public fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lorg/threeten/bp/OffsetDateTime; - public final fun component3 ()Lio/getstream/android/video/generated/models/CallResponse; - public final fun component4 ()Lio/getstream/android/video/generated/models/UserResponse; - public final fun component5 ()Ljava/lang/String; - public final fun component6 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/android/video/generated/models/LocalCallRejectedPostEvent; - public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/LocalCallRejectedPostEvent;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lio/getstream/android/video/generated/models/CallResponse;Lio/getstream/android/video/generated/models/UserResponse;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/LocalCallRejectedPostEvent; - public fun equals (Ljava/lang/Object;)Z - public final fun getCall ()Lio/getstream/android/video/generated/models/CallResponse; - public fun getCallCID ()Ljava/lang/String; - public final fun getCallCid ()Ljava/lang/String; - public final fun getCreatedAt ()Lorg/threeten/bp/OffsetDateTime; - public fun getEventType ()Ljava/lang/String; - public final fun getReason ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public final fun getUser ()Lio/getstream/android/video/generated/models/UserResponse; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - public abstract class io/getstream/android/video/generated/models/LocalEvent : io/getstream/android/video/generated/models/VideoEvent, io/getstream/android/video/generated/models/WSCallEvent { public fun ()V } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt index 59d0d70c87..240057076e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt @@ -27,10 +27,10 @@ import com.squareup.moshi.Json import org.threeten.bp.OffsetDateTime /** - * This event is sent when a user accepts a notification to join a call. + * This event is sent after [CallAcceptedEvent] is consumed in [io.getstream.video.android.core.CallState] */ -data class LocalCallAcceptedPostEvent ( +internal data class LocalCallAcceptedPostEvent ( @Json(name = "call_cid") val callCid: String, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt index 4e14d4eebe..953db4ca94 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt @@ -27,10 +27,10 @@ import com.squareup.moshi.Json import org.threeten.bp.OffsetDateTime /** - * This event is sent when a user rejects a notification to join a call. + * This event is sent after [CallRejectedEvent] is consumed in [io.getstream.video.android.core.CallState] */ -data class LocalCallRejectedPostEvent ( +internal data class LocalCallRejectedPostEvent ( @Json(name = "call_cid") val callCid: String, 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 08b8107c6e..7530ca7d8c 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 @@ -463,6 +463,9 @@ public class Call( } response.onSuccess { + /** + * Because [CallState.updateFromResponse] reads the value of [ClientState.ringingCall] + */ if (ring) { client.state._ringingCall.value = this } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/NotificationDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/NotificationDispatcher.kt index d5a4034e26..fb6311b9d8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/NotificationDispatcher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/NotificationDispatcher.kt @@ -19,6 +19,15 @@ package io.getstream.video.android.core.notifications.dispatchers import android.app.Notification import io.getstream.video.android.model.StreamCallId +/** + * Dispatches a notification associated with a specific call. + */ interface NotificationDispatcher { + + /** + * @param streamCallId The unique identifier of the call this notification belongs to. + * @param id The notification ID used by the system to post or update the notification. + * @param notification The [Notification] instance to be displayed. + */ fun notify(streamCallId: StreamCallId, id: Int, notification: Notification) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index b8c55829e7..56def6fa95 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -203,13 +203,6 @@ internal open class CallService : Service() { ): Int { maybeHandleMediaIntent(intent, params.callId) - // Promote early to foreground -// maybePromoteToForegroundService( -// params.streamVideo, -// params.callId.hashCode(), -// params.trigger, -// ) - val call = params.streamVideo.call(params.callId.type, params.callId.id) if (!verifyPermissions(params.streamVideo, call, params.callId, params.trigger)) { @@ -373,30 +366,6 @@ internal open class CallService : Service() { ) } - private fun maybePromoteToForegroundService( - videoClient: StreamVideoClient, - notificationId: Int, - trigger: String, - ) { - val hasActiveCall = videoClient.state.activeCall.value != null - val not = if (hasActiveCall) " not" else "" - - logger.d { - "[maybePromoteToForegroundService] hasActiveCall: $hasActiveCall. Will$not call startForeground early." - } - - if (!hasActiveCall) { - videoClient.getSettingUpCallNotification()?.let { notification -> - startForegroundWithServiceType( - notificationId, - notification, - trigger, - permissionManager.getServiceType(baseContext, trigger), - ) - } - } - } - private fun showIncomingCall( callId: StreamCallId, notificationId: Int, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt index 3dcd0abc47..194b6deb5e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt @@ -34,6 +34,25 @@ import io.getstream.video.android.model.StreamCallId internal class ServiceNotificationRetriever { private val logger by taggedLogger("ServiceNotificationRetriever") + /** + * Builds a notification and its corresponding notification ID for a given call trigger. + * + * This method is responsible for creating (or updating) the call-related notification + * based on the provided trigger and current call context. + * + * @param context The Android [Context] used to build the notification. + * @param trigger A string indicating the reason for the notification update + * eg. [CallService.TRIGGER_INCOMING_CALL], [CallService.TRIGGER_ONGOING_CALL], [CallService.TRIGGER_OUTGOING_CALL] + * @param streamVideo The active [StreamVideoClient] instance used to access call and SDK state. + * @param streamCallId The unique identifier of the call this notification belongs to. + * @param intentCallDisplayName Optional display name for the call, typically + * shown in the notification UI. + * + * @return A [Pair] where: + * - **first**: The [Notification] to be displayed, or `null` if no notification + * should be shown for the given trigger. + * - **second**: The notification ID used to post or update the notification. + */ open fun getNotificationPair( context: Context, trigger: String, From ed98b04696ef69d3bc918818864ac80b4294e719 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Dec 2025 14:09:27 +0530 Subject: [PATCH 24/42] feat: Add support for outgoing call notifications When a call is initiated, it now transitions through the `RingingState.Outgoing` state. This change ensures that a foreground service with an outgoing call notification is started, providing immediate feedback to the user that a call is being placed. A delay is introduced before starting the foreground service for outgoing calls to allow the system to properly transition the call state. --- .../video/android/core/ClientState.kt | 7 ++++ .../internal/service/CallService.kt | 42 +++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) 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 c69209625b..b2dd7cdf41 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 @@ -169,6 +169,13 @@ class ClientState(private val client: StreamVideo) { transitionToAcceptCall(call) maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) } + is RingingState.Outgoing -> { + call.scope.launch { + transitionToAcceptCall(call) + delay(500L) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } + } else -> { removeRingingCall(call) call.scope.launch { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 56def6fa95..82e9aa21af 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -242,7 +242,7 @@ internal open class CallService : Service() { trigger: String, ) { callServiceLifecycleManager.initializeCallAndSocket(serviceScope, streamVideo, callId) { -// stopServiceGracefully() +// stopServiceGracefully() TODO Rahul enable it before merge } if (trigger == TRIGGER_INCOMING_CALL) { @@ -268,6 +268,9 @@ internal open class CallService : Service() { trigger: String, call: Call, ): Boolean { + logger.d { + "[handleNotification] Noob 1, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" + } if (notification == null) { return if (trigger == TRIGGER_REMOVE_INCOMING_CALL) { removeIncomingCall(notificationId, call) @@ -283,9 +286,42 @@ internal open class CallService : Service() { showIncomingCall(callId, notificationId, notification) true } - + TRIGGER_ONGOING_CALL -> { + val notificationId = call.state.notificationId + ?: callId.getNotificationId(NotificationType.Ongoing) + logger.d { + "[handleNotification] Noob 2, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" + } + call.state.updateNotification(notificationId, notification) + startForegroundWithServiceType( + notificationId, + notification, + trigger, + permissionManager.getServiceType(baseContext, trigger), + ) + true + } + TRIGGER_OUTGOING_CALL -> { + val notificationId = call.state.notificationId + ?: callId.getNotificationId(NotificationType.Outgoing) + logger.d { + "[handleNotification] Noob 3, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" + } + call.state.updateNotification(notificationId, notification) + startForegroundWithServiceType( + notificationId, + notification, + trigger, + permissionManager.getServiceType(baseContext, trigger), + ) + true + } else -> { - val notificationId = callId.hashCode() + val notificationId = call.state.notificationId + ?: callId.hashCode() // instead get notification from call object + logger.d { + "[handleNotification] Noob 4, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" + } call.state.updateNotification(notificationId, notification) startForegroundWithServiceType( notificationId, From db54cee922847c070cc625d122f9224e8d33fdd9 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 31 Dec 2025 12:44:32 +0530 Subject: [PATCH 25/42] Refactor: Improve notification and service handling for calls This commit introduces several improvements to the handling of call notifications and the lifecycle of the `CallService`. The `handleNotification` method in `CallService` now returns a `CallServiceHandleNotificationResult` enum. This allows for more granular control over the service's state, enabling it to continue running without re-initialization (`START_NO_CHANGE`) or to be redelivered if an error occurs. Additionally, the logic for handling `LocalCallMissedEvent` has been moved from `CallServiceEventObserver` directly into the `CallState`. This change ensures that incoming call notifications are correctly dismissed when another call is already active, without unnecessarily stopping the foreground service. Other changes include: - Preventing the start of a new `CallService` if one is already running for another call. - Adding a short delay before starting the foreground service when a call is accepted to prevent race conditions. - Enhancing logging for better debugging of service and notification lifecycles. --- .../getstream/video/android/core/CallState.kt | 15 +++ .../video/android/core/ClientState.kt | 13 ++- .../receivers/LeaveCallBroadcastReceiver.kt | 1 + .../internal/service/CallService.kt | 94 ++++++++++-------- .../CallServiceHandleNotificationResult.kt | 37 +++++++ .../internal/service/IncomingCallPresenter.kt | 96 ++++++++++++------- .../internal/service/ServiceIntentBuilder.kt | 2 +- .../internal/service/ServiceLauncher.kt | 5 + .../CallServiceNotificationManager.kt | 18 +++- .../observers/CallServiceEventObserver.kt | 21 ++-- .../CallServiceNotificationManagerTest.kt | 6 +- 11 files changed, 211 insertions(+), 97 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceHandleNotificationResult.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index edfe6ba51a..8dbd2120e8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -20,6 +20,7 @@ import android.app.Notification import android.os.Bundle import android.util.Log import androidx.compose.runtime.Stable +import androidx.core.app.NotificationManagerCompat import io.getstream.android.video.generated.models.BlockedUserEvent import io.getstream.android.video.generated.models.CallAcceptedEvent import io.getstream.android.video.generated.models.CallClosedCaption @@ -112,6 +113,7 @@ import io.getstream.video.android.core.model.ScreenSharingSession import io.getstream.video.android.core.model.VisibilityOnScreenState import io.getstream.video.android.core.moderations.ModerationManager import io.getstream.video.android.core.notifications.IncomingNotificationData +import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository import io.getstream.video.android.core.permission.PermissionRequest @@ -125,6 +127,7 @@ import io.getstream.video.android.core.utils.TaskSchedulerWithDebounce import io.getstream.video.android.core.utils.mapState import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.core.utils.toUser +import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.User import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -711,6 +714,7 @@ public class CallState( fun handleEvent(event: VideoEvent) { logger.d { "[handleEvent] ${event::class.java.name.split(".").last()}" } + when (event) { is BlockedUserEvent -> { val newBlockedUsers = _blockedUsers.value.toMutableSet() @@ -802,6 +806,17 @@ public class CallState( _rejectedBy.value = newRejectedBySet.toSet() _ringingState.value = RingingState.RejectedByAll call.leave("LocalCallMissedEvent") + + val activeCallExists = client.state.activeCall.value != null + if (activeCallExists) { + // Another call is active - just remove incoming notification + val streamCallId = StreamCallId(call.type, call.id) + NotificationManagerCompat.from(client.context) + .cancel(streamCallId.getNotificationId(NotificationType.Incoming)) + } else { + // No other call - stop service + client.state.maybeStopForegroundService(call) + } } } 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 b2dd7cdf41..e0b54e9dc0 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 @@ -166,8 +166,11 @@ class ClientState(private val client: StreamVideo) { val ringingState = call.state.ringingState.value when (ringingState) { is RingingState.Incoming -> { - transitionToAcceptCall(call) - maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + call.scope.launch { + transitionToAcceptCall(call) + delay(500L) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } } is RingingState.Outgoing -> { call.scope.launch { @@ -201,6 +204,9 @@ class ClientState(private val client: StreamVideo) { } internal fun removeActiveCall(call: Call) { + logger.d { + "[removeActiveCall] call.id == activeCall.value?.id :${call.id == activeCall.value?.id}" + } if (call.id == activeCall.value?.id) { _activeCall.value?.let { maybeStopForegroundService(it) @@ -230,6 +236,9 @@ class ClientState(private val client: StreamVideo) { } fun removeRingingCall(call: Call) { + logger.d { + "[removeRingingCall] call.id == ringingCall.value?.id: ${call.id == ringingCall.value?.id}" + } if (call.id == ringingCall.value?.id) { (client as StreamVideoClient).callSoundAndVibrationPlayer.stopCallSound() ringingCall.value?.let { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt index a2bd6a1b2a..6453654fa3 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt @@ -39,6 +39,7 @@ internal class LeaveCallBroadcastReceiver : GenericCallActionBroadcastReceiver() call.leave("LeaveCallBroadcastReceiver") val notificationId = intent.getIntExtra(INTENT_EXTRA_NOTIFICATION_ID, 0) + logger.d { "[onReceive], notificationId: notificationId" } NotificationManagerCompat.from(context).cancel(notificationId) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 82e9aa21af..a425209f0c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -21,6 +21,7 @@ import android.app.Notification import android.app.Service import android.content.Intent import android.os.IBinder +import androidx.core.app.NotificationManagerCompat import androidx.media.session.MediaButtonReceiver import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call @@ -91,6 +92,8 @@ internal open class CallService : Service() { const val TRIGGER_ONGOING_CALL = "ongoing_call" const val EXTRA_STOP_SERVICE = "io.getstream.video.android.core.stop_service" + const val SERVICE_DESTROY_THRESHOLD_TIME_SECONDS = 2L + @Volatile public var runningServiceClassName: HashSet = HashSet() @@ -116,6 +119,7 @@ internal open class CallService : Service() { logger.d { "[shouldStopService]: service hashcode: ${this.hashCode()}, serviceState.currentCallId!=null : ${serviceState.currentCallId != null}, serviceState.currentCallId == intentCallId && shouldStopService : ${serviceState.currentCallId == intentCallId && shouldStopService}" } + if (serviceState.currentCallId != null && serviceState.currentCallId == intentCallId && shouldStopService) { logger.d { "[shouldStopService]: true, call_cid:${intentCallId?.cid}" } return true @@ -217,7 +221,7 @@ internal open class CallService : Service() { params.displayName, ) - val started = handleNotification( + val handleNotificationResult = handleNotification( notification, notificationId, params.callId, @@ -225,14 +229,19 @@ internal open class CallService : Service() { call, ) - return if (started) { - serviceState.soundPlayer = params.streamVideo.callSoundAndVibrationPlayer - initializeService(params.streamVideo, params.callId, params.trigger) - START_NOT_STICKY - } else { - logger.w { "Foreground service did not start!" } - stopServiceGracefully() - START_REDELIVER_INTENT + return when (handleNotificationResult) { + CallServiceHandleNotificationResult.START -> { + initializeService(params.streamVideo, params.callId, params.trigger) + START_NOT_STICKY + } + + CallServiceHandleNotificationResult.REDELIVER -> { + logger.w { "Foreground service did not start!" } + stopServiceGracefully() + START_REDELIVER_INTENT + } + + CallServiceHandleNotificationResult.START_NO_CHANGE -> START_NOT_STICKY } } @@ -267,26 +276,41 @@ internal open class CallService : Service() { callId: StreamCallId, trigger: String, call: Call, - ): Boolean { + ): CallServiceHandleNotificationResult { logger.d { "[handleNotification] Noob 1, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" } if (notification == null) { return if (trigger == TRIGGER_REMOVE_INCOMING_CALL) { - removeIncomingCall(notificationId, call) - true + val ifServiceWasStartedForThisCall = serviceState.currentCallId?.id == callId.id + if (ifServiceWasStartedForThisCall) { + removeIncomingCall(notificationId, call) + CallServiceHandleNotificationResult.START + } else { + /** + * Means we only posted notification for this call, Service was never started for this call + */ + val notificationId = call.state.notificationId + ?: callId.getNotificationId(NotificationType.Incoming) + NotificationManagerCompat.from(this).cancel(notificationId) + CallServiceHandleNotificationResult.START_NO_CHANGE + } } else { logger.e { "Could not get notification for trigger: $trigger, callId: ${callId.id}" } - false + CallServiceHandleNotificationResult.REDELIVER } } - serviceState.currentCallId = callId + + // TODO Rahul, problematic in-case of getting an incoming call while still on active call + return when (trigger) { TRIGGER_INCOMING_CALL -> { + serviceState.currentCallId = callId showIncomingCall(callId, notificationId, notification) - true + CallServiceHandleNotificationResult.START } TRIGGER_ONGOING_CALL -> { + serviceState.currentCallId = callId val notificationId = call.state.notificationId ?: callId.getNotificationId(NotificationType.Ongoing) logger.d { @@ -299,9 +323,10 @@ internal open class CallService : Service() { trigger, permissionManager.getServiceType(baseContext, trigger), ) - true + CallServiceHandleNotificationResult.START } TRIGGER_OUTGOING_CALL -> { + serviceState.currentCallId = callId val notificationId = call.state.notificationId ?: callId.getNotificationId(NotificationType.Outgoing) logger.d { @@ -314,9 +339,11 @@ internal open class CallService : Service() { trigger, permissionManager.getServiceType(baseContext, trigger), ) - true + CallServiceHandleNotificationResult.START } + else -> { + serviceState.currentCallId = callId val notificationId = call.state.notificationId ?: callId.hashCode() // instead get notification from call object logger.d { @@ -329,7 +356,7 @@ internal open class CallService : Service() { trigger, permissionManager.getServiceType(baseContext, trigger), ) - true + CallServiceHandleNotificationResult.START } } } @@ -429,10 +456,7 @@ internal open class CallService : Service() { } private fun removeIncomingCall(notificationId: Int, call: Call) { - logger.d { - "[removeIncomingCall] notificationId: $notificationId, serviceState.currentCallId?.cid == call.cid: ${serviceState.currentCallId?.cid == call.cid}" - } -// NotificationManagerCompat.from(this).cancel(notificationId) + logger.d { "[removeIncomingCall] notificationId: $notificationId" } if (serviceState.currentCallId?.cid == call.cid) { stopServiceGracefully() } @@ -487,6 +511,7 @@ internal open class CallService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) logger.w { "[onTaskRemoved]" } + callServiceLifecycleManager.endCall(serviceScope, serviceState.currentCallId) stopServiceGracefully() } @@ -529,50 +554,39 @@ internal open class CallService : Service() { */ val debouncer = Debouncer() private fun stopServiceGracefully() { -// printLastStackFrames(10) - serviceState.startTime?.let { startTime -> val currentTime = OffsetDateTime.now() val duration = Duration.between(startTime, currentTime) val differenceInSeconds = duration.seconds.absoluteValue - val debouncerThresholdTime = 2_000L + val debouncerThresholdTime = SERVICE_DESTROY_THRESHOLD_TIME_SECONDS logger.d { "[stopServiceGracefully] differenceInSeconds: $differenceInSeconds" } if (differenceInSeconds >= debouncerThresholdTime) { internalStopServiceGracefully() } else { - debouncer.submit(2_000L) { + debouncer.submit(debouncerThresholdTime) { internalStopServiceGracefully() } } } - -// } private fun internalStopServiceGracefully() { -// printLastStackFrames(4) logger.d { "Noob, [internalStopServiceGracefully]" } stopForeground(STOP_FOREGROUND_REMOVE) - notificationManager.cancelNotifications(this, serviceState.currentCallId) + serviceState.currentCallId?.let { + notificationManager.cancelNotifications(this, it) + } + serviceState.unregisterToggleCameraBroadcastReceiver(this) /** * Temp Fix!! The observeRingingState scope was getting cancelled and as a result, * ringing state was not properly updated */ - serviceState.soundPlayer?.stopCallSound() + serviceState.soundPlayer?.stopCallSound() // TODO should check which call owns the sound serviceScope.cancel() - -// stopService(Intent(this, this::class.java)) -// startId?.let { stack -> -// while (stack.isNotEmpty()) { -// val idToStop = stack.pop() -// logger.d { "[internalStopServiceGracefully] Stopping service for startId: $idToStop" } -// stopSelf(idToStop) -// } -// } stopSelf() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceHandleNotificationResult.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceHandleNotificationResult.kt new file mode 100644 index 0000000000..5791f7bccd --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceHandleNotificationResult.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal.service + +internal enum class CallServiceHandleNotificationResult { + /** + * The notification was handled successfully and the service should start as usual, + * initializing its state and resources. + */ + START, + + /** + * The notification was handled, but it did not change the service's state. + * The service should continue running but should not re-initialize its components. + */ + START_NO_CHANGE, + + /** + * The notification could not be handled properly. + * The service should stop and request the system to redeliver the intent at a later time. + */ + REDELIVER, +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt index ede264e3d7..49def91481 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt @@ -62,45 +62,71 @@ internal class IncomingCallPresenter(private val serviceIntentBuilder: ServiceIn showIncomingCallResult = ShowIncomingCallResult.FG_SERVICE } else { - logger.d { "[showIncomingCall] Starting regular service" } - context.startService( - serviceIntentBuilder.buildStartIntent( - context, - StartServiceParam( - callId, - TRIGGER_INCOMING_CALL, - callDisplayName, - callServiceConfiguration, - ), - ), + /** + * If already same service is running either FG or background then we skip it & show normal notification + */ + + val startServiceParam = StartServiceParam( + callId, + TRIGGER_INCOMING_CALL, + callDisplayName, + callServiceConfiguration, ) - showIncomingCallResult = ShowIncomingCallResult.SERVICE + val serviceClass = startServiceParam.callServiceConfiguration.serviceClass + if (ServiceIntentBuilder().isServiceRunning(serviceClass)) { + showNotification(context, notification, callId, null) + } else { + logger.d { "[showIncomingCall] Starting regular service" } + context.startService( + serviceIntentBuilder.buildStartIntent( + context, + startServiceParam, + ), + ) + showIncomingCallResult = ShowIncomingCallResult.SERVICE + } } }.onError { // Show notification - logger.e { "Could not start service, showing notification only: $it" } - val hasPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - logger.i { "Has permission: $hasPermission" } - logger.i { "Notification: $notification" } - if (hasPermission && notification != null) { - logger.d { - "[showIncomingCall] Showing notification fallback with ID: ${callId.getNotificationId( - NotificationType.Incoming, - )}" - } - StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher()?.notify( - callId, - callId.getNotificationId(NotificationType.Incoming), - notification, - ) - showIncomingCallResult = ShowIncomingCallResult.ONLY_NOTIFICATION - } else { - logger.w { - "[showIncomingCall] Cannot show notification - hasPermission: $hasPermission, notification: ${notification != null}" - } + logger.d { "[showIncomingCall] onError" } + showNotification(context, notification, callId, it) + } + return showIncomingCallResult + } + + private fun showNotification( + context: Context, + notification: Notification?, + callId: StreamCallId, + error: Any?, + ): ShowIncomingCallResult { + var showIncomingCallResult = ShowIncomingCallResult.ERROR + if (error != null) { + logger.e { "[showNotification] Could not start service, showing notification only: $error" } + } else { + logger.e { "[showNotification] Could not start service, showing notification only" } + } + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + logger.i { "Has permission: $hasPermission" } + logger.i { "Notification: $notification" } + if (hasPermission && notification != null) { + logger.d { + "[showIncomingCall] Showing notification fallback with ID: ${callId.getNotificationId( + NotificationType.Incoming, + )}" + } + StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher()?.notify( + callId, + callId.getNotificationId(NotificationType.Incoming), + notification, + ) + showIncomingCallResult = ShowIncomingCallResult.ONLY_NOTIFICATION + } else { + logger.w { + "[showIncomingCall] Cannot show notification - hasPermission: $hasPermission, notification: ${notification != null}" } } return showIncomingCallResult diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt index 7bcfae9aff..42b6f14ff7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt @@ -86,7 +86,7 @@ internal class ServiceIntentBuilder { } // TODO Rahul: Do before merge, check what was the problem with older method - private fun isServiceRunning(serviceClass: Class<*>): Boolean = + internal fun isServiceRunning(serviceClass: Class<*>): Boolean = safeCallWithDefault(true) { if (CallService.isServiceRunning()) { val runningServiceName = CallService.runningServiceClassName.filter { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt index 76774d6c61..2808baca9d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt @@ -225,6 +225,11 @@ internal class ServiceLauncher(val context: Context) { ), )!! }.onError { + logger.d { + "[removeIncomingCall] notificationId: ${callId.getNotificationId( + NotificationType.Incoming, + )}" + } NotificationManagerCompat.from(context) .cancel(callId.getNotificationId(NotificationType.Incoming)) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt index 1e00cca94a..12a49eba77 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt @@ -26,7 +26,6 @@ import androidx.core.app.NotificationManagerCompat import io.getstream.log.taggedLogger import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient -import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.handlers.StreamDefaultNotificationHandler import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId @@ -51,14 +50,23 @@ internal class CallServiceNotificationManager { } } - fun cancelNotifications(service: Service, callId: StreamCallId?) { - logger.d { "[cancelNotifications]" } + fun cancelNotifications(service: Service, callId: StreamCallId) { + logger.d { "[cancelNotifications], notificationId: " } val notificationManager = NotificationManagerCompat.from(service) - callId?.let { + callId.let { + logger.d { "[cancelNotifications], 1: notificationId via hashcode: ${it.hashCode()}" } notificationManager.cancel(it.hashCode()) - notificationManager.cancel(it.getNotificationId(NotificationType.Incoming)) + } + + val call = (StreamVideo.Companion.instanceOrNull() as? StreamVideoClient)?.call( + callId.type, + callId.id, + ) + call?.state?.notificationId?.let { notificationId -> + logger.d { "[cancelNotifications], 2: notificationId: $notificationId" } + notificationManager.cancel(notificationId) } safeCall { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt index ff3831c3ac..346915eb9d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt @@ -18,7 +18,6 @@ package io.getstream.video.android.core.notifications.internal.service.observers import io.getstream.android.video.generated.models.CallEndedEvent import io.getstream.android.video.generated.models.LocalCallAcceptedPostEvent -import io.getstream.android.video.generated.models.LocalCallMissedEvent import io.getstream.android.video.generated.models.LocalCallRejectedPostEvent import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call @@ -82,16 +81,16 @@ internal class CallServiceEventObserver( ) } is CallEndedEvent -> onServiceStop() - is LocalCallMissedEvent -> { - val activeCallExists = streamVideo.state.activeCall.value != null - if (activeCallExists) { - // Another call is active - just remove incoming notification - onRemoveIncoming() - } else { - // No other call - stop service - onServiceStop() - } - } +// is LocalCallMissedEvent -> { +// val activeCallExists = streamVideo.state.activeCall.value != null +// if (activeCallExists) { +// // Another call is active - just remove incoming notification +// onRemoveIncoming() +// } else { +// // No other call - stop service +// onServiceStop() +// } +// } } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt index bba3e3d594..41ff4d90f9 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt @@ -168,11 +168,11 @@ class CallServiceNotificationManagerTest { } } - @Test +// @Test fun `cancelNotifications is safe when callId is null`() { - sut.cancelNotifications(service, null) +// sut.cancelNotifications(service, null) - verify { notificationManagerCompat wasNot Called } +// verify { notificationManagerCompat wasNot Called } } @Test From 53896ebfb3f705c80ca8c3cf21f38fe177604f57 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 06:41:14 +0530 Subject: [PATCH 26/42] fix: Remove leftover comments and add clarity Removes a leftover development comment in `ServiceLauncher.kt`. Adds a clarifying comment in `ClientState.kt` to explain the logic for transitioning incoming/outgoing calls to an active state within the same service. --- .../kotlin/io/getstream/video/android/core/ClientState.kt | 3 +++ .../core/notifications/internal/service/ServiceLauncher.kt | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) 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 e0b54e9dc0..20c9e81341 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 @@ -163,6 +163,9 @@ class ClientState(private val client: StreamVideo) { fun setActiveCall(call: Call) { this._activeCall.value = call + /** + * Transition incoming/outgoing call to active on the same service + */ val ringingState = call.state.ringingState.value when (ringingState) { is RingingState.Incoming -> { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt index 2808baca9d..295e3ca6df 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt @@ -237,7 +237,7 @@ internal class ServiceLauncher(val context: Context) { fun stopService(call: Call) { logger.d { "[stopService]" } - // noob enable later + // TODO Rahul: check before merge Throttler.throttleFirst(1000) { logger.d { "[stopService], inside throttler.throttleFirst, time in ms: ${System.currentTimeMillis()}" @@ -253,6 +253,7 @@ internal class ServiceLauncher(val context: Context) { val callConfig = streamVideoClient.callServiceConfigRegistry.get(call.type) if (callConfig.runCallServiceInForeground) { val context = streamVideoClient.context + val serviceIntent = serviceIntentBuilder.buildStopIntent( context, StopServiceParam(call, callConfig), @@ -269,6 +270,7 @@ internal class ServiceLauncher(val context: Context) { } } } + private fun logBundle(bundle: Bundle) { val keys = bundle.keySet() if (keys != null) { From bbdd712968eb92733bce667cc1d389b60abe2ded Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 07:04:25 +0530 Subject: [PATCH 27/42] chore: Remove temporary logging Removes temporary "Noob" log messages from `ClientState`, `CallServiceEventObserver`, and `CallService`. Also removes some commented-out code from `CallServiceEventObserver`. --- .../io/getstream/video/android/core/ClientState.kt | 4 ++-- .../notifications/internal/service/CallService.kt | 7 +------ .../service/observers/CallServiceEventObserver.kt | 12 +----------- 3 files changed, 4 insertions(+), 19 deletions(-) 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 20c9e81341..b1bce3f78d 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 @@ -263,7 +263,7 @@ class ClientState(private val client: StreamVideo) { * This depends on the flag in [StreamVideoBuilder] called `runForegroundServiceForCalls` */ internal fun maybeStartForegroundService(call: Call, trigger: String) { - logger.d { "Noob, [maybeStartForegroundService], trigger: $trigger" } + logger.d { "[maybeStartForegroundService], trigger: $trigger" } when (trigger) { CallService.TRIGGER_ONGOING_CALL -> serviceLauncher.showOnGoingCall( call, @@ -289,7 +289,7 @@ class ClientState(private val client: StreamVideo) { if (callConfig.runCallServiceInForeground) { val context = streamVideoClient.context - logger.d { "Noob, Building stop intent for call_id: ${call.cid}" } + logger.d { "Building stop intent for call_id: ${call.cid}" } val serviceLauncher = ServiceLauncher(context) serviceLauncher.stopService(call) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index a425209f0c..8710433a47 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -530,11 +530,6 @@ internal open class CallService : Service() { logger.d { stack.takeLast(count).joinToString("\n") } } - override fun stopService(name: Intent?): Boolean { - logger.d { "Noob, [stopService(name)], Callservice hashcode: ${hashCode()}" } - return super.stopService(name) - } - private fun streamDefaultNotificationHandler(): StreamDefaultNotificationHandler? { val client = StreamVideo.instanceOrNull() as StreamVideoClient val handler = @@ -572,7 +567,7 @@ internal open class CallService : Service() { } private fun internalStopServiceGracefully() { - logger.d { "Noob, [internalStopServiceGracefully]" } + logger.d { "[internalStopServiceGracefully]" } stopForeground(STOP_FOREGROUND_REMOVE) serviceState.currentCallId?.let { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt index 346915eb9d..a38d0e18ae 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt @@ -81,16 +81,6 @@ internal class CallServiceEventObserver( ) } is CallEndedEvent -> onServiceStop() -// is LocalCallMissedEvent -> { -// val activeCallExists = streamVideo.state.activeCall.value != null -// if (activeCallExists) { -// // Another call is active - just remove incoming notification -// onRemoveIncoming() -// } else { -// // No other call - stop service -// onServiceStop() -// } -// } } } @@ -107,7 +97,7 @@ internal class CallServiceEventObserver( // If I accepted the call on another device while this device is still ringing if (acceptedByUserId == myUserId && callRingingState is RingingState.Incoming) { - onServiceStop() // noob 1 + onServiceStop() } } From bbbbe3fd390145c1c60ac27cf7b9504a740a6853 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 07:20:13 +0530 Subject: [PATCH 28/42] fix: Correctly report service running state Updates the `isServiceRunning` check to return `false` when the service is not running. --- .../core/notifications/internal/service/ServiceIntentBuilder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt index 42b6f14ff7..cebb3be25e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt @@ -96,7 +96,7 @@ internal class ServiceIntentBuilder { return@safeCallWithDefault runningServiceName.isNotEmpty() } else { logger.w { "[isServiceRunning], Service is not running: $serviceClass" } - return@safeCallWithDefault true + return@safeCallWithDefault false } } } From 5d38f6f1fe9801071cb7de5ade8ecbfe6da0dbd1 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 07:34:29 +0530 Subject: [PATCH 29/42] refactor: Improve service running check Removes the static `runningServiceClassName` set from `CallService` and updates the `isServiceRunning` check. The `ServiceIntentBuilder.isServiceRunning` method now uses `ActivityManager.getRunningServices()` to determine if a service is active. This provides a more reliable check based on the Android system's state. The method signature for `isServiceRunning` has been updated to require a `Context` parameter. --- .../internal/service/CallService.kt | 6 ----- .../internal/service/IncomingCallPresenter.kt | 2 +- .../internal/service/ServiceIntentBuilder.kt | 24 ++++++++++--------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 8710433a47..f3c1b4e5a2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -94,10 +94,6 @@ internal open class CallService : Service() { const val SERVICE_DESTROY_THRESHOLD_TIME_SECONDS = 2L - @Volatile - public var runningServiceClassName: HashSet = HashSet() - - fun isServiceRunning(): Boolean = runningServiceClassName.isNotEmpty() private val logger by taggedLogger("CallService") val handler = CoroutineExceptionHandler { _, exception -> @@ -110,7 +106,6 @@ internal open class CallService : Service() { override fun onCreate() { super.onCreate() serviceState.startTime = OffsetDateTime.now() - runningServiceClassName.add(this::class.java.simpleName) } private fun shouldStopService(intent: Intent?): Boolean { @@ -520,7 +515,6 @@ internal open class CallService : Service() { logger.d { "Noob, [onDestroy], Callservice hashcode: ${hashCode()}, call_cid: ${serviceState.currentCallId?.cid}" } - runningServiceClassName.remove(this::class.java.simpleName) serviceState.soundPlayer?.cleanUpAudioResources() super.onDestroy() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt index 49def91481..dff1bf29dd 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt @@ -73,7 +73,7 @@ internal class IncomingCallPresenter(private val serviceIntentBuilder: ServiceIn callServiceConfiguration, ) val serviceClass = startServiceParam.callServiceConfiguration.serviceClass - if (ServiceIntentBuilder().isServiceRunning(serviceClass)) { + if (ServiceIntentBuilder().isServiceRunning(context, serviceClass)) { showNotification(context, notification, callId, null) } else { logger.d { "[showIncomingCall] Starting regular service" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt index cebb3be25e..78655a2fb3 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt @@ -16,6 +16,7 @@ package io.getstream.video.android.core.notifications.internal.service +import android.app.ActivityManager import android.content.Context import android.content.Intent import io.getstream.log.taggedLogger @@ -70,7 +71,7 @@ internal class ServiceIntentBuilder { fun buildStopIntent(context: Context, stopServiceParam: StopServiceParam): Intent? { logger.d { "[buildStopIntent]" } val serviceClass = stopServiceParam.callServiceConfiguration.serviceClass - val intent = if (isServiceRunning(serviceClass)) { + val intent = if (isServiceRunning(context, serviceClass)) { Intent(context, serviceClass) } else { return null @@ -85,18 +86,19 @@ internal class ServiceIntentBuilder { return intent.putExtra(EXTRA_STOP_SERVICE, true) } - // TODO Rahul: Do before merge, check what was the problem with older method - internal fun isServiceRunning(serviceClass: Class<*>): Boolean = + internal fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean = safeCallWithDefault(true) { - if (CallService.isServiceRunning()) { - val runningServiceName = CallService.runningServiceClassName.filter { - it.contains(serviceClass.simpleName) + val activityManager = context.getSystemService( + Context.ACTIVITY_SERVICE, + ) as ActivityManager + val runningServices = activityManager.getRunningServices(Int.MAX_VALUE) + for (service in runningServices) { + if (serviceClass.name == service.service.className) { + logger.d { "[isServiceRunning], Service is running: $serviceClass" } + return true } - logger.d { "[isServiceRunning], Service is running: $runningServiceName" } - return@safeCallWithDefault runningServiceName.isNotEmpty() - } else { - logger.w { "[isServiceRunning], Service is not running: $serviceClass" } - return@safeCallWithDefault false } + logger.d { "[isServiceRunning], Service is NOT running: $serviceClass" } + return false } } From 9c7d4982deceeb469dff616827f86082b3f113c9 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 07:45:04 +0530 Subject: [PATCH 30/42] refactor: Extract Throttler from Debouncer Extracts the `Throttler` object from `Debouncer.kt` into its own dedicated file, `Throttler.kt`. This change also moves the `debouncer` instance in `CallService` to be a top-level property and adds documentation explaining its purpose. --- .../core/notifications/internal/Debouncer.kt | 69 +-------------- .../core/notifications/internal/Throttler.kt | 84 +++++++++++++++++++ .../internal/service/CallService.kt | 18 ++-- 3 files changed, 96 insertions(+), 75 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Throttler.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt index 5e8dddbab2..6eba3b6d1e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt @@ -18,8 +18,6 @@ package io.getstream.video.android.core.notifications.internal import android.os.Handler import android.os.Looper -import io.getstream.log.taggedLogger -import java.util.concurrent.ConcurrentHashMap internal class Debouncer { @@ -27,74 +25,11 @@ internal class Debouncer { private var runnable: Runnable? = null fun submit(delayMs: Long, action: () -> Unit) { - // Cancel previous run runnable?.let { handler.removeCallbacks(it) } - // Schedule new run runnable = Runnable { action() } - handler.postDelayed(runnable!!, delayMs) - } -} - -internal object Throttler { - - private val logger by taggedLogger("Throttler") - - // A thread-safe map to store the last execution time for each key. - // The value is the timestamp (in milliseconds) when the key's cooldown started. - private val lastExecutionTimestamps = ConcurrentHashMap() - - /** - * Submits an action for potential execution, identified by a unique key. - * - * @param key A unique String identifying this action or instruction. - * @param cooldownMs The duration in milliseconds for this key's cooldown period. - * @param action The lambda to execute if the key is not on cooldown. - */ - fun throttleFirst(key: String, cooldownMs: Long, action: () -> Unit) { - val currentTime = System.currentTimeMillis() - val lastExecutionTime = lastExecutionTimestamps[key] ?: 0L - val timeDiff = currentTime - lastExecutionTime - - // Check if the key is not on cooldown. - // This is true if the key has never been used (lastExecutionTime is null) - // or if the cooldown period has passed. - logger.d { - "[throttleFirst], timeDiff: $timeDiff, current: $currentTime, lastExecutionTime: $lastExecutionTime, key:$key, hashcode: ${hashCode()}" - } - if (lastExecutionTime == 0L || (timeDiff) >= cooldownMs) { - // Update the last execution time for this key to the current time. - lastExecutionTimestamps[key] = currentTime - // Execute the action. - action() + runnable?.let { + handler.postDelayed(it, delayMs) } - // If the key is on cooldown, do nothing. - } - - fun throttleFirst(cooldownMs: Long, action: () -> Unit) { - val key = getKey(action) - throttleFirst(key, cooldownMs, action) - } - - fun getKey(action: () -> Unit): String { - return Thread.currentThread().stackTrace.getOrNull(4)?.let { - "${it.className}#${it.methodName}:${it.lineNumber}" - } ?: "fallback_${action.hashCode()}" - } - - /** - * Manually clears the cooldown for a specific key, allowing its next action to run immediately. - * - * @param key The key to reset. - */ - fun reset(key: String) { - lastExecutionTimestamps.remove(key) - } - - /** - * Clears all active cooldowns. - */ - fun resetAll() { - lastExecutionTimestamps.clear() } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Throttler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Throttler.kt new file mode 100644 index 0000000000..70ab668a7f --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Throttler.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.notifications.internal + +import io.getstream.log.taggedLogger +import java.util.concurrent.ConcurrentHashMap +import kotlin.getValue + +internal object Throttler { + + private val logger by taggedLogger("Throttler") + + // A thread-safe map to store the last execution time for each key. + // The value is the timestamp (in milliseconds) when the key's cooldown started. + private val lastExecutionTimestamps = ConcurrentHashMap() + + /** + * Submits an action for potential execution, identified by a unique key. + * + * @param key A unique String identifying this action or instruction. + * @param cooldownMs The duration in milliseconds for this key's cooldown period. + * @param action The lambda to execute if the key is not on cooldown. + */ + fun throttleFirst(key: String, cooldownMs: Long, action: () -> Unit) { + val currentTime = System.currentTimeMillis() + val lastExecutionTime = lastExecutionTimestamps[key] ?: 0L + val timeDiff = currentTime - lastExecutionTime + + // Check if the key is not on cooldown. + // This is true if the key has never been used (lastExecutionTime is null) + // or if the cooldown period has passed. + logger.d { + "[throttleFirst], timeDiff: $timeDiff, current: $currentTime, lastExecutionTime: $lastExecutionTime, key:$key, hashcode: ${hashCode()}" + } + if (lastExecutionTime == 0L || (timeDiff) >= cooldownMs) { + // Update the last execution time for this key to the current time. + lastExecutionTimestamps[key] = currentTime + // Execute the action. + action() + } + // If the key is on cooldown, do nothing. + } + + fun throttleFirst(cooldownMs: Long, action: () -> Unit) { + val key = getKey(action) + throttleFirst(key, cooldownMs, action) + } + + fun getKey(action: () -> Unit): String { + return Thread.currentThread().stackTrace.getOrNull(4)?.let { + "${it.className}#${it.methodName}:${it.lineNumber}" + } ?: "fallback_${action.hashCode()}" + } + + /** + * Manually clears the cooldown for a specific key, allowing its next action to run immediately. + * + * @param key The key to reset. + */ + fun reset(key: String) { + lastExecutionTimestamps.remove(key) + } + + /** + * Clears all active cooldowns. + */ + fun resetAll() { + lastExecutionTimestamps.clear() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index f3c1b4e5a2..325f49d297 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -55,7 +55,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import org.threeten.bp.Duration import org.threeten.bp.OffsetDateTime -import java.util.Stack import kotlin.math.absoluteValue /** @@ -68,7 +67,16 @@ internal open class CallService : Service() { internal val callServiceLifecycleManager = CallServiceLifecycleManager() private val notificationManager = CallServiceNotificationManager() internal val serviceState = ServiceState() - private var startId: Stack? = null + + /** + * A debouncer used to delay the final stopping of the service. + * + * This is a workaround for an Android framework behavior where killing a Foreground Service + * too quickly (e.g., within ~2 seconds) can prevent its associated notification from being + * dismissed, especially if the notification tray is open. By debouncing the stop action, + * we ensure enough time has passed for the system to process the notification removal. + */ + internal val debouncer = Debouncer() open val serviceType: Int @SuppressLint("InlinedApi") @@ -168,11 +176,6 @@ internal open class CallService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { logger.d { "[onStartCommand], intent = $intent, flags:$flags, startId:$startId" } - if (this.startId == null) { - this.startId = Stack() - } - this.startId?.push(startId) - logIntentExtras(intent) // Early exit conditions @@ -541,7 +544,6 @@ internal open class CallService : Service() { * Should be invoke carefully for the calls which are still present in [StreamVideoClient.calls] * Else stopping service by an expired call can cancel current call's notification and the service itself */ - val debouncer = Debouncer() private fun stopServiceGracefully() { serviceState.startTime?.let { startTime -> From 309e3d6751d93b4060fd938902e52eed5498d2e2 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 08:21:09 +0530 Subject: [PATCH 31/42] feat: Throttle call service stop requests Throttles the `stopService` requests in `ServiceLauncher` to prevent the service from being stopped and restarted too frequently. The throttling is set to `1000ms` using the new `SERVICE_DESTROY_THROTTLE_TIME_MS` constant defined in `CallService`. --- .../core/notifications/internal/service/CallService.kt | 1 + .../notifications/internal/service/ServiceLauncher.kt | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 325f49d297..d4e172bd73 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -101,6 +101,7 @@ internal open class CallService : Service() { const val EXTRA_STOP_SERVICE = "io.getstream.video.android.core.stop_service" const val SERVICE_DESTROY_THRESHOLD_TIME_SECONDS = 2L + const val SERVICE_DESTROY_THROTTLE_TIME_MS = 1_000L private val logger by taggedLogger("CallService") diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt index 295e3ca6df..154f8bd83d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt @@ -235,13 +235,13 @@ internal class ServiceLauncher(val context: Context) { } } + /** + * Throttling the service by [CallService.SERVICE_DESTROY_THROTTLE_TIME_MS] such that the stop + * service is invoked once (at least less frequently) + */ fun stopService(call: Call) { logger.d { "[stopService]" } - // TODO Rahul: check before merge - Throttler.throttleFirst(1000) { - logger.d { - "[stopService], inside throttler.throttleFirst, time in ms: ${System.currentTimeMillis()}" - } + Throttler.throttleFirst(CallService.SERVICE_DESTROY_THROTTLE_TIME_MS) { stopCallServiceInternal(call) } } From e5641b53f937efb2f4ee2e215b273e512496052e Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 08:30:11 +0530 Subject: [PATCH 32/42] chore: Re-enable graceful service stop This commit re-enables the `stopServiceGracefully()` call, which was previously commented out. The `source` parameter has also been removed from the `stopServiceGracefully()` method signature. --- .../core/notifications/internal/service/CallService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index d4e172bd73..9d6817da8d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -250,7 +250,7 @@ internal open class CallService : Service() { trigger: String, ) { callServiceLifecycleManager.initializeCallAndSocket(serviceScope, streamVideo, callId) { -// stopServiceGracefully() TODO Rahul enable it before merge + stopServiceGracefully() } if (trigger == TRIGGER_INCOMING_CALL) { @@ -545,7 +545,7 @@ internal open class CallService : Service() { * Should be invoke carefully for the calls which are still present in [StreamVideoClient.calls] * Else stopping service by an expired call can cancel current call's notification and the service itself */ - private fun stopServiceGracefully() { + private fun stopServiceGracefully(source: String? = null) { serviceState.startTime?.let { startTime -> val currentTime = OffsetDateTime.now() From d96cdefd34879f54093b1b05606edf2f8521a338 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 08:31:03 +0530 Subject: [PATCH 33/42] chore: Remove TODO comment Removes a `// TODO` comment from `DefaultNotificationDispatcher`. --- .../notifications/dispatchers/DefaultNotificationDispatcher.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt index be282ddb2e..eb4ef41025 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/DefaultNotificationDispatcher.kt @@ -24,7 +24,6 @@ import io.getstream.log.taggedLogger import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.model.StreamCallId -// TODO Rahul: Do before merge, ensure this is not broken with new fix class DefaultNotificationDispatcher( val notificationManager: NotificationManagerCompat, ) : NotificationDispatcher { From 85dd8683edb20067159004a266edc742b1ab06e6 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 08:46:49 +0530 Subject: [PATCH 34/42] chore: Use notificationId from call state Retrieves the `notificationId` from the `call.state` when available, instead of calculating it. This ensures a consistent notification ID is used for ongoing, incoming, and outgoing call notifications within the `ServiceNotificationRetriever` and `CallServiceNotificationUpdateObserver`. --- .../service/ServiceNotificationRetriever.kt | 21 ++++++++++++------- .../CallServiceNotificationUpdateObserver.kt | 1 - 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt index 194b6deb5e..f47c9b8710 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetriever.kt @@ -63,23 +63,29 @@ internal class ServiceNotificationRetriever { logger.d { "[getNotificationPair] trigger: $trigger, callId: ${streamCallId.id}, callDisplayName: $intentCallDisplayName" } + val call = streamVideo.call(streamCallId.type, streamCallId.id) val notificationData: Pair = when (trigger) { TRIGGER_ONGOING_CALL -> { logger.d { "[getNotificationPair] Creating ongoing call notification" } + val notificationId = call.state.notificationId + ?: streamCallId.getNotificationId(NotificationType.Ongoing) + Pair( first = streamVideo.getOngoingCallNotification( callId = streamCallId, callDisplayName = intentCallDisplayName, payload = emptyMap(), ), - second = streamCallId.hashCode(), + second = notificationId, ) } TRIGGER_INCOMING_CALL -> { - logger.d { "[getNotificationPair] Creating incoming call notification" } val shouldHaveContentIntent = streamVideo.state.activeCall.value == null - logger.d { "[getNotificationPair] shouldHaveContentIntent: $shouldHaveContentIntent" } + logger.d { "[getNotificationPair] Creating incoming call notification" } + val notificationId = call.state.notificationId + ?: streamCallId.getNotificationId(NotificationType.Incoming) + Pair( first = streamVideo.getRingingCallNotification( ringingState = RingingState.Incoming(), @@ -88,12 +94,15 @@ internal class ServiceNotificationRetriever { shouldHaveContentIntent = shouldHaveContentIntent, payload = emptyMap(), ), - second = streamCallId.getNotificationId(NotificationType.Incoming), + second = notificationId, ) } TRIGGER_OUTGOING_CALL -> { logger.d { "[getNotificationPair] Creating outgoing call notification" } + val notificationId = call.state.notificationId + ?: streamCallId.getNotificationId(NotificationType.Outgoing) + Pair( first = streamVideo.getRingingCallNotification( ringingState = RingingState.Outgoing(), @@ -103,9 +112,7 @@ internal class ServiceNotificationRetriever { ), payload = emptyMap(), ), - second = streamCallId.getNotificationId( - NotificationType.Incoming, // TODO Rahul, should we change it to outgoing? - ), // Same for incoming and outgoing + second = notificationId, ) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt index 310ef0ad0c..ba69b7ccbb 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt @@ -138,7 +138,6 @@ internal class CallServiceNotificationUpdateObserver( notification: Notification, ) { logger.d { "[updateNotification] Showing active call notification" } -// val notificationId = callId.hashCode() val notificationId = call.state.notificationId ?: callId.getNotificationId(NotificationType.Ongoing) startForegroundWithServiceType( From 6b073d8263031595e41fe6ed2de97ea06f3a8963 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 09:31:52 +0530 Subject: [PATCH 35/42] refactor: Use Call specific notification ID in tests Updates the `ServiceNotificationRetrieverTest` to use the call-specific notification ID generated by `callId.getNotificationId()` instead of the call ID's hashcode. This aligns the test with the actual implementation for generating notification IDs. --- .../service/ServiceNotificationRetrieverTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt index 9ff3c3957f..898d356f33 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt @@ -18,6 +18,7 @@ package io.getstream.video.android.core.notifications.internal.service import android.app.Notification import android.content.Context +import io.getstream.video.android.core.Call import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL @@ -28,6 +29,7 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import kotlinx.coroutines.test.TestScope import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Before @@ -48,14 +50,18 @@ class ServiceNotificationRetrieverTest { private lateinit var context: Context private lateinit var serviceNotificationRetriever: ServiceNotificationRetriever private lateinit var testCallId: StreamCallId + private lateinit var call: Call @Before fun setUp() { MockKAnnotations.init(this, relaxUnitFun = true) context = RuntimeEnvironment.getApplication() -// callService = CallService() serviceNotificationRetriever = ServiceNotificationRetriever() testCallId = StreamCallId(type = "default", id = "test-call-123") + every { mockStreamVideoClient.scope } returns TestScope() + + call = Call(mockStreamVideoClient, "default", "test-call-123", mockk()) + every { mockStreamVideoClient.call(testCallId.type, testCallId.id) } returns call } // Test notification generation logic @@ -77,7 +83,7 @@ class ServiceNotificationRetrieverTest { // Then assertEquals(mockNotification, result.first) - assertEquals(testCallId.hashCode(), result.second) + assertEquals(testCallId.getNotificationId(NotificationType.Ongoing), result.second) } @Test From 4068127ab8afa99391113907aeac4609c8298725 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 15:03:06 +0530 Subject: [PATCH 36/42] fix: Cancel correct notification ID for calls When cancelling notifications for a call, the `CallServiceNotificationManager` now correctly uses the `notificationId` from the call's state. Previously, it was attempting to cancel a hardcoded `Incoming` notification type ID, which might not be the correct one. This change ensures the notification that was actually shown is the one that gets cancelled. --- .../CallServiceNotificationManagerTest.kt | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt index 41ff4d90f9..3300a04899 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt @@ -24,10 +24,10 @@ import android.content.pm.PackageManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import io.getstream.video.android.core.Call import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationConfig -import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher import io.getstream.video.android.core.notifications.handlers.CompatibilityStreamNotificationHandler import io.getstream.video.android.core.notifications.internal.StreamNotificationManager @@ -39,6 +39,7 @@ import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.test.TestScope import org.junit.After import org.junit.Before import org.junit.runner.RunWith @@ -54,7 +55,6 @@ class CallServiceNotificationManagerTest { private val notification: Notification = mockk() private val callId = StreamCallId("default", "call-123") -// private val dispatcher: StreamNotificationDispatcher = mockk(relaxed = true) private val notificationDispatcher: NotificationDispatcher = mockk(relaxed = true) private val notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true) @@ -133,13 +133,23 @@ class CallServiceNotificationManagerTest { @Test fun `cancelNotifications cancels call notifications`() { + val streamVideoClient = mockk {} + + every { StreamVideo.instanceOrNull() } returns streamVideoClient + + every { streamVideoClient.scope } returns TestScope() + + val call = Call(streamVideoClient, callId.type, callId.id, mockk()) + every { streamVideoClient.call(callId.type, callId.id) } returns call + every { StreamVideo.instanceOrNull() } returns streamVideoClient + sut.cancelNotifications(service, callId) verify { notificationManagerCompat.cancel(callId.hashCode()) - notificationManagerCompat.cancel( - callId.getNotificationId(NotificationType.Incoming), - ) + call.state.notificationId?.let { + notificationManagerCompat.cancel(it) + } } } @@ -159,6 +169,10 @@ class CallServiceNotificationManagerTest { every { this@mockk.streamNotificationManager } returns streamNotificationManager } + every { streamVideoClient.scope } returns TestScope() + + val call = Call(streamVideoClient, callId.type, callId.id, mockk()) + every { streamVideoClient.call(callId.type, callId.id) } returns call every { StreamVideo.instanceOrNull() } returns streamVideoClient sut.cancelNotifications(service, callId) From 332a3991c77c62aebe076cdfd5ccfbd6c1ccf35d Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 15:19:12 +0530 Subject: [PATCH 37/42] chore: Add logging and fix notification ID usage Adds more detailed logging to the `CallServiceNotificationUpdateObserver` for better insight into notification updates. Also, this change ensures that an existing notification ID from the call state is used before generating a new one when showing call notifications. --- .../CallServiceNotificationUpdateObserver.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt index ba69b7ccbb..96bfba1eca 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt @@ -94,10 +94,10 @@ internal class CallServiceNotificationUpdateObserver( */ private suspend fun updateNotification(context: Context) { val ringingState = call.state.ringingState.value - logger.d { "[updateNotification] ringingState: $ringingState" } - val notification = streamVideo.onCallNotificationUpdate(call) - logger.d { "[updateNotification] notification: ${notification != null}" } + logger.d { + "[updateNotification] ringingState: $ringingState, notification: ${notification != null}" + } if (notification != null) { showNotificationForState(context, ringingState, notification) @@ -137,11 +137,11 @@ internal class CallServiceNotificationUpdateObserver( callId: StreamCallId, notification: Notification, ) { - logger.d { "[updateNotification] Showing active call notification" } + logger.d { "[showActiveCallNotification] Showing active call notification" } val notificationId = call.state.notificationId ?: callId.getNotificationId(NotificationType.Ongoing) startForegroundWithServiceType( - notificationId, // todo rahul - correct this + notificationId, notification, CallService.Companion.TRIGGER_ONGOING_CALL, permissionManager.getServiceType(context, CallService.Companion.TRIGGER_ONGOING_CALL), @@ -153,9 +153,11 @@ internal class CallServiceNotificationUpdateObserver( callId: StreamCallId, notification: Notification, ) { - logger.d { "[updateNotification] Showing outgoing call notification" } + logger.d { "[showOutgoingCallNotification] Showing outgoing call notification" } + val notificationId = + call.state.notificationId ?: callId.getNotificationId(NotificationType.Outgoing) startForegroundWithServiceType( - callId.getNotificationId(NotificationType.Outgoing), // todo rahul - correct this + notificationId, notification, CallService.Companion.TRIGGER_OUTGOING_CALL, permissionManager.getServiceType(context, CallService.Companion.TRIGGER_OUTGOING_CALL), @@ -167,9 +169,11 @@ internal class CallServiceNotificationUpdateObserver( callId: StreamCallId, notification: Notification, ) { - logger.d { "[updateNotification] Showing incoming call notification" } + logger.d { "[showIncomingCallNotification] Showing incoming call notification" } + val notificationId = + call.state.notificationId ?: callId.getNotificationId(NotificationType.Incoming) startForegroundWithServiceType( - callId.getNotificationId(NotificationType.Incoming), + notificationId, notification, CallService.Companion.TRIGGER_INCOMING_CALL, permissionManager.getServiceType(context, CallService.Companion.TRIGGER_INCOMING_CALL), From b52fe239a8879775b770faa3d88962e8bd2c88f1 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 17:21:20 +0530 Subject: [PATCH 38/42] Refactor: Improve call service logic and logging This commit refactors the `CallService` to improve clarity and maintainability. Key changes include: - Extracting the notification handling logic into smaller, more focused functions (`handleNullNotification`, `startForegroundForCall`). - Passing the `serviceScope` to `CallServiceEventObserver` to ensure its lifecycle is tied to the service. - Enhancing logging throughout the call service and related managers to provide more detailed information for debugging, including hashcodes to identify specific object instances. - Adding `serviceScope.cancel()` in `onDestroy` to ensure all coroutines are properly cancelled when the service is destroyed. --- .../internal/service/CallService.kt | 169 +++++++++++------- .../CallServiceNotificationManager.kt | 6 +- .../observers/CallServiceEventObserver.kt | 4 +- 3 files changed, 108 insertions(+), 71 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 9d6817da8d..21e3a342bc 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -263,7 +263,7 @@ internal open class CallService : Service() { } serviceState.soundPlayer = streamVideo.callSoundAndVibrationPlayer - logger.d { "[initializeService] soundPlayer hashcode: ${serviceState.soundPlayer?.hashCode()}" } + logger.d { "[initializeService] soundPlayer's hashcode: ${serviceState.soundPlayer?.hashCode()}" } observeCall(callId, streamVideo) serviceState.registerToggleCameraBroadcastReceiver(this, serviceScope) @@ -276,87 +276,121 @@ internal open class CallService : Service() { trigger: String, call: Call, ): CallServiceHandleNotificationResult { - logger.d { - "[handleNotification] Noob 1, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" - } + logHandleStart(trigger, call, notificationId) + if (notification == null) { - return if (trigger == TRIGGER_REMOVE_INCOMING_CALL) { - val ifServiceWasStartedForThisCall = serviceState.currentCallId?.id == callId.id - if (ifServiceWasStartedForThisCall) { - removeIncomingCall(notificationId, call) - CallServiceHandleNotificationResult.START - } else { - /** - * Means we only posted notification for this call, Service was never started for this call - */ - val notificationId = call.state.notificationId - ?: callId.getNotificationId(NotificationType.Incoming) - NotificationManagerCompat.from(this).cancel(notificationId) - CallServiceHandleNotificationResult.START_NO_CHANGE - } - } else { - logger.e { "Could not get notification for trigger: $trigger, callId: ${callId.id}" } - CallServiceHandleNotificationResult.REDELIVER - } + return handleNullNotification(trigger, callId, call, notificationId) } - // TODO Rahul, problematic in-case of getting an incoming call while still on active call + serviceState.currentCallId = callId return when (trigger) { TRIGGER_INCOMING_CALL -> { - serviceState.currentCallId = callId showIncomingCall(callId, notificationId, notification) CallServiceHandleNotificationResult.START } - TRIGGER_ONGOING_CALL -> { - serviceState.currentCallId = callId - val notificationId = call.state.notificationId - ?: callId.getNotificationId(NotificationType.Ongoing) - logger.d { - "[handleNotification] Noob 2, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" - } - call.state.updateNotification(notificationId, notification) - startForegroundWithServiceType( - notificationId, + + TRIGGER_ONGOING_CALL -> + startForegroundForCall( + call, + callId, notification, + NotificationType.Ongoing, trigger, - permissionManager.getServiceType(baseContext, trigger), ) - CallServiceHandleNotificationResult.START - } - TRIGGER_OUTGOING_CALL -> { - serviceState.currentCallId = callId - val notificationId = call.state.notificationId - ?: callId.getNotificationId(NotificationType.Outgoing) - logger.d { - "[handleNotification] Noob 3, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" - } - call.state.updateNotification(notificationId, notification) - startForegroundWithServiceType( - notificationId, + + TRIGGER_OUTGOING_CALL -> + startForegroundForCall( + call, + callId, notification, + NotificationType.Outgoing, trigger, - permissionManager.getServiceType(baseContext, trigger), ) - CallServiceHandleNotificationResult.START - } - else -> { - serviceState.currentCallId = callId - val notificationId = call.state.notificationId - ?: callId.hashCode() // instead get notification from call object - logger.d { - "[handleNotification] Noob 4, trigger: $trigger, call.state.notificationId: ${call.state.notificationId}, notificationId: $notificationId, hashcode: ${hashCode()}" - } - call.state.updateNotification(notificationId, notification) - startForegroundWithServiceType( - notificationId, + else -> + startForegroundForCall( + call, + callId, notification, + null, trigger, - permissionManager.getServiceType(baseContext, trigger), ) - CallServiceHandleNotificationResult.START + } + } + + private fun handleNullNotification( + trigger: String, + callId: StreamCallId, + call: Call, + fallbackNotificationId: Int, + ): CallServiceHandleNotificationResult { + if (trigger != TRIGGER_REMOVE_INCOMING_CALL) { + logger.e { + "[handleNullNotification], Could not get notification for trigger: $trigger, callId: ${callId.id}" } + return CallServiceHandleNotificationResult.REDELIVER + } + + val serviceStartedForThisCall = serviceState.currentCallId?.id == callId.id + + return if (serviceStartedForThisCall) { + removeIncomingCall(fallbackNotificationId, call) + CallServiceHandleNotificationResult.START + } else { + /** + * Means we only posted notification for this call, Service was never started for this call + */ + val notificationId = + call.state.notificationId + ?: callId.getNotificationId(NotificationType.Incoming) + + NotificationManagerCompat.from(this).cancel(notificationId) + CallServiceHandleNotificationResult.START_NO_CHANGE + } + } + + private fun startForegroundForCall( + call: Call, + callId: StreamCallId, + notification: Notification, + type: NotificationType?, + trigger: String, + ): CallServiceHandleNotificationResult { + val resolvedNotificationId = + call.state.notificationId + ?: type?.let { callId.getNotificationId(it) } + ?: callId.hashCode() + + logger.d { + "[startForegroundForCall] trigger=$trigger, " + + "call.state.notificationId=${call.state.notificationId}, " + + "notificationId=$resolvedNotificationId, " + + "hashcode=${hashCode()}" + } + + call.state.updateNotification(resolvedNotificationId, notification) + + startForegroundWithServiceType( + resolvedNotificationId, + notification, + trigger, + permissionManager.getServiceType(baseContext, trigger), + ) + + return CallServiceHandleNotificationResult.START + } + + private fun logHandleStart( + trigger: String, + call: Call, + notificationId: Int, + ) { + logger.d { + "[logHandleStart] trigger=$trigger, " + + "call.state.notificationId=${call.state.notificationId}, " + + "notificationId=$notificationId, " + + "hashcode=${hashCode()}" } } @@ -455,7 +489,9 @@ internal open class CallService : Service() { } private fun removeIncomingCall(notificationId: Int, call: Call) { - logger.d { "[removeIncomingCall] notificationId: $notificationId" } + logger.d { + "[removeIncomingCall] notificationId: $notificationId, ${this.javaClass.name} hashcode: ${hashCode()}" + } if (serviceState.currentCallId?.cid == call.cid) { stopServiceGracefully() } @@ -467,7 +503,7 @@ internal open class CallService : Service() { CallServiceRingingStateObserver(call, serviceState.soundPlayer, streamVideo, serviceScope) .observe { stopServiceGracefully() } - CallServiceEventObserver(call, streamVideo) + CallServiceEventObserver(call, streamVideo, serviceScope) .observe( onServiceStop = { stopServiceGracefully() }, onRemoveIncoming = { @@ -517,9 +553,10 @@ internal open class CallService : Service() { override fun onDestroy() { logger.d { - "Noob, [onDestroy], Callservice hashcode: ${hashCode()}, call_cid: ${serviceState.currentCallId?.cid}" + "[onDestroy], hashcode: ${hashCode()}, call_cid: ${serviceState.currentCallId?.cid}" } serviceState.soundPlayer?.cleanUpAudioResources() + serviceScope.cancel() super.onDestroy() } @@ -564,7 +601,7 @@ internal open class CallService : Service() { } private fun internalStopServiceGracefully() { - logger.d { "[internalStopServiceGracefully]" } + logger.d { "[internalStopServiceGracefully] hashcode: ${hashCode()}" } stopForeground(STOP_FOREGROUND_REMOVE) serviceState.currentCallId?.let { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt index 12a49eba77..465493065b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt @@ -51,12 +51,10 @@ internal class CallServiceNotificationManager { } fun cancelNotifications(service: Service, callId: StreamCallId) { - logger.d { "[cancelNotifications], notificationId: " } - val notificationManager = NotificationManagerCompat.from(service) callId.let { - logger.d { "[cancelNotifications], 1: notificationId via hashcode: ${it.hashCode()}" } + logger.d { "[cancelNotifications], notificationId via hashcode: ${it.hashCode()}" } notificationManager.cancel(it.hashCode()) } @@ -65,7 +63,7 @@ internal class CallServiceNotificationManager { callId.id, ) call?.state?.notificationId?.let { notificationId -> - logger.d { "[cancelNotifications], 2: notificationId: $notificationId" } + logger.d { "[cancelNotifications], notificationId from call.state: $notificationId" } notificationManager.cancel(notificationId) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt index a38d0e18ae..4fffd8bd59 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt @@ -25,12 +25,14 @@ import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.utils.toUser +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch internal class CallServiceEventObserver( private val call: Call, private val streamVideo: StreamVideoClient, + private val scope: CoroutineScope, ) { private val logger by taggedLogger("CallEventObserver") @@ -47,7 +49,7 @@ internal class CallServiceEventObserver( * Observes call events (accepted, rejected, ended, missed). */ private fun observeCallEvents(onServiceStop: () -> Unit, onRemoveIncoming: () -> Unit) { - call.scope.launch { + scope.launch { call.events.collect { event -> handleCallEvent(event, onServiceStop, onRemoveIncoming) } From 1800f99c13b96de93140854ed0226191810da573 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 2 Jan 2026 19:02:01 +0530 Subject: [PATCH 39/42] refactor: Improve incoming call and service stop logic Refactors the `IncomingCallPresenter` to simplify its logic by extracting functionality into smaller, more descriptive private functions. This improves readability and makes the different paths for handling incoming calls clearer. The `buildStopIntent` method in `ServiceIntentBuilder` is updated to only return an intent if the corresponding service is actually running, preventing unnecessary stop commands. Additionally, this commit: - Removes the handling of `LocalCallMissedEvent` from `CallServiceEventObserver` as it's no longer needed. - Enhances tests for `IncomingCallPresenter` and `ServiceIntentBuilder` to cover the new logic and edge cases. --- .../internal/service/IncomingCallPresenter.kt | 168 ++++++++------- .../service/IncomingCallPresenterTest.kt | 199 +++++++++++------- .../service/ServiceIntentBuilderTest.kt | 101 +++++++-- .../observers/CallServiceEventObserverTest.kt | 29 +-- 4 files changed, 303 insertions(+), 194 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt index dff1bf29dd..3e78b9d20c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt @@ -38,98 +38,120 @@ internal class IncomingCallPresenter(private val serviceIntentBuilder: ServiceIn callServiceConfiguration: CallServiceConfig, notification: Notification?, ): ShowIncomingCallResult { - logger.d { - "[showIncomingCall] callId: ${callId.id}, callDisplayName: $callDisplayName, notification: ${notification != null}" - } - val hasActiveCall = StreamVideo.instanceOrNull()?.state?.activeCall?.value != null - logger.d { "[showIncomingCall] hasActiveCall: $hasActiveCall" } - var showIncomingCallResult = ShowIncomingCallResult.ERROR + logInput(callId, callDisplayName, notification) + + val startParams = StartServiceParam( + callId = callId, + trigger = TRIGGER_INCOMING_CALL, + callDisplayName = callDisplayName, + callServiceConfiguration = callServiceConfiguration, + ) + + var result = ShowIncomingCallResult.ERROR safeCallWithResult { - if (!hasActiveCall) { - logger.d { "[showIncomingCall] Starting foreground service" } - ContextCompat.startForegroundService( - context, - serviceIntentBuilder.buildStartIntent( - context, - StartServiceParam( - callId, - TRIGGER_INCOMING_CALL, - callDisplayName, - callServiceConfiguration, - ), - ), - ) - - showIncomingCallResult = ShowIncomingCallResult.FG_SERVICE + if (hasNoActiveCall()) { + startForegroundService(context, startParams) + result = ShowIncomingCallResult.FG_SERVICE } else { - /** - * If already same service is running either FG or background then we skip it & show normal notification - */ - - val startServiceParam = StartServiceParam( - callId, - TRIGGER_INCOMING_CALL, - callDisplayName, - callServiceConfiguration, - ) - val serviceClass = startServiceParam.callServiceConfiguration.serviceClass - if (ServiceIntentBuilder().isServiceRunning(context, serviceClass)) { - showNotification(context, notification, callId, null) - } else { - logger.d { "[showIncomingCall] Starting regular service" } - context.startService( - serviceIntentBuilder.buildStartIntent( - context, - startServiceParam, - ), - ) - showIncomingCallResult = ShowIncomingCallResult.SERVICE - } + result = handleWhileActiveCall(context, startParams, notification) } - }.onError { - // Show notification + }.onError { error -> logger.d { "[showIncomingCall] onError" } - showNotification(context, notification, callId, it) + result = showNotification(context, notification, callId, error) } - return showIncomingCallResult + return result } - private fun showNotification( + // ---------------------------------- + // Decision branches + // ---------------------------------- + + private fun handleWhileActiveCall( context: Context, + startParams: StartServiceParam, notification: Notification?, - callId: StreamCallId, - error: Any?, ): ShowIncomingCallResult { - var showIncomingCallResult = ShowIncomingCallResult.ERROR - if (error != null) { - logger.e { "[showNotification] Could not start service, showing notification only: $error" } + val serviceClass = startParams.callServiceConfiguration.serviceClass + + return if (serviceIntentBuilder.isServiceRunning(context, serviceClass)) { + showNotification(context, notification, startParams.callId, null) } else { - logger.e { "[showNotification] Could not start service, showing notification only" } + logger.d { "[showIncomingCall] Starting regular service" } + context.startService( + serviceIntentBuilder.buildStartIntent(context, startParams), + ) + ShowIncomingCallResult.SERVICE } - val hasPermission = ContextCompat.checkSelfPermission( + } + + // ---------------------------------- + // Side effects + // ---------------------------------- + + private fun startForegroundService( + context: Context, + params: StartServiceParam, + ) { + logger.d { "[showIncomingCall] Starting foreground service" } + ContextCompat.startForegroundService( context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - logger.i { "Has permission: $hasPermission" } - logger.i { "Notification: $notification" } - if (hasPermission && notification != null) { - logger.d { - "[showIncomingCall] Showing notification fallback with ID: ${callId.getNotificationId( - NotificationType.Incoming, - )}" + serviceIntentBuilder.buildStartIntent(context, params), + ) + } + + private fun showNotification( + context: Context, + notification: Notification?, + callId: StreamCallId, + error: Any?, + ): ShowIncomingCallResult { + if (!hasNotificationPermission(context) || notification == null) { + logger.w { + "[showIncomingCall] Cannot show notification - " + + "permission=${hasNotificationPermission(context)}, " + + "notification=${notification != null}" } - StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher()?.notify( + return ShowIncomingCallResult.ERROR + } + + StreamVideo.instanceOrNull() + ?.getStreamNotificationDispatcher() + ?.notify( callId, callId.getNotificationId(NotificationType.Incoming), notification, ) - showIncomingCallResult = ShowIncomingCallResult.ONLY_NOTIFICATION - } else { - logger.w { - "[showIncomingCall] Cannot show notification - hasPermission: $hasPermission, notification: ${notification != null}" - } + + return ShowIncomingCallResult.ONLY_NOTIFICATION + } + + // ---------------------------------- + // State / helpers + // ---------------------------------- + + private fun hasNoActiveCall(): Boolean { + val hasActiveCall = + StreamVideo.instanceOrNull()?.state?.activeCall?.value != null + logger.d { "[showIncomingCall] hasActiveCall: $hasActiveCall" } + return !hasActiveCall + } + + private fun hasNotificationPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + + private fun logInput( + callId: StreamCallId, + callDisplayName: String?, + notification: Notification?, + ) { + logger.d { + "[showIncomingCall] callId=${callId.id}, " + + "callDisplayName=$callDisplayName, " + + "notification=${notification != null}" } - return showIncomingCallResult } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt index d031cd6acb..08de90db17 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt @@ -19,14 +19,16 @@ package io.getstream.video.android.core.notifications.internal.service import android.Manifest import android.app.Notification import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.core.content.ContextCompat +import io.getstream.video.android.core.ClientState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient -import io.getstream.video.android.core.notifications.NotificationType -import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher +import io.getstream.video.android.core.notifications.dispatchers.DefaultNotificationDispatcher import io.getstream.video.android.model.StreamCallId +import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -34,12 +36,12 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify import org.junit.After -import org.junit.Assert import org.junit.Before import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import kotlin.test.Test +import kotlin.test.assertEquals @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) @@ -49,16 +51,19 @@ class IncomingCallPresenterTest { private lateinit var serviceIntentBuilder: ServiceIntentBuilder private lateinit var presenter: IncomingCallPresenter private lateinit var callServiceConfig: CallServiceConfig - private lateinit var callId: StreamCallId private lateinit var notification: Notification private lateinit var streamVideoClient: StreamVideoClient + private val callId = StreamCallId("default", "123", "default:123") + private val serviceClass = CallService::class.java + private val config = CallServiceConfig(serviceClass = serviceClass) + @Before fun setup() { + MockKAnnotations.init(this, relaxed = true) context = mockk(relaxed = true) serviceIntentBuilder = mockk(relaxed = true) callServiceConfig = CallServiceConfig(enableTelecom = true) - callId = StreamCallId("default", "123") notification = mockk(relaxed = true) streamVideoClient = mockk(relaxed = true) @@ -76,121 +81,165 @@ class IncomingCallPresenterTest { unmockkAll() } - // region 1️⃣ Foreground service branch (no active call) - @Test - fun `when no active call should start foreground service and return FG_SERVICE`() { - // Given no active call - every { StreamVideo.instanceOrNull()?.state?.activeCall?.value } returns null + fun `returns FG_SERVICE when no active call`() { + // given + mockNoActiveCall() + every { - ContextCompat.startForegroundService(context, any()) - } returns mockk(relaxed = true) + serviceIntentBuilder.buildStartIntent(any(), any()) + } returns Intent() - // When + // when val result = presenter.showIncomingCall( context = context, callId = callId, - callDisplayName = "Caller", - callServiceConfiguration = callServiceConfig, - notification = notification, + callDisplayName = "Test", + callServiceConfiguration = config, + notification = mockk(), ) - // Then - verify { ContextCompat.startForegroundService(context, any()) } - Assert.assertEquals(ShowIncomingCallResult.FG_SERVICE, result) - } - - // endregion + // then + assertEquals(ShowIncomingCallResult.FG_SERVICE, result) - // region 2️⃣ Normal service branch (active call exists) + verify { + ContextCompat.startForegroundService(context, any()) + } + } @Test - fun `when active call exists should start normal service and return SERVICE`() { - every { StreamVideo.instanceOrNull()?.state?.activeCall?.value } returns mockk(relaxed = true) + fun `returns ONLY_NOTIFICATION when active call and service already running`() { + // given + mockActiveCall() + every { serviceIntentBuilder.isServiceRunning(any(), any()) } returns true + mockNotificationPermission(granted = true) - val intent = mockk(relaxed = true) - every { serviceIntentBuilder.buildStartIntent(any(), any()) } returns intent + val dispatcher = mockk(relaxed = true) + every { + StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher() + } returns dispatcher + // when val result = presenter.showIncomingCall( - context = context, - callId = callId, - callDisplayName = "TestCaller", - callServiceConfiguration = callServiceConfig, - notification = notification, + context, + callId, + "Test", + config, + mockk(), ) - verify { context.startService(any()) } - Assert.assertEquals(ShowIncomingCallResult.SERVICE, result) + // then + assertEquals(ShowIncomingCallResult.ONLY_NOTIFICATION, result) + + verify { + dispatcher.notify(any(), any(), any()) + } } - // endregion + @Test + fun `returns SERVICE when active call and service not running`() { + // given + mockActiveCall() + every { serviceIntentBuilder.isServiceRunning(any(), any()) } returns false - // region 3️⃣ Error branch (service start fails → fallback to notification) + every { + serviceIntentBuilder.buildStartIntent(any(), any()) + } returns Intent() - @Test - fun `when service start fails and permission granted should show notification`() { - every { streamVideoClient.state.activeCall.value } returns null + // when + val result = presenter.showIncomingCall( + context, + callId, + "Test", + config, + mockk(), + ) - val notificationDispatcher = mockk(relaxed = true) - every { streamVideoClient.getStreamNotificationDispatcher() } returns notificationDispatcher + // then + assertEquals(ShowIncomingCallResult.SERVICE, result) + + verify { + context.startService(any()) + } + } + + @Test + fun `returns ONLY_NOTIFICATION on exception but has notification`() { + // given + mockNoActiveCall() + mockNotificationPermission(granted = true) - // Force exception inside safeCallWithResult every { - ContextCompat.startForegroundService(context, any()) - } throws RuntimeException("service fail") + serviceIntentBuilder.buildStartIntent(any(), any()) + } throws RuntimeException("Boom") - // Mock permission granted + val dispatcher = mockk(relaxed = true) every { - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) - } returns PackageManager.PERMISSION_GRANTED + StreamVideo.instanceOrNull()?.getStreamNotificationDispatcher() + } returns dispatcher + // when val result = presenter.showIncomingCall( context, callId, - "Caller", - callServiceConfig, - notification, + "Test", + config, + mockk(), ) + // then + assertEquals(ShowIncomingCallResult.ONLY_NOTIFICATION, result) + verify { - notificationDispatcher.notify( - callId, - callId.getNotificationId(NotificationType.Incoming), - notification, - ) + dispatcher.notify(any(), any(), any()) } - - Assert.assertEquals(ShowIncomingCallResult.ONLY_NOTIFICATION, result) } - // endregion - - // region 4️⃣ Error branch (service start fails, no permission) - @Test - fun `when service start fails and no permission should return ERROR`() { - every { streamVideoClient.state.activeCall.value } returns null + fun `returns ERROR when notification permission missing`() { + // given + mockNoActiveCall() + mockNotificationPermission(granted = false) every { - ContextCompat.startForegroundService(context, any()) - } throws RuntimeException("fail") - - every { - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) - } returns PackageManager.PERMISSION_DENIED + serviceIntentBuilder.buildStartIntent(any(), any()) + } throws RuntimeException("Boom") + // when val result = presenter.showIncomingCall( context, callId, - "Caller", - callServiceConfig, - notification, + "Test", + config, + mockk(), ) - verify(exactly = 0) { - streamVideoClient.getStreamNotificationDispatcher().notify(any(), any(), any()) + // then + assertEquals(ShowIncomingCallResult.ERROR, result) + } + + // ---------- helpers ---------- + + private fun mockNoActiveCall() { + val state = mockk { + every { activeCall.value } returns null } + every { StreamVideo.instanceOrNull()?.state } returns state + } + + private fun mockActiveCall() { + val state = mockk { + every { activeCall.value } returns mockk() + } + every { StreamVideo.instanceOrNull()?.state } returns state + } - Assert.assertEquals(ShowIncomingCallResult.ERROR, result) + private fun mockNotificationPermission(granted: Boolean) { + every { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) + } returns if (granted) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt index 7a50946b5b..62188bda45 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt @@ -17,6 +17,7 @@ package io.getstream.video.android.core.notifications.internal.service import android.content.Context +import io.getstream.video.android.core.Call import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_CID import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_DISPLAY_NAME import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL @@ -28,6 +29,9 @@ import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.streamCallDisplayName import io.getstream.video.android.model.streamCallId import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -37,6 +41,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) class ServiceIntentBuilderTest { @@ -136,33 +141,93 @@ class ServiceIntentBuilderTest { } @Test - fun `buildStopIntent creates correct intent`() { - // When - val intent = ServiceIntentBuilder().buildStopIntent(context, StopServiceParam()) + fun `buildStopIntent returns null when service is not running`() { + val builder = spyk(ServiceIntentBuilder()) + + // given + val serviceClass = CallService::class.java + val config = CallServiceConfig(serviceClass = serviceClass) + val param = StopServiceParam( + callServiceConfiguration = config, + call = null, + ) - // Then - assertNotNull(intent) - assertEquals(CallService::class.java.name, intent?.component?.className) + every { + builder.isServiceRunning(context, serviceClass) + } returns false + + // when + val intent = builder.buildStopIntent(context, param) + + // then + assertNull(intent) } -// @Test - fun `buildStopIntent uses custom service class from configuration`() { - // Given - val customConfig = CallServiceConfig( - serviceClass = LivestreamCallService::class.java, + fun `buildStopIntent returns intent with stop flag when service is running`() { + // given + val builder = spyk(ServiceIntentBuilder()) + + // given + val serviceClass = CallService::class.java + val config = CallServiceConfig(serviceClass = serviceClass) + val param = StopServiceParam( + callServiceConfiguration = config, + call = null, ) - // When - val intent = ServiceIntentBuilder().buildStopIntent( - context, - StopServiceParam(callServiceConfiguration = customConfig), + every { + builder.isServiceRunning(context, serviceClass) + } returns true + + // when + val intent = builder.buildStopIntent(context, param) + + // then + assertNotNull(intent) + assertEquals(serviceClass.name, intent!!.component?.className) + assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false)) + } + + @Test + fun `buildStopIntent attaches call cid when call is present`() { + // given + val builder = spyk(ServiceIntentBuilder()) + + // given + val serviceClass = CallService::class.java + val config = CallServiceConfig(serviceClass = serviceClass) + + val call = mockk { + every { type } returns "default" + every { id } returns "123" + every { cid } returns "default:123" + } + + val param = StopServiceParam( + callServiceConfiguration = config, + call = call, ) - // Then + every { + builder.isServiceRunning(context, serviceClass) + } returns true + + // when + val intent = builder.buildStopIntent(context, param) + + // then assertNotNull(intent) - // Note: The actual implementation has some complex logic for running services - // so we just verify the intent is created + + val streamCallId = + intent!!.getParcelableExtra(INTENT_EXTRA_CALL_CID) + + assertNotNull(streamCallId) + assertEquals("default", streamCallId!!.type) + assertEquals("123", streamCallId.id) + assertEquals("default:123", streamCallId.cid) + + assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false)) } @Test diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt index ee5757b24c..ffc2aecf87 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt @@ -18,7 +18,6 @@ package io.getstream.video.android.core.notifications.internal.service.observers import io.getstream.android.video.generated.models.CallEndedEvent import io.getstream.android.video.generated.models.LocalCallAcceptedPostEvent -import io.getstream.android.video.generated.models.LocalCallMissedEvent import io.getstream.android.video.generated.models.LocalCallRejectedPostEvent import io.getstream.android.video.generated.models.VideoEvent import io.getstream.video.android.core.Call @@ -106,7 +105,7 @@ class CallServiceEventObserverTest { onRemoveIncomingInvoked = true } - observer = CallServiceEventObserver(call, streamVideo) + observer = CallServiceEventObserver(call, streamVideo, testScope) } @After @@ -196,32 +195,6 @@ class CallServiceEventObserverTest { assertTrue(onServiceStopInvoked) } - @Test - fun `missed call with active call removes incoming`() = runTest { - activeCallFlow.value = mockk() - - observer.observe(onServiceStop, onRemoveIncoming) - - eventsFlow.emit(LocalCallMissedEvent("", "")) - - advanceUntilIdle() - assertTrue(onRemoveIncomingInvoked) - assertFalse(onServiceStopInvoked) - } - - @Test - fun `missed call with no active call stops service`() = runTest { - activeCallFlow.value = null - - observer.observe(onServiceStop, onRemoveIncoming) - - eventsFlow.emit(LocalCallMissedEvent("", "")) - - advanceUntilIdle() - - assertTrue(onServiceStopInvoked) - } - @Test fun `connection failure for ringing call cleans up`() = runTest { ringingCallFlow.value = call From 2d18d42d2725184cceb57ee022dd28fa9d62d0f8 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 7 Jan 2026 14:21:46 +0530 Subject: [PATCH 40/42] chore: Improve foreground service stability and test coverage This commit introduces several improvements to enhance the stability of the foreground `CallService` and increases test coverage across various components. Key changes include: - A `Debouncer` was added to `CallService` to gracefully handle service stop requests, preventing premature termination. - Introduced `debouncer.cancel()` in `CallService.onDestroy()` to clean up pending actions. - The `ClientState.setActiveCall` logic now uses a constant for the service transition delay. - Added and fixed tests for `CallServiceLifecycleManager`, `CallServiceNotificationManager`, and `ServiceIntentBuilder` to ensure more reliable behavior. - Minor fixes in logging and code cleanup. --- .../video/android/core/ClientState.kt | 16 ++++++------- .../core/notifications/internal/Debouncer.kt | 5 ++++ .../receivers/LeaveCallBroadcastReceiver.kt | 2 +- .../internal/service/CallService.kt | 13 +++++----- .../service/ServiceIntentBuilderTest.kt | 24 ------------------- .../CallServiceLifecycleManagerTest.kt | 2 ++ .../CallServiceNotificationManagerTest.kt | 9 +------ .../service/models/ServiceStateTest.kt | 1 - 8 files changed, 23 insertions(+), 49 deletions(-) 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 b1bce3f78d..e6e94162c0 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 @@ -161,34 +161,32 @@ class ClientState(private val client: StreamVideo) { _connection.value = ConnectionState.Failed(error) } + /** + * Transition incoming/outgoing call to active on the same service + */ fun setActiveCall(call: Call) { this._activeCall.value = call - /** - * Transition incoming/outgoing call to active on the same service - */ + val serviceTransitionDelayMs = 500L val ringingState = call.state.ringingState.value when (ringingState) { is RingingState.Incoming -> { call.scope.launch { transitionToAcceptCall(call) - delay(500L) + delay(serviceTransitionDelayMs) maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) } } is RingingState.Outgoing -> { call.scope.launch { transitionToAcceptCall(call) - delay(500L) + delay(serviceTransitionDelayMs) maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) } } else -> { removeRingingCall(call) call.scope.launch { - /** - * Temporary fix: `maybeStartForegroundService` is called just before this code, which can stop the service - */ - delay(500L) + delay(serviceTransitionDelayMs) maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt index 6eba3b6d1e..dc83b54c8d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt @@ -32,4 +32,9 @@ internal class Debouncer { handler.postDelayed(it, delayMs) } } + + fun cancel() { + runnable?.let { handler.removeCallbacks(it) } + runnable = null + } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt index 6453654fa3..8a6d3a9247 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt @@ -39,7 +39,7 @@ internal class LeaveCallBroadcastReceiver : GenericCallActionBroadcastReceiver() call.leave("LeaveCallBroadcastReceiver") val notificationId = intent.getIntExtra(INTENT_EXTRA_NOTIFICATION_ID, 0) - logger.d { "[onReceive], notificationId: notificationId" } + logger.d { "[onReceive], notificationId: $notificationId" } NotificationManagerCompat.from(context).cancel(notificationId) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 21e3a342bc..c327489974 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -77,6 +77,8 @@ internal open class CallService : Service() { * we ensure enough time has passed for the system to process the notification removal. */ internal val debouncer = Debouncer() + internal val serviceScope: CoroutineScope = + CoroutineScope(Dispatchers.IO.limitedParallelism(1) + handler + SupervisorJob()) open val serviceType: Int @SuppressLint("InlinedApi") @@ -100,7 +102,7 @@ internal open class CallService : Service() { const val TRIGGER_ONGOING_CALL = "ongoing_call" const val EXTRA_STOP_SERVICE = "io.getstream.video.android.core.stop_service" - const val SERVICE_DESTROY_THRESHOLD_TIME_SECONDS = 2L + const val SERVICE_DESTROY_THRESHOLD_TIME_MS = 2_000L const val SERVICE_DESTROY_THROTTLE_TIME_MS = 1_000L private val logger by taggedLogger("CallService") @@ -108,8 +110,6 @@ internal open class CallService : Service() { val handler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "[CallService#Scope] Uncaught exception: $exception" } } - val serviceScope: CoroutineScope = - CoroutineScope(Dispatchers.IO.limitedParallelism(1) + handler + SupervisorJob()) } override fun onCreate() { @@ -556,17 +556,18 @@ internal open class CallService : Service() { "[onDestroy], hashcode: ${hashCode()}, call_cid: ${serviceState.currentCallId?.cid}" } serviceState.soundPlayer?.cleanUpAudioResources() + debouncer.cancel() serviceScope.cancel() super.onDestroy() } - fun printLastStackFrames(count: Int = 10) { + private fun debugPrintLastStackFrames(count: Int = 10) { val stack = Thread.currentThread().stackTrace logger.d { stack.takeLast(count).joinToString("\n") } } private fun streamDefaultNotificationHandler(): StreamDefaultNotificationHandler? { - val client = StreamVideo.instanceOrNull() as StreamVideoClient + val client = StreamVideo.instanceOrNull() as? StreamVideoClient ?: return null val handler = client.streamNotificationManager.notificationConfig.notificationHandler as? StreamDefaultNotificationHandler return handler @@ -588,7 +589,7 @@ internal open class CallService : Service() { val currentTime = OffsetDateTime.now() val duration = Duration.between(startTime, currentTime) val differenceInSeconds = duration.seconds.absoluteValue - val debouncerThresholdTime = SERVICE_DESTROY_THRESHOLD_TIME_SECONDS + val debouncerThresholdTime = SERVICE_DESTROY_THRESHOLD_TIME_MS logger.d { "[stopServiceGracefully] differenceInSeconds: $differenceInSeconds" } if (differenceInSeconds >= debouncerThresholdTime) { internalStopServiceGracefully() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt index 62188bda45..ccedff6de5 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilderTest.kt @@ -60,8 +60,6 @@ class ServiceIntentBuilderTest { @Test fun `buildStartIntent creates correct intent for outgoing call`() { - // When - val intent = ServiceIntentBuilder().buildStartIntent( context, StartServiceParam( @@ -70,7 +68,6 @@ class ServiceIntentBuilderTest { ), ) - // Then assertEquals(CallService::class.java.name, intent.component?.className) assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) assertEquals(TRIGGER_OUTGOING_CALL, intent.getStringExtra(TRIGGER_KEY)) @@ -79,7 +76,6 @@ class ServiceIntentBuilderTest { @Test fun `buildStartIntent creates correct intent for ongoing call`() { - // When val intent = ServiceIntentBuilder().buildStartIntent( context, StartServiceParam( @@ -88,7 +84,6 @@ class ServiceIntentBuilderTest { ), ) - // Then assertEquals(CallService::class.java.name, intent.component?.className) assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) assertEquals(TRIGGER_ONGOING_CALL, intent.getStringExtra(TRIGGER_KEY)) @@ -96,13 +91,11 @@ class ServiceIntentBuilderTest { @Test fun `buildStartIntent creates correct intent for remove incoming call`() { - // When val intent = ServiceIntentBuilder().buildStartIntent( context = context, StartServiceParam(testCallId, TRIGGER_REMOVE_INCOMING_CALL), ) - // Then assertEquals(CallService::class.java.name, intent.component?.className) assertEquals(testCallId, intent.streamCallId(INTENT_EXTRA_CALL_CID)) assertEquals(TRIGGER_REMOVE_INCOMING_CALL, intent.getStringExtra(TRIGGER_KEY)) @@ -121,12 +114,10 @@ class ServiceIntentBuilderTest { @Test fun `buildStartIntent uses custom service class from configuration`() { - // Given val customConfig = CallServiceConfig( serviceClass = LivestreamCallService::class.java, ) - // When val intent = ServiceIntentBuilder().buildStartIntent( context, StartServiceParam( @@ -136,7 +127,6 @@ class ServiceIntentBuilderTest { ), ) - // Then assertEquals(LivestreamCallService::class.java.name, intent.component?.className) } @@ -144,7 +134,6 @@ class ServiceIntentBuilderTest { fun `buildStopIntent returns null when service is not running`() { val builder = spyk(ServiceIntentBuilder()) - // given val serviceClass = CallService::class.java val config = CallServiceConfig(serviceClass = serviceClass) val param = StopServiceParam( @@ -156,19 +145,15 @@ class ServiceIntentBuilderTest { builder.isServiceRunning(context, serviceClass) } returns false - // when val intent = builder.buildStopIntent(context, param) - // then assertNull(intent) } @Test fun `buildStopIntent returns intent with stop flag when service is running`() { - // given val builder = spyk(ServiceIntentBuilder()) - // given val serviceClass = CallService::class.java val config = CallServiceConfig(serviceClass = serviceClass) val param = StopServiceParam( @@ -180,10 +165,8 @@ class ServiceIntentBuilderTest { builder.isServiceRunning(context, serviceClass) } returns true - // when val intent = builder.buildStopIntent(context, param) - // then assertNotNull(intent) assertEquals(serviceClass.name, intent!!.component?.className) assertTrue(intent.getBooleanExtra(CallService.EXTRA_STOP_SERVICE, false)) @@ -191,10 +174,8 @@ class ServiceIntentBuilderTest { @Test fun `buildStopIntent attaches call cid when call is present`() { - // given val builder = spyk(ServiceIntentBuilder()) - // given val serviceClass = CallService::class.java val config = CallServiceConfig(serviceClass = serviceClass) @@ -213,10 +194,8 @@ class ServiceIntentBuilderTest { builder.isServiceRunning(context, serviceClass) } returns true - // when val intent = builder.buildStopIntent(context, param) - // then assertNotNull(intent) val streamCallId = @@ -232,13 +211,11 @@ class ServiceIntentBuilderTest { @Test fun `service respects configuration for different call types`() { - // Given val livestreamConfig = CallServiceConfig( serviceClass = LivestreamCallService::class.java, runCallServiceInForeground = true, ) - // When val intent = ServiceIntentBuilder().buildStartIntent( context, StartServiceParam( @@ -248,7 +225,6 @@ class ServiceIntentBuilderTest { ), ) - // Then assertEquals(LivestreamCallService::class.java.name, intent.component?.className) } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt index e513dadb5b..8800220544 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt @@ -188,6 +188,8 @@ class CallServiceLifecycleManagerTest { @Test fun `endCall does nothing when callId is null`() = testScope.runTest { + mockkObject(StreamVideo.Companion) + every { StreamVideo.instanceOrNull() } returns streamVideo sut.endCall(this, null) advanceUntilIdle() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt index 3300a04899..248160a4a4 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt @@ -101,7 +101,7 @@ class CallServiceNotificationManagerTest { @Test fun `justNotify does nothing when permission is denied`() { every { - ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + ContextCompat.checkSelfPermission(any(), Manifest.permission.POST_NOTIFICATIONS) } returns PackageManager.PERMISSION_DENIED val streamVideo = mockk(relaxed = true) @@ -182,13 +182,6 @@ class CallServiceNotificationManagerTest { } } -// @Test - fun `cancelNotifications is safe when callId is null`() { -// sut.cancelNotifications(service, null) - -// verify { notificationManagerCompat wasNot Called } - } - @Test fun `cancelNotifications is safe when StreamVideo is null`() { every { StreamVideo.instanceOrNull() } returns null diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt index fe2b7638ed..8956776bbb 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt @@ -105,7 +105,6 @@ class ServiceStateTest { */ @Test fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() { - println("[unregisterToggleCameraBroadcastReceiver unregisters receiver]") val sut = ServiceState() sut.registerToggleCameraBroadcastReceiver(service, testScope) sut.unregisterToggleCameraBroadcastReceiver(service) From d1f3843512849d1477f7511ad232ca2ea2eeccf8 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 7 Jan 2026 15:29:08 +0530 Subject: [PATCH 41/42] Fix: Make `CallService` safer against null `StreamVideo` instance Makes `CallService` more robust by handling cases where `StreamVideo.instanceOrNull()` might return null. The `notificationConfig()` function now returns a nullable `NotificationConfig?` to prevent potential crashes. This change is propagated to `maybeHandleMediaIntent` to ensure it safely handles the nullable configuration. --- .../notifications/internal/service/CallService.kt | 14 +++++++------- .../managers/CallServiceNotificationManagerTest.kt | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index c327489974..2eff2d80e1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -434,10 +434,10 @@ internal open class CallService : Service() { private fun maybeHandleMediaIntent(intent: Intent?, callId: StreamCallId?) = safeCall { val handler = streamDefaultNotificationHandler() if (handler != null && callId != null) { - val isMediaNotification = notificationConfig().mediaNotificationCallTypes.contains( + val isMediaNotification = notificationConfig()?.mediaNotificationCallTypes?.contains( callId.type, ) - if (isMediaNotification) { + if (isMediaNotification == true) { logger.d { "[maybeHandleMediaIntent] Handling media intent" } MediaButtonReceiver.handleIntent( handler.mediaSession(callId), @@ -573,8 +573,8 @@ internal open class CallService : Service() { return handler } - private fun notificationConfig(): NotificationConfig { - val client = StreamVideo.instanceOrNull() as StreamVideoClient + private fun notificationConfig(): NotificationConfig? { + val client = StreamVideo.instanceOrNull() as? StreamVideoClient ?: return null return client.streamNotificationManager.notificationConfig } @@ -589,12 +589,12 @@ internal open class CallService : Service() { val currentTime = OffsetDateTime.now() val duration = Duration.between(startTime, currentTime) val differenceInSeconds = duration.seconds.absoluteValue - val debouncerThresholdTime = SERVICE_DESTROY_THRESHOLD_TIME_MS + val debouncerThresholdTimeInSeconds = SERVICE_DESTROY_THRESHOLD_TIME_MS / 1_000 logger.d { "[stopServiceGracefully] differenceInSeconds: $differenceInSeconds" } - if (differenceInSeconds >= debouncerThresholdTime) { + if (differenceInSeconds >= debouncerThresholdTimeInSeconds) { internalStopServiceGracefully() } else { - debouncer.submit(debouncerThresholdTime) { + debouncer.submit(debouncerThresholdTimeInSeconds) { internalStopServiceGracefully() } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt index 248160a4a4..82294511d4 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt @@ -121,7 +121,7 @@ class CallServiceNotificationManagerTest { @Test fun `justNotify is safe when StreamVideo instance is null`() { every { - ActivityCompat.checkSelfPermission(any(), any()) + ContextCompat.checkSelfPermission(any(), any()) } returns PackageManager.PERMISSION_GRANTED every { StreamVideo.instanceOrNull() } returns null From b0c64055304eaee358e5f31523d8e28c21556e82 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Thu, 8 Jan 2026 15:39:25 +0530 Subject: [PATCH 42/42] Refactor: Move debugPrintLastStackFrames to AndroidUtils --- .../core/notifications/internal/service/CallService.kt | 5 ----- .../io/getstream/video/android/core/utils/AndroidUtils.kt | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt index 2eff2d80e1..4d98c1c4c7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallService.kt @@ -561,11 +561,6 @@ internal open class CallService : Service() { super.onDestroy() } - private fun debugPrintLastStackFrames(count: Int = 10) { - val stack = Thread.currentThread().stackTrace - logger.d { stack.takeLast(count).joinToString("\n") } - } - private fun streamDefaultNotificationHandler(): StreamDefaultNotificationHandler? { val client = StreamVideo.instanceOrNull() as? StreamVideoClient ?: return null val handler = diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt index c9512a6e28..fa632a5684 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidUtils.kt @@ -257,3 +257,9 @@ internal fun isAppInForeground(): Boolean { false // fallback if lifecycle isn't initialized yet } } + +internal fun debugPrintLastStackFrames(tag: String, count: Int = 10) { + val stack = Thread.currentThread().stackTrace + val message = stack.takeLast(count).joinToString("\n") + Log.d(tag, message) +}