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 1ab7e3a6a2..11cc5055f7 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 @@ -237,7 +237,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 cac7d0ab51..a8a2225420 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -3558,13 +3558,16 @@ public final class io/getstream/android/video/generated/models/ListTranscription } 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 synthetic fun (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 ()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; @@ -7778,6 +7781,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 (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 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 new file mode 100644 index 0000000000..240057076e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallAcceptedPostEvent.kt @@ -0,0 +1,59 @@ +/* + * 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 com.squareup.moshi.Json +import org.threeten.bp.OffsetDateTime + +/** + * This event is sent after [CallAcceptedEvent] is consumed in [io.getstream.video.android.core.CallState] + */ + +internal data class LocalCallAcceptedPostEvent ( + @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/LocalCallRejectedPostEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt new file mode 100644 index 0000000000..953db4ca94 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/LocalCallRejectedPostEvent.kt @@ -0,0 +1,62 @@ +/* + * 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 com.squareup.moshi.Json +import org.threeten.bp.OffsetDateTime + +/** + * This event is sent after [CallRejectedEvent] is consumed in [io.getstream.video.android.core.CallState] + */ + +internal data class LocalCallRejectedPostEvent ( + @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 87a209fecd..43b2b9a0b2 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,12 @@ public class Call( } response.onSuccess { + /** + * Because [CallState.updateFromResponse] reads the value of [ClientState.ringingCall] + */ + if (ring) { + client.state._ringingCall.value = this + } state.updateFromResponse(it) if (ring) { client.state.addRingingCall(this, RingingState.Outgoing()) @@ -929,7 +935,6 @@ public class Call( } private fun internalLeave(disconnectionReason: Throwable?, reason: String) = atomicLeave { - val callId = id monitorSubscriberPCStateJob?.cancel() monitorPublisherPCStateJob?.cancel() monitorPublisherPCStateJob = null @@ -1535,11 +1540,15 @@ public class Call( logger.d { "[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) } + /** + * Should outlive both the call scope and the service scope and needs to be executed in the client-level scope. + * Because the call scope or service scope may be cancelled or finished while the network request is still in flight + * TODO: Run this in clientImpl.scope internally + */ suspend fun reject(reason: RejectReason? = null): Result { logger.d { "[reject] #ringing; rejectReason: $reason, call_id:$id" } return clientImpl.reject(type, id, reason) 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 cc5f85bb4f..d62863b755 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 @@ -33,6 +34,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 @@ -63,7 +65,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.LocalCallAcceptedPostEvent import io.getstream.android.video.generated.models.LocalCallMissedEvent +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 @@ -109,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 @@ -122,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 @@ -700,6 +706,9 @@ public class CallState( internal val atomicNotification: AtomicReference = AtomicReference(null) + private var _notificationIdFlow = MutableStateFlow(null) + internal val notificationIdFlow: StateFlow = _notificationIdFlow + @InternalStreamVideoApi internal var jetpackTelecomRepository: JetpackTelecomRepository? = null @@ -707,6 +716,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() @@ -747,9 +757,23 @@ public class CallState( // Then leave the call on this device if (!acceptedOnThisDevice) call.leave("accepted-on-another-device") } + call.fireEvent( + LocalCallAcceptedPostEvent( + event.callCid, + event.createdAt, + event.call, + event.user, + event.type, + ), + ) + } + + is CallMissedEvent -> { + _createdBy.value = event.call.createdBy.toUser() } is CallRejectedEvent -> { + _createdBy.value = event.call.createdBy.toUser() val new = _rejectedBy.value.toMutableSet() new.add(event.user.id) _rejectedBy.value = new.toSet() @@ -763,6 +787,16 @@ public class CallState( } }, ) + call.fireEvent( + LocalCallRejectedPostEvent( + event.callCid, + event.createdAt, + event.call, + event.user, + event.type, + event.reason, + ), + ) } is LocalCallMissedEvent -> { @@ -774,6 +808,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) + } } } @@ -1645,9 +1690,15 @@ 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._notificationIdFlow.value = notificationId + this.atomicNotification.set(notification) + } } private fun MemberResponse.toMemberState(): MemberState { 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 ae773e6b1a..7455e64e37 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,15 +161,35 @@ 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 - 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 serviceTransitionDelayMs = 500L + val ringingState = call.state.ringingState.value + when (ringingState) { + is RingingState.Incoming -> { + call.scope.launch { + transitionToAcceptCall(call) + delay(serviceTransitionDelayMs) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } + } + is RingingState.Outgoing -> { + call.scope.launch { + transitionToAcceptCall(call) + delay(serviceTransitionDelayMs) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } + } + else -> { + removeRingingCall(call) + call.scope.launch { + delay(serviceTransitionDelayMs) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } + } } } @@ -185,6 +205,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) @@ -214,6 +237,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 { @@ -223,11 +249,19 @@ class ClientState(private val client: StreamVideo) { } } + internal 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` */ internal fun maybeStartForegroundService(call: Call, trigger: String) { + logger.d { "[maybeStartForegroundService], trigger: $trigger" } when (trigger) { CallService.TRIGGER_ONGOING_CALL -> serviceLauncher.showOnGoingCall( call, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt index fcdacaa103..257c781373 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt @@ -53,6 +53,7 @@ public interface StreamVideo : NotificationHandler { /** * Create a call with the given type and id. + * For GetStream Devs: Careful when invoking it during call/service cleanup as it will fill itself in [io.getstream.video.android.core.StreamVideoClient.calls] map */ public fun call(type: String, id: String = ""): Call 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 44f4feb993..48ec010f35 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,10 @@ 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 callCid: String, + val createdById: String? = null, +) : LocalEvent() { override fun getCallCID(): String { return callCid } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt index 2cbb242aba..269298d31a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt @@ -63,7 +63,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @Deprecated( - message = "This class is deprecated. Use the notification interceptors instead.", + message = "This class is deprecated. Use CompatibilityStreamNotificationHandler instead.", ) public open class DefaultNotificationHandler( private val application: Application, 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 caa556d75a..88725bf12d 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/dispatchers/NotificationDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/dispatchers/NotificationDispatcher.kt index 91b921ed9e..ac896cb68a 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/handlers/CompatibilityStreamNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/CompatibilityStreamNotificationHandler.kt index 86ecd142c7..c03c374bfc 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/CompatibilityStreamNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/CompatibilityStreamNotificationHandler.kt @@ -42,7 +42,68 @@ import io.getstream.video.android.model.User import kotlinx.coroutines.CoroutineScope /** - * This class is for compatibility with the old notification handler. + * Compatibility notification handler that bridges the new notification system + * with the legacy notification handler behavior. + * + * This class allows applications that previously relied on the old notification + * handler APIs to continue working while adopting the new notification + * interception mechanism. + * + * ## Usage + * + * ```kotlin + * notificationHandler = CompatibilityStreamNotificationHandler( + * context.app, + * initialNotificationBuilderInterceptor = + * object : StreamNotificationBuilderInterceptors() { + * + * override fun onBuildIncomingCallNotification( + * builder: NotificationCompat.Builder, + * fullScreenPendingIntent: PendingIntent, + * acceptCallPendingIntent: PendingIntent, + * rejectCallPendingIntent: PendingIntent, + * callerName: String?, + * shouldHaveContentIntent: Boolean + * ): NotificationCompat.Builder { + * builder.setContentTitle("My new and shiny incoming call") + * builder.setContentText( + * "This is my new content text that I've changed!!!" + * ) + * return builder + * } + * }, + * + * updateNotificationBuilderInterceptor = + * object : StreamNotificationUpdateInterceptors() { + * + * private suspend fun loadBitmapFromUrl(url: String): Bitmap = + * withContext(Dispatchers.IO) { + * URL(url).openStream().use { + * BitmapFactory.decodeStream(it) + * } + * } + * + * override suspend fun onUpdateIncomingCallNotification( + * builder: NotificationCompat.Builder, + * callDisplayName: String?, + * call: Call + * ): NotificationCompat.Builder { + * // For demonstration purposes, a delay can be added here. + * // delay(4_000) + * + * val userImageUrl = "MyImageURL" + * val bitmap = loadBitmapFromUrl(userImageUrl) + * + * builder.setContentText("My new notification with my image!!") + * builder.setLargeIcon(bitmap) + * return builder + * } + * } + * ) + * ``` + * + * @see StreamNotificationBuilderInterceptors + * @see StreamNotificationUpdateInterceptors */ @OptIn(ExperimentalStreamVideoApi::class) open class CompatibilityStreamNotificationHandler 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 599e24f747..5e67e72752 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,8 +52,8 @@ 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 import io.getstream.video.android.core.notifications.dispatchers.DefaultNotificationDispatcher import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher @@ -205,23 +205,24 @@ 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 + } 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(callId.cid, createdByUserId)) } } @@ -257,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 new file mode 100644 index 0000000000..e59dcecbbc --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/Debouncer.kt @@ -0,0 +1,40 @@ +/* + * 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 android.os.Handler +import android.os.Looper + +internal class Debouncer { + + private val handler = Handler(Looper.getMainLooper()) + private var runnable: Runnable? = null + + fun submit(delayMs: Long, action: () -> Unit) { + runnable?.let { handler.removeCallbacks(it) } + + runnable = Runnable { action() } + runnable?.let { + 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/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..62ba0eee7f --- /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 class 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/receivers/LeaveCallBroadcastReceiver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt index e3a4f04a12..a1ba9da074 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/AudioCallService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/AudioCallService.kt index 8d57866e7d..79342316de 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 68af40c1eb..c4932cb9c1 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,33 @@ 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.Debouncer +import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.EXTRA_STOP_SERVICE +import io.getstream.video.android.core.notifications.internal.service.controllers.ServiceStateController +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.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 import io.getstream.video.android.model.StreamCallId @@ -63,124 +53,46 @@ 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 +import org.threeten.bp.Duration +import org.threeten.bp.OffsetDateTime +import kotlin.math.absoluteValue /** * 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, - ) + internal val callServiceLifecycleManager = CallServiceLifecycleManager() + + internal val serviceStateController = ServiceStateController() /** - * 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 + * 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() + internal val serviceScope: CoroutineScope = + CoroutineScope(Dispatchers.IO.limitedParallelism(1) + handler + SupervisorJob()) - @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() - } - } + private val notificationManager = + CallServiceNotificationManager(serviceStateController, serviceScope) 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" } - } - 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 { @@ -192,16 +104,34 @@ 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" + + const val SERVICE_DESTROY_THRESHOLD_TIME_MS = 2_000L + const val SERVICE_DESTROY_THROTTLE_TIME_MS = 1_000L + + private val logger by taggedLogger("CallService") + + val handler = CoroutineExceptionHandler { _, exception -> + logger.e(exception) { "[CallService#Scope] Uncaught exception: $exception" } + } } - private fun shouldStopServiceFromIntent(intent: Intent?): Boolean { + override fun onCreate() { + super.onCreate() + serviceStateController.setStartTime(OffsetDateTime.now()) + } + + 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}" } + logger.d { + "[shouldStopService]: service hashcode: ${this.hashCode()}, serviceState.currentCallId!=null : ${serviceStateController.currentCallId != null}, serviceState.currentCallId == intentCallId && shouldStopService : ${serviceStateController.currentCallId == intentCallId && shouldStopService}" + } + + if (serviceStateController.state.value.currentCallId != null && serviceStateController.currentCallId == intentCallId && shouldStopService) { + logger.d { "[shouldStopService]: true, call_cid:${intentCallId?.cid}" } return true } - logger.d { "shouldStopServiceFromIntent: false, call_cid:${intentCallId?.cid}" } + logger.d { "[shouldStopService]: false, call_cid:${intentCallId?.cid}" } return false } @@ -229,8 +159,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,622 +176,407 @@ internal open class CallService : Service() { } } } + } - // STOP SERVICE LOGIC STARTS - if (shouldStopServiceFromIntent(intent)) { - stopService() - return START_NOT_STICKY + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + logger.d { "[onStartCommand], intent = $intent, flags:$flags, startId:$startId" } + logIntentExtras(intent) + // 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" - } + val call = params.streamVideo.call(params.callId.type, params.callId.id) - maybeHandleMediaIntent(intent, intentCallId) + if (!verifyPermissions(params.streamVideo, call, params.callId, params.trigger)) { + stopServiceGracefully() + return START_NOT_STICKY + } - 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 (notification, notificationId) = getNotificationPair( + params.trigger, + params.streamVideo, + params.callId, + params.displayName, - 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!" } - } - } + ) - logger.d { - "[onStartCommand] Getting notification for trigger: $trigger, callId: ${intentCallId.id}" - } - val notificationData: Pair = - getNotificationPair(trigger, streamVideo, intentCallId, intentCallDisplayName) + val handleNotificationResult = handleNotification( + notification, + notificationId, + params.callId, + params.trigger, + call, + ) - 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 - - call.state.updateNotification(notification) - - startForegroundWithServiceType( - intentCallId.hashCode(), - notification, - trigger, - getServiceTypeForStartingFGService(trigger), - ) - } - 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 - } - } - } 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" + return when (handleNotificationResult) { + CallServiceHandleNotificationResult.START -> { + initializeService(params.streamVideo, call, params.trigger) + START_NOT_STICKY } - 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!!) - if (trigger == TRIGGER_INCOMING_CALL) { - updateRingingCall(streamVideo, intentCallId, RingingState.Incoming()) + CallServiceHandleNotificationResult.REDELIVER -> { + logger.w { "Foreground service did not start!" } + stopServiceGracefully() + START_REDELIVER_INTENT } - callSoundAndVibrationPlayer = streamVideo.callSoundAndVibrationPlayer - - logger.d { - "[onStartCommand]. callSoundPlayer's hashcode: ${callSoundAndVibrationPlayer?.hashCode()}, Callservice hashcode: ${hashCode()}" - } - observeCall(intentCallId, streamVideo) - registerToggleCameraBroadcastReceiver() - return START_NOT_STICKY + CallServiceHandleNotificationResult.START_NO_CHANGE -> START_NOT_STICKY } } - private fun maybeHandleMediaIntent(intent: Intent?, callId: StreamCallId?) = safeCall { - val handler = streamDefaultNotificationHandler() - if (handler != null && callId != null) { - val isMediaNotification = notificationConfig().mediaNotificationCallTypes.contains( - callId.type, + private fun initializeService( + streamVideo: StreamVideoClient, + call: Call, + trigger: String, + ) { + callServiceLifecycleManager.initializeCallAndSocket(serviceScope, streamVideo, call) { + stopServiceGracefully() + } + + if (trigger == TRIGGER_INCOMING_CALL) { + callServiceLifecycleManager.updateRingingCall( + serviceScope, + streamVideo, + call, + RingingState.Incoming(), ) - if (isMediaNotification) { - logger.d { "[maybeHandleMediaIntent] Handling media intent" } - MediaButtonReceiver.handleIntent( - handler.mediaSession(callId), - intent, - ) - } } - } - open fun getNotificationPair( - trigger: String, - streamVideo: StreamVideoClient, - streamCallId: StreamCallId, - intentCallDisplayName: String?, - ): Pair { - return serviceNotificationRetriever.getNotificationPair( - applicationContext, - trigger, - streamVideo, - streamCallId, - intentCallDisplayName, - ) + serviceStateController.setSoundPlayer(streamVideo.callSoundAndVibrationPlayer) + logger.d { + "[initializeService] soundPlayer's hashcode: ${serviceStateController.soundPlayer?.hashCode()}" + } + + observeCall(call, streamVideo) + serviceStateController.registerToggleCameraBroadcastReceiver(this, serviceScope) } - private fun maybePromoteToForegroundService( - videoClient: StreamVideoClient, + private fun handleNotification( + notification: Notification?, notificationId: Int, + callId: StreamCallId, trigger: String, - ) { - val hasActiveCall = videoClient.state.activeCall.value != null - val not = if (hasActiveCall) " not" else "" + call: Call, + ): CallServiceHandleNotificationResult { + logHandleStart(trigger, call, notificationId) - logger.d { - "[maybePromoteToForegroundService] hasActiveCall: $hasActiveCall. Will$not call startForeground early." + if (notification == null) { + return handleNullNotification(trigger, callId, call, notificationId) } - if (!hasActiveCall) { - videoClient.getSettingUpCallNotification()?.let { notification -> - startForegroundWithServiceType( - notificationId, + serviceStateController.setCurrentCallId(callId) + notificationManager.observeCallNotification(call) + + return when (trigger) { + TRIGGER_INCOMING_CALL -> { + showIncomingCall(callId, notificationId, notification) + CallServiceHandleNotificationResult.START + } + + TRIGGER_ONGOING_CALL -> + startForegroundForCall( + call, + callId, notification, + NotificationType.Ongoing, trigger, - getServiceTypeForStartingFGService(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" - } + TRIGGER_OUTGOING_CALL -> + startForegroundForCall( + call, + callId, + notification, + NotificationType.Outgoing, + trigger, + ) + + else -> + startForegroundForCall( + call, + callId, + notification, + null, + trigger, + ) } } - @SuppressLint("MissingPermission") - private fun showIncomingCall( + private fun handleNullNotification( + trigger: String, callId: StreamCallId, - notificationId: Int, - notification: Notification, - ) { - 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" } - - StreamVideo.instanceOrNull()?.call(callId.type, callId.id) - ?.state?.updateNotification(notification) - - startForegroundWithServiceType( - notificationId, - notification, - TRIGGER_INCOMING_CALL, - getServiceTypeForStartingFGService(TRIGGER_INCOMING_CALL), - ).onError { - logger.e { - "[showIncomingCall] Failed to start foreground service, falling back to justNotify: $it" - } - justNotify(callId, notificationId, notification) + call: Call, + fallbackNotificationId: Int, + ): CallServiceHandleNotificationResult { + if (trigger != TRIGGER_REMOVE_INCOMING_CALL) { + logger.e { + "[handleNullNotification], Could not get notification for trigger: $trigger, callId: ${callId.id}" } - } 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) + return CallServiceHandleNotificationResult.REDELIVER } - } - private fun removeIncomingCall(notificationId: Int) { - NotificationManagerCompat.from(this).cancel(notificationId) + val serviceStartedForThisCall = serviceStateController.currentCallId?.id == callId.id - 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 - } - } + return if (serviceStartedForThisCall) { + removeIncomingCall(call) + CallServiceHandleNotificationResult.START + } else { + /** + * Means we only posted notification for this call, Service was never started for this call + */ + val notificationId = + call.state.notificationIdFlow.value + ?: callId.getNotificationId(NotificationType.Incoming) - // Monitor coordinator socket - serviceScope.launch { - streamVideo.connectIfNotAlreadyConnected() + NotificationManagerCompat.from(this).cancel(notificationId) + CallServiceHandleNotificationResult.START_NO_CHANGE } } - private fun updateRingingCall( - streamVideo: StreamVideo, + private fun startForegroundForCall( + call: Call, callId: StreamCallId, - ringingState: RingingState, - ) { - serviceScope.launch { - val call = streamVideo.call(callId.type, callId.id) - streamVideo.state.addRingingCall(call, ringingState) - } - } + notification: Notification, + type: NotificationType?, + trigger: String, + ): CallServiceHandleNotificationResult { + val resolvedNotificationId = + call.state.notificationIdFlow.value + ?: type?.let { callId.getNotificationId(it) } + ?: callId.hashCode() - private fun observeCall(callId: StreamCallId, streamVideo: StreamVideoClient) { - observeRingingState(callId, streamVideo) - observeCallEvents(callId, streamVideo) - if (streamVideo.enableCallNotificationUpdates) { - observeNotificationUpdates(callId, streamVideo) + logger.d { + "[startForegroundForCall] trigger=$trigger, " + + "call.state.notificationId=${call.state.notificationIdFlow.value}, " + + "notificationId=$resolvedNotificationId, " + + "hashcode=${hashCode()}" } - } - 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. - } - } + call.state.updateNotification(resolvedNotificationId, notification) - 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() - } + startForegroundWithServiceType( + resolvedNotificationId, + notification, + trigger, + permissionManager.getServiceType(baseContext, trigger), + ) - is RingingState.TimeoutNoAnswer -> { - callSoundAndVibrationPlayer?.stopCallSound() - } + return CallServiceHandleNotificationResult.START + } - else -> { - callSoundAndVibrationPlayer?.stopCallSound() - } - } - } + private fun logHandleStart( + trigger: String, + call: Call, + notificationId: Int, + ) { + logger.d { + "[logHandleStart] trigger=$trigger, " + + "call.state.notificationId=${call.state.notificationIdFlow.value}, " + + "notificationId=$notificationId, " + + "hashcode=${hashCode()}" } } - 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() - } + 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(), + ) - is LocalCallMissedEvent -> handleSlowCallRejectedEvent(call) - } + if (streamVideo.crashOnMissingPermission) { + throw exception + } else { + logger.e(exception) { "Make sure all required permissions are granted!" } } + return false } + return true + } - 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) - } - } + 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) - else -> {} - } - } - } + return CallIntentParams(streamVideo, callId, trigger, displayName) } - 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 maybeHandleMediaIntent(intent: Intent?, callId: StreamCallId?) = safeCall { + val handler = streamDefaultNotificationHandler() + if (handler != null && callId != null) { + val isMediaNotification = notificationConfig()?.mediaNotificationCallTypes?.contains( + callId.type, + ) + if (isMediaNotification == true) { + logger.d { "[maybeHandleMediaIntent] Handling media intent" } + MediaButtonReceiver.handleIntent( + handler.mediaSession(callId), + intent, + ) + } } } - private fun handleSlowCallRejectedEvent(call: Call) { - val callId = StreamCallId(call.type, call.id) - removeIncomingCall(callId.getNotificationId(NotificationType.Incoming)) + open fun getNotificationPair( + trigger: String, + streamVideo: StreamVideoClient, + streamCallId: StreamCallId, + intentCallDisplayName: String?, + ): Pair { + return serviceNotificationRetriever.getNotificationPair( + applicationContext, + trigger, + streamVideo, + streamCallId, + intentCallDisplayName, + ) } - private fun handleIncomingCallRejectedByMeOrCaller( - call: Call, - rejectedByUserId: String, - myUserId: String, - createdByUserId: String?, - activeCallExists: Boolean, + private fun showIncomingCall( + callId: StreamCallId, + notificationId: Int, + notification: Notification, ) { - // 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)) + StreamVideo.instanceOrNull()?.let { client -> + logger.d { "[showIncomingCall] notificationId: $notificationId" } + val hasActiveCall = client.state.activeCall.value != null + + if (!hasActiveCall) { + client.call(callId.type, callId.id) + .state.updateNotification(notificationId, notification) + + startForegroundWithServiceType( + notificationId, + notification, + TRIGGER_INCOMING_CALL, + permissionManager.getServiceType(baseContext, TRIGGER_INCOMING_CALL), + ).onError { + logger.e { "[showIncomingCall] Failed to start foreground: $it" } + notificationManager.justNotify(this, callId, notificationId, notification) + } } else { - stopService() + notificationManager.justNotify(this, callId, notificationId, notification) } } } - @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." - } - } - } + private fun removeIncomingCall(call: Call) { + logger.d { "[removeIncomingCall] call_cid:${call.cid}" } + if (serviceStateController.currentCallId?.cid == call.cid) { + stopServiceGracefully() } } - 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) - }, + private fun observeCall(call: Call, streamVideo: StreamVideoClient) { + CallServiceRingingStateObserver( + call, + serviceStateController.soundPlayer, + streamVideo, + serviceScope, + ) + .observe { stopServiceGracefully() } + + CallServiceEventObserver(call, streamVideo, serviceScope) + .observe( + onServiceStop = { stopServiceGracefully() }, + onRemoveIncoming = { + removeIncomingCall(call) + }, + ) + + if (streamVideo.enableCallNotificationUpdates) { + CallServiceNotificationUpdateObserver( + 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() + logger.w { "[onTimeout] Timeout received from the system, service will stop." } + stopServiceGracefully(null) } override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) + logger.w { "[onTaskRemoved]" } - endCall() - stopService() + callServiceLifecycleManager.endCall(serviceScope, serviceStateController.currentCallId) + stopServiceGracefully(null) } - 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("call-service-end-call-incoming") - logger.i { "[onTaskRemoved] Ended incoming call for me." } - } - } else { - // If I'm in an ongoing call, end the call for me - call.leave("call-service-end-call-unknown") - logger.i { "[onTaskRemoved] Ended ongoing call for me." } - } - } + override fun onDestroy() { + logger.d { + "[onDestroy], hashcode: ${hashCode()}, call_cid: ${serviceStateController.currentCallId?.cid}" } + serviceStateController.soundPlayer?.cleanUpAudioResources() + debouncer.cancel() + serviceScope.cancel() + super.onDestroy() } - override fun onDestroy() { - logger.d { "[onDestroy], Callservice hashcode: ${hashCode()}, call_cid: ${callId?.cid}" } - stopService() - callSoundAndVibrationPlayer?.cleanUpAudioResources() - super.onDestroy() + private fun streamDefaultNotificationHandler(): StreamDefaultNotificationHandler? { + val client = StreamVideo.instanceOrNull() as? StreamVideoClient ?: return null + val handler = + client.streamNotificationManager.notificationConfig.notificationHandler as? StreamDefaultNotificationHandler + return handler } - override fun stopService(name: Intent?): Boolean { - logger.d { "[stopService(name)], Callservice hashcode: ${hashCode()}" } - stopService() - return super.stopService(name) + private fun notificationConfig(): NotificationConfig? { + val client = StreamVideo.instanceOrNull() as? StreamVideoClient ?: return null + return client.streamNotificationManager.notificationConfig } /** @@ -870,68 +584,43 @@ 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 stopService() { - // Cancel the notification - val notificationManager = NotificationManagerCompat.from(this) - callId?.let { - val notificationId = callId.hashCode() - notificationManager.cancel(notificationId) - - logger.i { "[stopService]. Cancelled notificationId: $notificationId" } + private fun stopServiceGracefully(source: String? = null) { + serviceStateController.startTime?.let { startTime -> + + val currentTime = OffsetDateTime.now() + val duration = Duration.between(startTime, currentTime) + val differenceInSeconds = duration.seconds.absoluteValue + val debouncerThresholdTimeInSeconds = SERVICE_DESTROY_THRESHOLD_TIME_MS / 1_000 + logger.d { "[stopServiceGracefully] differenceInSeconds: $differenceInSeconds" } + if (differenceInSeconds >= debouncerThresholdTimeInSeconds) { + internalStopServiceGracefully() + } else { + debouncer.submit(debouncerThresholdTimeInSeconds) { + internalStopServiceGracefully() + } + } } + } - safeCall { - val handler = streamDefaultNotificationHandler() - handler?.clearMediaSession(callId) - } + private fun internalStopServiceGracefully() { + logger.d { "[internalStopServiceGracefully] hashcode: ${hashCode()}" } - // 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" } + stopForeground(STOP_FOREGROUND_REMOVE) + serviceStateController.currentCallId?.let { + notificationManager.cancelNotifications(this, it) } - // Camera privacy - unregisterToggleCameraBroadcastReceiver() + serviceStateController.unregisterToggleCameraBroadcastReceiver(this) - // 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 + serviceStateController.soundPlayer?.stopCallSound() // TODO should check which call owns the sound serviceScope.cancel() - - // Optionally (no-op if already stopping) stopSelf() } - private fun streamDefaultNotificationHandler(): StreamDefaultNotificationHandler? { - val client = StreamVideo.instanceOrNull() as StreamVideoClient - val handler = - client.streamNotificationManager.notificationConfig.notificationHandler as? StreamDefaultNotificationHandler - return handler - } - - private fun notificationConfig(): NotificationConfig { - val client = StreamVideo.instanceOrNull() as StreamVideoClient - return client.streamNotificationManager.notificationConfig - } - - private fun unregisterToggleCameraBroadcastReceiver() { - if (isToggleCameraBroadcastReceiverRegistered) { - try { - unregisterReceiver(toggleCameraBroadcastReceiver) - isToggleCameraBroadcastReceiverRegistered = false - } catch (e: Exception) { - logger.d { "Unable to unregister ToggleCameraBroadcastReceiver." } - } - } - } - // This service does not return a Binder override fun onBind(intent: Intent?): IBinder? = null } 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..23e75fce44 --- /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-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.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 b2a7ca3759..cf89073e50 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 @@ -39,72 +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, - ), - ), - ) - ComponentName(context, CallService::class.java) - showIncomingCallResult = ShowIncomingCallResult.FG_SERVICE + if (hasNoActiveCall()) { + startForegroundService(context, startParams) + result = ShowIncomingCallResult.FG_SERVICE } else { - logger.d { "[showIncomingCall] Starting regular service" } - context.startService( - serviceIntentBuilder.buildStartIntent( - context, - StartServiceParam( - callId, - TRIGGER_INCOMING_CALL, - callDisplayName, - callServiceConfiguration, - ), - ), - ) - showIncomingCallResult = ShowIncomingCallResult.SERVICE + result = handleWhileActiveCall(context, startParams, notification) } - }.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}" - } + }.onError { error -> + logger.d { "[showIncomingCall] onError" } + result = showNotification(context, notification, callId, error) + } + return result + } + + // ---------------------------------- + // Decision branches + // ---------------------------------- + + private fun handleWhileActiveCall( + context: Context, + startParams: StartServiceParam, + notification: Notification?, + ): ShowIncomingCallResult { + val serviceClass = startParams.callServiceConfiguration.serviceClass + + return if (serviceIntentBuilder.isServiceRunning(context, serviceClass)) { + showNotification(context, notification, startParams.callId, null) + } else { + logger.d { "[showIncomingCall] Starting regular service" } + context.startService( + serviceIntentBuilder.buildStartIntent(context, startParams), + ) + ShowIncomingCallResult.SERVICE + } + } + + // ---------------------------------- + // Side effects + // ---------------------------------- + + private fun startForegroundService( + context: Context, + params: StartServiceParam, + ) { + logger.d { "[showIncomingCall] Starting foreground service" } + ContextCompat.startForegroundService( + context, + 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}" } + return ShowIncomingCallResult.ERROR + } + + StreamVideo.instanceOrNull() + ?.getStreamNotificationDispatcher() + ?.notify( + callId, + callId.getNotificationId(NotificationType.Incoming), + notification, + ) + + 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/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 197877788f..17d362a91b 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/ServiceIntentBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceIntentBuilder.kt index 615a054bc5..1a1d22ead2 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 @@ -33,11 +33,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,24 +68,25 @@ 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? { + logger.d { "[buildStopIntent]" } + val serviceClass = stopServiceParam.callServiceConfiguration.serviceClass + val intent = if (isServiceRunning(context, serviceClass)) { + Intent(context, serviceClass) + } else { + return null } + logger.d { + "[buildStopIntent], class:${intent.component?.shortClassName}, call_id: ${stopServiceParam.call?.cid}" + } + stopServiceParam.call?.let { call -> + 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 = + internal fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean = safeCallWithDefault(true) { val activityManager = context.getSystemService( Context.ACTIVITY_SERVICE, @@ -93,11 +94,11 @@ internal class ServiceIntentBuilder { val runningServices = activityManager.getRunningServices(Int.MAX_VALUE) for (service in runningServices) { if (serviceClass.name == service.service.className) { - logger.w { "Service is running: $serviceClass" } + logger.d { "[isServiceRunning], Service is running: $serviceClass" } return true } } - logger.w { "Service is NOT running: $serviceClass" } + logger.d { "[isServiceRunning], Service is NOT running: $serviceClass" } return false } } 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 1a1e61e114..28d4f64121 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 @@ -64,6 +65,7 @@ internal class ServiceLauncher(val context: Context) { private val telecomHelper = TelecomHelper() private val telecomPermissions = TelecomPermissions() private val jetpackTelecomRepositoryProvider = JetpackTelecomRepositoryProvider(context) + private val throttler = Throttler() @SuppressLint("MissingPermission", "NewApi") fun showIncomingCall( @@ -201,8 +203,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) } } @@ -223,16 +226,29 @@ internal class ServiceLauncher(val context: Context) { ), )!! }.onError { + logger.d { + "[removeIncomingCall] notificationId: ${callId.getNotificationId( + NotificationType.Incoming, + )}" + } NotificationManagerCompat.from(context) .cancel(callId.getNotificationId(NotificationType.Incoming)) } } + /** + * 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) { - stopCallServiceInternal(call) + logger.d { "[stopService]" } + throttler.throttleFirst(CallService.SERVICE_DESTROY_THROTTLE_TIME_MS) { + 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) @@ -243,11 +259,15 @@ internal class ServiceLauncher(val context: Context) { 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.startService(serviceIntent) } } } 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 688ee2c077..fd5176c814 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,7 +34,26 @@ import io.getstream.video.android.model.StreamCallId internal class ServiceNotificationRetriever { private val logger by taggedLogger("ServiceNotificationRetriever") - open fun getNotificationPair( + /** + * 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. + */ + fun getNotificationPair( context: Context, trigger: String, streamVideo: StreamVideoClient, @@ -44,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.notificationIdFlow.value + ?: 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.notificationIdFlow.value + ?: streamCallId.getNotificationId(NotificationType.Incoming) + Pair( first = streamVideo.getRingingCallNotification( ringingState = RingingState.Incoming(), @@ -69,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.notificationIdFlow.value + ?: streamCallId.getNotificationId(NotificationType.Outgoing) + Pair( first = streamVideo.getRingingCallNotification( ringingState = RingingState.Outgoing(), @@ -84,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/controllers/ServiceStateController.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/controllers/ServiceStateController.kt new file mode 100644 index 0000000000..801f0d493e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/controllers/ServiceStateController.kt @@ -0,0 +1,130 @@ +/* + * 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.service.controllers + +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.notifications.internal.service.models.ServiceStateSnapshot +import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import org.threeten.bp.OffsetDateTime + +internal class ServiceStateController { + private val _state = MutableStateFlow(ServiceStateSnapshot()) + val state: StateFlow = _state + + val currentCallId: StreamCallId? + get() = state.value.currentCallId + + val notificationId: Int? + get() = state.value.notificationId + + val soundPlayer: CallSoundAndVibrationPlayer? + get() = state.value.soundPlayer + + val startTime: OffsetDateTime? + get() = state.value.startTime + + fun setCurrentCallId(callId: StreamCallId) { + _state.update { it.copy(currentCallId = callId) } + } + + fun setCallNotificationId(notificationId: Int) { + _state.update { it.copy(notificationId = notificationId) } + } + + fun setSoundPlayer(player: CallSoundAndVibrationPlayer) { + _state.update { it.copy(soundPlayer = player) } + } + + fun setStartTime(time: OffsetDateTime) { + _state.update { it.copy(startTime = time) } + } + + fun registerToggleCameraBroadcastReceiver( + service: Service, + scope: CoroutineScope, + ) { + val receiver = ToggleCameraBroadcastReceiver(scope) + + var shouldRegister = false + + _state.update { current -> + if (current.isReceiverRegistered) { + current + } else { + shouldRegister = true + current.copy( + toggleCameraBroadcastReceiver = receiver, + isReceiverRegistered = true, + ) + } + } + + if (!shouldRegister) return + + try { + service.registerReceiver( + receiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_USER_PRESENT) + }, + ) + } catch (e: Exception) { + // Roll back state if registration fails + _state.update { + it.copy( + toggleCameraBroadcastReceiver = null, + isReceiverRegistered = false, + ) + } + } + } + + fun unregisterToggleCameraBroadcastReceiver(service: Service) { + var receiverToUnregister: ToggleCameraBroadcastReceiver? = null + + _state.update { current -> + if (!current.isReceiverRegistered) { + receiverToUnregister = null + current + } else { + receiverToUnregister = current.toggleCameraBroadcastReceiver + current.copy( + toggleCameraBroadcastReceiver = null, + isReceiverRegistered = false, + ) + } + } + + receiverToUnregister ?: return + + try { + service.unregisterReceiver(receiverToUnregister) + } catch (e: Exception) { + // Best-effort cleanup; state is already consistent + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManager.kt new file mode 100644 index 0000000000..d396f6351d --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManager.kt @@ -0,0 +1,105 @@ +/* + * 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.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 CallServiceLifecycleManager { + private val logger by taggedLogger("CallServiceLifecycleManager") + fun initializeCallAndSocket( + scope: CoroutineScope, + streamVideo: StreamVideo, + call: Call, + onError: () -> Unit, + ) { + scope.launch { + val update = call.get() + + if (update.isFailure) { + onError() + return@launch + } + } + + scope.launch { + streamVideo.connectIfNotAlreadyConnected() + } + } + + fun updateRingingCall( + scope: CoroutineScope, + streamVideo: StreamVideo, + call: Call, + ringingState: RingingState, + ) { + scope.launch { + 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("call-service-end-call-unknown") + logger.i { "[onTaskRemoved] Ended ongoing call for me" } + } + } + } + } + } + + private fun handleIncomingCallTaskRemoved(scope: CoroutineScope, call: Call) { + val memberCount = call.state.members.value.size + logger.i { "[handleIncomingCallTaskRemoved] Total members: $memberCount" } + + if (memberCount == 2) { + scope.launch { + call.reject(source = "memberCount == 2") + logger.i { "[handleIncomingCallTaskRemoved] Ended incoming call for both users" } + } + } else { + call.leave("call-service-end-call-incoming") + 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/CallServiceNotificationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt new file mode 100644 index 0000000000..a320394d89 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManager.kt @@ -0,0 +1,86 @@ +/* + * 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.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.log.taggedLogger +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.handlers.StreamDefaultNotificationHandler +import io.getstream.video.android.core.notifications.internal.service.controllers.ServiceStateController +import io.getstream.video.android.core.utils.safeCall +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlin.getValue + +internal class CallServiceNotificationManager(val stateController: ServiceStateController, val scope: CoroutineScope) { + private val logger by taggedLogger("CallServiceNotificationManager") + + fun observeCallNotification(call: Call) { + scope.launch { + call.state.notificationIdFlow.filterNotNull() + .collect { stateController.setCallNotificationId(it) } + } + } + + @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 { + logger.d { "[cancelNotifications], notificationId via hashcode: ${it.hashCode()}" } + notificationManager.cancel(it.hashCode()) + } + + stateController.notificationId?.let { notificationId -> + logger.d { "[cancelNotifications], notificationId from stateController: $notificationId" } + notificationManager.cancel(notificationId) + } + + 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..87bebffc98 --- /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-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.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/ServiceStateSnapshot.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateSnapshot.kt new file mode 100644 index 0000000000..04d4397901 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateSnapshot.kt @@ -0,0 +1,31 @@ +/* + * 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.service.models + +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 org.threeten.bp.OffsetDateTime + +internal data class ServiceStateSnapshot( + val currentCallId: StreamCallId? = null, + val notificationId: Int? = null, + val soundPlayer: CallSoundAndVibrationPlayer? = null, + val toggleCameraBroadcastReceiver: ToggleCameraBroadcastReceiver? = null, + val isReceiverRegistered: Boolean = false, + val startTime: OffsetDateTime? = null, +) 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 new file mode 100644 index 0000000000..6d473e3549 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserver.kt @@ -0,0 +1,155 @@ +/* + * 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.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.LocalCallRejectedPostEvent +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 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") + + /** + * 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 -> + handleCallEvent(event, onServiceStop, onRemoveIncoming) + } + } + } + + /** + * Handles different types of call events. + */ + private fun handleCallEvent( + event: Any, + onServiceStop: () -> Unit, + onRemoveIncoming: () -> Unit, + ) { + when (event) { + is LocalCallAcceptedPostEvent -> { + handleIncomingCallAcceptedByMeOnAnotherDevice( + event.user.id, + streamVideo.userId, + onServiceStop, + ) + } + is LocalCallRejectedPostEvent -> { + handleIncomingCallRejectedByMeOrCaller( + rejectedByUserId = event.user.id, + myUserId = streamVideo.userId, + createdByUserId = event.call.createdBy.toUser().id, + activeCallExists = streamVideo.state.activeCall.value != null, + onServiceStop = onServiceStop, + onRemoveIncoming = onRemoveIncoming, + ) + } + is CallEndedEvent -> onServiceStop() + } + } + + /** + * Handles call accepted event - stops service if accepted on another device. + */ + private fun handleIncomingCallAcceptedByMeOnAnotherDevice( + acceptedByUserId: String, + 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 + 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 + 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 + 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/CallServiceNotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt new file mode 100644 index 0000000000..adc33f6a6a --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt @@ -0,0 +1,191 @@ +/* + * 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.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 CallServiceNotificationUpdateObserver( + 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 + val notification = streamVideo.onCallNotificationUpdate(call) + logger.d { + "[updateNotification] ringingState: $ringingState, 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 { "[showActiveCallNotification] Showing active call notification" } + val notificationId = + call.state.notificationIdFlow.value ?: callId.getNotificationId(NotificationType.Ongoing) + startForegroundWithServiceType( + notificationId, + 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 { "[showOutgoingCallNotification] Showing outgoing call notification" } + val notificationId = + call.state.notificationIdFlow.value ?: callId.getNotificationId(NotificationType.Outgoing) + startForegroundWithServiceType( + notificationId, + 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 { "[showIncomingCallNotification] Showing incoming call notification" } + val notificationId = + call.state.notificationIdFlow.value ?: callId.getNotificationId(NotificationType.Incoming) + startForegroundWithServiceType( + notificationId, + 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/CallServiceRingingStateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserver.kt new file mode 100644 index 0000000000..88c96adfc5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserver.kt @@ -0,0 +1,148 @@ +/* + * 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.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.sounds.CallSoundAndVibrationPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class CallServiceRingingStateObserver( + 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) { + streamVideo.scope.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..99740b063d --- /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-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.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..0313a3bae2 --- /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-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.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..a50135a16b --- /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-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.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..46b245ec25 --- /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-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.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..f0df098c67 --- /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-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.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 + } + } +} 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 6d820aa029..7db3a884c9 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, messagePrefix: String = "", count: Int = 5) { + val stack = Thread.currentThread().stackTrace + val message = stack.takeLast(count).joinToString("\n") + Log.d(tag, "$messagePrefix:$message") +} 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 a401f893f5..4fbeac0a58 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,7 +298,7 @@ class StreamDefaultNotificationHandlerTest { } // Verify notification manager is called to show notification - verify { mockNotificationManager.notify(testCallId.hashCode(), any()) } + verify { mockNotificationManager.notify(notificationId, any()) } } @Test @@ -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..e4dff53fd4 --- /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-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 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..53c9e67998 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/ThrottlerTest.kt @@ -0,0 +1,100 @@ +/* + * 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 org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ThrottlerTest { + + private val throttler = Throttler() + + @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/IncomingCallPresenterTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenterTest.kt index ba98366325..b89ccf71b9 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 6a3a93a2b2..7f8e12b841 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 { @@ -55,8 +60,6 @@ class ServiceIntentBuilderTest { @Test fun `buildStartIntent creates correct intent for outgoing call`() { - // When - val intent = ServiceIntentBuilder().buildStartIntent( context, StartServiceParam( @@ -65,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)) @@ -74,7 +76,6 @@ class ServiceIntentBuilderTest { @Test fun `buildStartIntent creates correct intent for ongoing call`() { - // When val intent = ServiceIntentBuilder().buildStartIntent( context, StartServiceParam( @@ -83,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)) @@ -91,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)) @@ -116,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( @@ -131,49 +127,95 @@ class ServiceIntentBuilderTest { ), ) - // Then assertEquals(LivestreamCallService::class.java.name, intent.component?.className) } @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()) + + 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 + + val intent = builder.buildStopIntent(context, param) + + 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`() { + val builder = spyk(ServiceIntentBuilder()) + + 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 + + val intent = builder.buildStopIntent(context, param) + + 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`() { + val builder = spyk(ServiceIntentBuilder()) + + 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 + + val intent = builder.buildStopIntent(context, param) + 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 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( @@ -183,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/ServiceNotificationRetrieverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt index 4e70425a74..ce481c01ff 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 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..6b0feaa37a --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceLifecycleManagerTest.kt @@ -0,0 +1,199 @@ +/* + * 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.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, + call = call, + 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, call, 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, + call = call, + 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 { + mockkObject(StreamVideo.Companion) + every { StreamVideo.instanceOrNull() } returns streamVideo + 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..0cf56c8535 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt @@ -0,0 +1,200 @@ +/* + * 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.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.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.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.core.notifications.internal.service.controllers.ServiceStateController +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 kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +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 notificationDispatcher: NotificationDispatcher = mockk(relaxed = true) + private val notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true) + private val stateController: ServiceStateController = mockk(relaxed = true) + private val dispatcher = StandardTestDispatcher() + private val testScope = TestScope(dispatcher) + + @Before + fun setup() { + context = mockk(relaxed = true) + sut = CallServiceNotificationManager(stateController, testScope) + + 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(any(), 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 { + ContextCompat.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`() { + 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()) + call.state.notificationIdFlow.value?.let { + notificationManagerCompat.cancel(it) + } + } + } + + @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 { 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 { + handler.clearMediaSession(callId) + } + } + + @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..a3de87b40d --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceStateTest.kt @@ -0,0 +1,131 @@ +/* + * 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.service.models + +import android.app.Service +import android.content.IntentFilter +import io.getstream.video.android.core.notifications.internal.receivers.ToggleCameraBroadcastReceiver +import io.getstream.video.android.core.notifications.internal.service.controllers.ServiceStateController +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 org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.Test + +@RunWith(RobolectricTestRunner::class) +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 `registerToggleCameraBroadcastReceiver registers receiver`() { + val sut = ServiceStateController() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + + verify(exactly = 1) { + service.registerReceiver( + any(), + any(), + ) + } + } + + @Test + fun `registerToggleCameraBroadcastReceiver does not re-register if already registered`() { + val sut = ServiceStateController() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + sut.registerToggleCameraBroadcastReceiver(service, testScope) + + verify(exactly = 1) { + service.registerReceiver(any(), any()) + } + } + + @Test + fun `registerToggleCameraBroadcastReceiver swallows exception`() { + val sut = ServiceStateController() + 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 = ServiceStateController() + sut.unregisterToggleCameraBroadcastReceiver(service) + + verify { + service wasNot Called + } + } + + /** + * Ignored because of unknown reason of failure, it will fail when running all tests + */ + @Test + fun `unregisterToggleCameraBroadcastReceiver unregisters receiver`() { + val sut = ServiceStateController() + sut.registerToggleCameraBroadcastReceiver(service, testScope) + sut.unregisterToggleCameraBroadcastReceiver(service) + + verify { + service.unregisterReceiver(any()) + } + } + + @Test + fun `unregisterToggleCameraBroadcastReceiver swallows exception`() { + val sut = ServiceStateController() + 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..23adeda0bf --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceEventObserverTest.kt @@ -0,0 +1,213 @@ +/* + * 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.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.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, testScope) + } + + @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 `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..04a4850c97 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserverTest.kt @@ -0,0 +1,204 @@ +/* + * 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.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 testNotificationIdFlow: MutableStateFlow = MutableStateFlow(null) + 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 { notificationIdFlow } returns testNotificationIdFlow + } + + 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..964eb4745c --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceRingingStateObserverTest.kt @@ -0,0 +1,232 @@ +/* + * 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.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 { + every { streamVideo.scope } returns testScope + 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..66eedd1b8c --- /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-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.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..30671f36a0 --- /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-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.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..3e064f046d --- /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-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.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..fe0c651e27 --- /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-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.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..0d008f6fd6 --- /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-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.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, + ) + } +}