From 2746976a0ba4d9105c730d946b86db8a99c34b69 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 4 Sep 2026 14:58:30 +0530 Subject: [PATCH 1/8] fix(core): register app with Telecom once --- .../internal/service/ServiceLauncher.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 f825aa2f17a..f6d959bb564 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 @@ -168,6 +168,21 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { } } + /** + * Because we need to retrieve the notification + * in [io.getstream.video.android.core.notifications.internal.telecom.connection.SuccessIncomingTelecomConnection] + */ + private fun updateIncomingCallNotification( + notification: Notification?, + callId: StreamCallId, + ) { + notification?.let { + val notificationId = callId.getNotificationId(NotificationType.Incoming) + client.call(callId.type, callId.id) + .state.updateNotification(notificationId, notification) + } + } + fun removeIncomingCall( callId: StreamCallId, config: CallServiceConfig = DefaultCallConfigurations.default, From a8c871e42bdcb7759b294bea1cd7bcf03ae5128d Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 4 Sep 2026 15:00:37 +0530 Subject: [PATCH 2/8] refactor(core): extract incoming call coordinator --- .../internal/service/ServiceLauncher.kt | 15 --------------- 1 file changed, 15 deletions(-) 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 f6d959bb564..f825aa2f17a 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 @@ -168,21 +168,6 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { } } - /** - * Because we need to retrieve the notification - * in [io.getstream.video.android.core.notifications.internal.telecom.connection.SuccessIncomingTelecomConnection] - */ - private fun updateIncomingCallNotification( - notification: Notification?, - callId: StreamCallId, - ) { - notification?.let { - val notificationId = callId.getNotificationId(NotificationType.Incoming) - client.call(callId.type, callId.id) - .state.updateNotification(notificationId, notification) - } - } - fun removeIncomingCall( callId: StreamCallId, config: CallServiceConfig = DefaultCallConfigurations.default, From 69cc8a986e7ac1ec7458ba96a6284cf39592d36a Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 4 Sep 2026 15:05:50 +0530 Subject: [PATCH 3/8] feat(core): support notification-owned incoming ringing --- .../android/core/IncomingRingtoneOwner.kt | 22 ++++++ .../DefaultNotificationHandler.kt | 16 +++-- .../IncomingCallNotificationPreparer.kt | 58 +++++++++++++++ .../handlers/ChannelInfoProvider.kt | 19 +++++ .../StreamDefaultNotificationHandler.kt | 70 ++++++++++++++++--- .../internal/service/IncomingCallRequest.kt | 3 +- .../internal/service/ServiceLauncher.kt | 5 +- .../PreAndroid17IncomingCallCoordinator.kt | 15 +++- .../CallServiceRingingStateObserver.kt | 5 ++ .../android/core/sounds/RingingConfig.kt | 5 ++ .../android/core/utils/AndroidVersionCodes.kt | 5 ++ .../IncomingCallNotificationAlertTest.kt | 48 +++++++++++++ .../StreamDefaultNotificationHandlerTest.kt | 19 +++++ .../internal/service/ServiceLauncherTest.kt | 4 +- .../core/utils/AndroidVersionCodesTest.kt | 34 +++++++++ 15 files changed, 304 insertions(+), 24 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt new file mode 100644 index 00000000000..bfb823f576c --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt @@ -0,0 +1,22 @@ +/* + * 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 + +internal sealed interface IncomingRingtoneOwner { + data object Notification : IncomingRingtoneOwner + data object Legacy : IncomingRingtoneOwner +} 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 16b3ecb4d11..482f25bc9fe 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 @@ -118,13 +118,15 @@ public open class DefaultNotificationHandler( streamVideo.state.callConfigRegistry.get(callId.type), isVideo = isVideoCall(callId, payload), payload = payload, - notification = getRingingCallNotification( - RingingState.Incoming(), - callId, - callDisplayName, - shouldHaveContentIntent = true, - payload, - ), + notificationProvider = { + getRingingCallNotification( + RingingState.Incoming(), + callId, + callDisplayName, + shouldHaveContentIntent = true, + payload, + ) + }, ) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt new file mode 100644 index 00000000000..2d535181ca9 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt @@ -0,0 +1,58 @@ +/* + * 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 + +import android.app.Notification +import androidx.core.app.NotificationCompat +import io.getstream.video.android.core.IncomingRingtoneOwner +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.handlers.incomingCallNotificationFlags + +internal class IncomingCallNotificationPreparer( + private val streamVideo: StreamVideoClient, +) { + + private val context = streamVideo.context + + fun prepare( + nonRingingNotification: Notification, + owner: IncomingRingtoneOwner, + ringingState: RingingState.Incoming, + ): Notification = when (owner) { + IncomingRingtoneOwner.Notification -> prepareRingingNotification( + nonRingingNotification, + ringingState, + ) + IncomingRingtoneOwner.Legacy -> nonRingingNotification + } + + private fun prepareRingingNotification( + nonRingingNotification: Notification, + ringingState: RingingState.Incoming, + ): Notification { + val channelId = requireNotNull(NotificationCompat.getChannelId(nonRingingNotification)) { + "Incoming-call ringing requires a notification channel ID." + } + return NotificationCompat.Builder(context, nonRingingNotification) + .setChannelId(channelId) + .build() + .apply { + flags = incomingCallNotificationFlags(flags, ringingState) + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt index 7992a50c20e..9df1bcca376 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt @@ -18,6 +18,8 @@ package io.getstream.video.android.core.notifications.handlers import android.app.NotificationManager import android.content.Context +import android.media.AudioAttributes +import android.net.Uri import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat @@ -87,6 +89,23 @@ internal fun StreamNotificationChannelInfo.create(manager: NotificationManagerCo ) } +/** Creates an incoming-call channel whose sound and vibration are owned by the system. */ +internal fun StreamNotificationChannelInfo.createRingingChannel( + manager: NotificationManagerCompat, + soundUri: Uri?, + audioAttributes: AudioAttributes, + vibrationPattern: LongArray?, +) = safeCall { + manager.createNotificationChannel( + NotificationChannelCompat.Builder(id, importance) + .setName(name) + .setDescription(description) + .setSound(soundUri, audioAttributes) + .setVibrationPattern(vibrationPattern) + .build(), + ) +} + /** * Provides the channel information for the notification. * diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index 4897e25d35b..7954c2f207a 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 @@ -25,6 +25,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.media.AudioAttributes import android.os.Build import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat @@ -47,6 +48,7 @@ import io.getstream.video.android.core.call.CallBusyHandler import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver +import io.getstream.video.android.core.notifications.IncomingCallNotificationPreparer 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 @@ -59,6 +61,7 @@ import io.getstream.video.android.core.notifications.extractor.DefaultNotificati import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL import io.getstream.video.android.core.notifications.style.StyleProvider import io.getstream.video.android.core.utils.BackgroundRestrictions +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.core.utils.isAppInForeground import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId @@ -166,7 +169,8 @@ constructor( payload: Map, ) { logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" } - val streamVideo = StreamVideo.instance() + val streamVideo = StreamVideo.instance() as StreamVideoClient + val notificationPreparer = IncomingCallNotificationPreparer(streamVideo) if (shouldShowIncomingCallNotification( (streamVideo as StreamVideoClient).callBusyHandler, callId.cid, @@ -181,13 +185,18 @@ constructor( streamVideo.state.callConfigRegistry.get(callId.type), isVideo = isVideoCall(callId, payload), payload = payload, - notification = getRingingCallNotification( - RingingState.Incoming(), - callId, - callDisplayName, - shouldHaveContentIntent = true, - payload, - ), + notificationProvider = { owner -> + val ringingState = RingingState.Incoming() + getRingingCallNotification( + ringingState, + callId, + callDisplayName, + shouldHaveContentIntent = true, + payload, + )?.let { notification -> + notificationPreparer.prepare(notification, owner, ringingState) + } + }, ) } } @@ -469,7 +478,7 @@ constructor( else -> notificationChannels.incomingCallChannel } - return ensureChannelAndBuildNotification(notificationChannel) { + return ensureIncomingCallChannelAndBuildNotification(notificationChannel) { priority = if (hideRingingNotificationInForeground) { NotificationCompat.PRIORITY_LOW } else { @@ -1234,6 +1243,31 @@ constructor( return NotificationCompat.Builder(application, channelInfo.id).let(builder).build() } + private inline fun ensureIncomingCallChannelAndBuildNotification( + channelInfo: StreamNotificationChannelInfo, + builder: NotificationCompat.Builder.() -> NotificationCompat.Builder, + ): Notification { + val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient + if (isAndroid17OrHigher() && streamVideo != null) { + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + val vibrationPattern = streamVideo.vibrationConfig + .takeIf { it.enabled } + ?.vibratePattern + channelInfo.createRingingChannel( + manager = notificationManager, + soundUri = streamVideo.sounds.ringingConfig.incomingCallSoundUri, + audioAttributes = audioAttributes, + vibrationPattern = vibrationPattern, + ) + } else { + channelInfo.create(notificationManager) + } + return NotificationCompat.Builder(application, channelInfo.id).let(builder).build() + } + @OptIn(ExperimentalStreamVideoApi::class) internal fun mediaSession(callId: StreamCallId) = mediaSessionController.provideMediaSession( application, @@ -1255,3 +1289,21 @@ constructor( return call?.isVideoEnabled() == true } } + +/** + * Updates how an incoming-call notification alerts the user. + * + * While the call is unanswered, the notification keeps ringing. After the user accepts the call, + * ringing is stopped and later notification updates are prevented from starting the sound again. + * Any unrelated notification flags are kept unchanged. + */ +internal fun incomingCallNotificationFlags( + currentFlags: Int, + ringingState: RingingState.Incoming, +): Int = if (ringingState.acceptedByMe) { + val withoutInsistent = currentFlags and Notification.FLAG_INSISTENT.inv() + withoutInsistent or Notification.FLAG_ONLY_ALERT_ONCE +} else { + val withoutOnlyAlertOnce = currentFlags and Notification.FLAG_ONLY_ALERT_ONCE.inv() + withoutOnlyAlertOnce or Notification.FLAG_INSISTENT +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt index 3e51bf02274..55660edc01b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt @@ -17,6 +17,7 @@ package io.getstream.video.android.core.notifications.internal.service import android.app.Notification +import io.getstream.video.android.core.IncomingRingtoneOwner import io.getstream.video.android.model.StreamCallId internal data class IncomingCallRequest( @@ -25,5 +26,5 @@ internal data class IncomingCallRequest( val callServiceConfiguration: CallServiceConfig, val isVideo: Boolean, val payload: Map, - val notification: Notification?, + val notificationProvider: (IncomingRingtoneOwner) -> Notification?, ) 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 f825aa2f17a..425e575ce37 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 @@ -39,6 +39,7 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call +import io.getstream.video.android.core.IncomingRingtoneOwner import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.internal.Throttler import io.getstream.video.android.core.notifications.internal.VideoPushDelegate.Companion.DEFAULT_CALL_TEXT @@ -82,7 +83,7 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { callServiceConfiguration: CallServiceConfig, isVideo: Boolean, payload: Map, - notification: Notification?, + notificationProvider: (IncomingRingtoneOwner) -> Notification?, ) { incomingCallCoordinator.showIncomingCall( IncomingCallRequest( @@ -91,7 +92,7 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { callServiceConfiguration = callServiceConfiguration, isVideo = isVideo, payload = payload, - notification = notification, + notificationProvider = notificationProvider, ), ) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt index 0f1807f5f00..5fb838a9dd1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt @@ -21,6 +21,7 @@ import android.content.Context import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import io.getstream.log.taggedLogger +import io.getstream.video.android.core.IncomingRingtoneOwner import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.internal.VideoPushDelegate.Companion.DEFAULT_CALL_TEXT @@ -34,6 +35,7 @@ import io.getstream.video.android.core.notifications.internal.service.ShowIncomi import io.getstream.video.android.core.notifications.internal.service.StartServiceParam import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.core.utils.safeCallWithResult import io.getstream.video.android.model.StreamCallId import kotlinx.coroutines.launch @@ -53,12 +55,18 @@ internal class PreAndroid17IncomingCallCoordinator( @SuppressLint("MissingPermission", "NewApi") override fun showIncomingCall(request: IncomingCallRequest) { + val ringtoneOwner = if (isAndroid17OrHigher()) { + IncomingRingtoneOwner.Notification + } else { + IncomingRingtoneOwner.Legacy + } + val notification = request.notificationProvider(ringtoneOwner) val result = incomingCallPresenter.showIncomingCall( context = context, callId = request.callId, callDisplayName = request.callDisplayName, callServiceConfiguration = request.callServiceConfiguration, - notification = request.notification, + notification = notification, ) logger.d { "[showIncomingCall] service start result: $result" } @@ -69,7 +77,7 @@ internal class PreAndroid17IncomingCallCoordinator( return } - updateIncomingCallNotification(request, client) + updateIncomingCallNotification(request, client, notification) val jetpackTelecomRepository = jetpackTelecomRepositoryProvider.get(request.callId) val addressUri = "${client.telecomConfig?.schema}:${request.callId.id}".toUri() val formattedCallDisplayName = request.callDisplayName @@ -113,8 +121,9 @@ internal class PreAndroid17IncomingCallCoordinator( private fun updateIncomingCallNotification( request: IncomingCallRequest, client: StreamVideoClient, + notification: android.app.Notification?, ) { - request.notification?.let { notification -> + notification?.let { val notificationId = request.callId.getNotificationId(NotificationType.Incoming) client.call(request.callId.type, request.callId.id) .state.updateNotification(notificationId, notification) 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 index 88c96adfc55..32102fc4027 100644 --- 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 @@ -24,6 +24,7 @@ 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.utils.isAndroid17OrHigher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -66,6 +67,10 @@ internal class CallServiceRingingStateObserver( */ private fun handleIncomingState(state: RingingState.Incoming) { if (!state.acceptedByMe) { + if (isAndroid17OrHigher()) { + return + } + // Start vibration if allowed if (shouldVibrate()) { val pattern = streamVideo.vibrationConfig.vibratePattern diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt index be80b6e7cf7..8c597976284 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sounds/RingingConfig.kt @@ -78,6 +78,11 @@ public interface RingingConfig { * This configuration allows overriding that behavior for incoming * and outgoing call sounds. * + * This configuration does not apply to incoming-call ringing owned by a notification channel. + * Android does not provide a supported way for a notification ringtone to bypass silent mode. + * It continues to apply when incoming ringing is played directly by the SDK and to outgoing + * call sounds. + * * @param playIncomingSoundIfMuted Whether the incoming call sound should play even if the device is muted. * @param playOutgoingSoundIfMuted Whether the outgoing ringing sound should play even if the device is muted. */ diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt index bed1757ef12..2fa78d598f8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt @@ -16,7 +16,12 @@ package io.getstream.video.android.core.utils +import android.os.Build + // Polyfills for Build.VERSION_CODES values not yet available at the current compileSdk. // TODO: delete each once compileSdk covers it, replacing usages with the real constant. internal const val BUILD_VERSION_CODES_BAKLAVA = 36 // Build.VERSION_CODES.BAKLAVA — needs compileSdk 36 internal const val BUILD_VERSION_CODES_CINNAMON_BUN = 37 // Build.VERSION_CODES.CINNAMON_BUN — needs compileSdk 37 + +internal fun isAndroid17OrHigher(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = + sdkInt >= BUILD_VERSION_CODES_CINNAMON_BUN diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt new file mode 100644 index 00000000000..943d070885e --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt @@ -0,0 +1,48 @@ +/* + * 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.handlers + +import android.app.Notification +import io.getstream.video.android.core.RingingState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class IncomingCallNotificationAlertTest { + + @Test + fun `ringing notification loops and can alert again`() { + val flags = incomingCallNotificationFlags( + currentFlags = Notification.FLAG_ONLY_ALERT_ONCE, + ringingState = RingingState.Incoming(acceptedByMe = false), + ) + + assertNotEquals(0, flags and Notification.FLAG_INSISTENT) + assertEquals(0, flags and Notification.FLAG_ONLY_ALERT_ONCE) + } + + @Test + fun `accepted notification stops looping and alerts only once`() { + val flags = incomingCallNotificationFlags( + currentFlags = Notification.FLAG_INSISTENT, + ringingState = RingingState.Incoming(acceptedByMe = true), + ) + + assertEquals(0, flags and Notification.FLAG_INSISTENT) + assertNotEquals(0, flags and Notification.FLAG_ONLY_ALERT_ONCE) + } +} 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 ec49be03cc4..cef86a048c3 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 @@ -27,6 +27,7 @@ import io.getstream.android.push.permissions.NotificationPermissionHandler 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.IncomingRingtoneOwner import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient @@ -37,6 +38,7 @@ import io.getstream.video.android.core.notifications.dispatchers.NotificationDis import io.getstream.video.android.core.notifications.internal.service.CallService import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry +import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher import io.getstream.video.android.model.StreamCallId import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -48,6 +50,7 @@ import io.mockk.mockk import io.mockk.mockkConstructor import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.slot import io.mockk.unmockkAll import io.mockk.unmockkObject import io.mockk.verify @@ -104,6 +107,8 @@ class StreamDefaultNotificationHandlerTest { @RelaxedMockK internal lateinit var callBusyHandler: CallBusyHandler + private lateinit var serviceLauncher: ServiceLauncher + @Before fun setUp2() { MockKAnnotations.init(this, relaxUnitFun = true, relaxed = true) @@ -119,6 +124,8 @@ class StreamDefaultNotificationHandlerTest { every { StreamVideo.instance() } returns mockStreamVideo every { mockStreamVideo.state } returns mockState + serviceLauncher = mockk(relaxed = true) + every { mockState.serviceLauncher } returns serviceLauncher every { mockState.callConfigRegistry } returns mockCallConfigRegistry every { mockStreamVideo.callServiceConfigRegistry } returns mockCallConfigRegistry every { mockCallConfigRegistry.get(any()) } returns mockCallServiceConfig @@ -221,7 +228,18 @@ class StreamDefaultNotificationHandlerTest { fun `onRingingCall shows incoming call notification when caller is not busy with comprehensive verification`() { // Given val callDisplayName = "John Doe" + val notificationProvider = slot<(IncomingRingtoneOwner) -> Notification?>() every { callBusyHandler.isBusyWithAnotherCall(testCallId.cid) } returns false + every { + serviceLauncher.showIncomingCall( + callId = testCallId, + callDisplayName = callDisplayName, + callServiceConfiguration = any(), + isVideo = any(), + payload = payload, + notificationProvider = capture(notificationProvider), + ) + } returns Unit // Mock intent resolver calls every { mockIntentResolver.searchIncomingCallPendingIntent(testCallId, payload = payload) @@ -252,6 +270,7 @@ class StreamDefaultNotificationHandlerTest { // When testHandler.onRingingCall(testCallId, callDisplayName, payload) + notificationProvider.captured(IncomingRingtoneOwner.Legacy) // Then - Verify all intent resolver calls verify { mockIntentResolver.searchIncomingCallPendingIntent(testCallId, payload = payload) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt index 45c422c2b3c..3c0aeced063 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt @@ -159,7 +159,7 @@ class ServiceLauncherTest { callServiceConfiguration = callServiceConfig, isVideo = true, payload = emptyMap(), - notification = notification, + notificationProvider = { notification }, ) testScheduler.advanceUntilIdle() @@ -176,7 +176,7 @@ class ServiceLauncherTest { callServiceConfig, isVideo = false, payload = emptyMap(), - notification = notification, + notificationProvider = { notification }, ) coVerify(exactly = 0) { jetpackTelecomRepository.registerCall(any(), any(), any(), any()) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt new file mode 100644 index 00000000000..db33336e90a --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt @@ -0,0 +1,34 @@ +/* + * 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.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidVersionCodesTest { + + @Test + fun `SDK below Android 17 is not Android 17 or higher`() { + assertFalse(isAndroid17OrHigher(BUILD_VERSION_CODES_CINNAMON_BUN - 1)) + } + + @Test + fun `Android 17 SDK is Android 17 or higher`() { + assertTrue(isAndroid17OrHigher(BUILD_VERSION_CODES_CINNAMON_BUN)) + } +} From c6ada149ef0d12de2ed4ecc05552b2590e6c3681 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 8 Sep 2026 01:57:43 +0530 Subject: [PATCH 4/8] fix(core): keep legacy ringtone ownership in legacy flow --- .../notifications/IncomingCallNotificationPreparer.kt | 5 ++--- .../PreAndroid17IncomingCallCoordinator.kt | 10 ++-------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt index 2d535181ca9..04113e27bc9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt @@ -45,9 +45,8 @@ internal class IncomingCallNotificationPreparer( nonRingingNotification: Notification, ringingState: RingingState.Incoming, ): Notification { - val channelId = requireNotNull(NotificationCompat.getChannelId(nonRingingNotification)) { - "Incoming-call ringing requires a notification channel ID." - } + val channelId = NotificationCompat.getChannelId(nonRingingNotification) + ?: return nonRingingNotification return NotificationCompat.Builder(context, nonRingingNotification) .setChannelId(channelId) .build() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt index 5fb838a9dd1..223aad3defc 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt @@ -35,12 +35,11 @@ import io.getstream.video.android.core.notifications.internal.service.ShowIncomi import io.getstream.video.android.core.notifications.internal.service.StartServiceParam import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions -import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.core.utils.safeCallWithResult import io.getstream.video.android.model.StreamCallId import kotlinx.coroutines.launch -/** Coordinates the existing incoming-call service and optional Telecom registration path. */ +/** Coordinates the legacy incoming-call flow, where CallService owns the ringtone. */ internal class PreAndroid17IncomingCallCoordinator( private val context: Context, private val client: StreamVideoClient, @@ -55,12 +54,7 @@ internal class PreAndroid17IncomingCallCoordinator( @SuppressLint("MissingPermission", "NewApi") override fun showIncomingCall(request: IncomingCallRequest) { - val ringtoneOwner = if (isAndroid17OrHigher()) { - IncomingRingtoneOwner.Notification - } else { - IncomingRingtoneOwner.Legacy - } - val notification = request.notificationProvider(ringtoneOwner) + val notification = request.notificationProvider(IncomingRingtoneOwner.Legacy) val result = incomingCallPresenter.showIncomingCall( context = context, callId = request.callId, From eb39d59b74ad0ae19cc3b784a45abcf2e0224445 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 9 Sep 2026 19:33:20 +0530 Subject: [PATCH 5/8] store incoming ringtone owner in callState --- .../io/getstream/video/android/core/CallState.kt | 8 ++++++++ .../observers/CallServiceRingingStateObserver.kt | 10 +++++----- 2 files changed, 13 insertions(+), 5 deletions(-) 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 5f837b37f62..96ec8c090b1 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 @@ -836,6 +836,10 @@ public class CallState( @Volatile internal var callJoinInterceptorProvider: (() -> CallJoinInterceptor?)? = null + internal val incomingRingtoneOwner = MutableStateFlow( + IncomingRingtoneOwner.Legacy, + ) + fun handleEvent(event: VideoEvent) { logger.d { "[handleEvent] ${event::class.java.name.split(".").last()}" } @@ -1906,6 +1910,10 @@ public class CallState( this.atomicNotification.set(notification) } + internal fun updateIncomingRingtoneOwner(incomingRingtoneOwner: IncomingRingtoneOwner) { + this.incomingRingtoneOwner.value = incomingRingtoneOwner + } + @InternalStreamVideoApi fun setOwnCapabilities(ownCapability: List) { this._ownCapabilities.value = ownCapability 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 index 32102fc4027..6bccd4a8d7d 100644 --- 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 @@ -20,11 +20,11 @@ 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.IncomingRingtoneOwner 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.utils.isAndroid17OrHigher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -66,11 +66,11 @@ internal class CallServiceRingingStateObserver( * Handles incoming call state - plays ringtone and vibrates. */ private fun handleIncomingState(state: RingingState.Incoming) { - if (!state.acceptedByMe) { - if (isAndroid17OrHigher()) { - return - } + if (call.state.incomingRingtoneOwner.value == IncomingRingtoneOwner.Notification) { + return + } + if (!state.acceptedByMe) { // Start vibration if allowed if (shouldVibrate()) { val pattern = streamVideo.vibrationConfig.vibratePattern From 97fac5a53fcbd637351d3794ccbda5a70e0ada0c Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 9 Sep 2026 19:35:38 +0530 Subject: [PATCH 6/8] store incoming ringtone owner in callState --- .../CallServiceRingingStateObserver.kt | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) 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 index 6bccd4a8d7d..4d9cf815305 100644 --- 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 @@ -66,25 +66,23 @@ internal class CallServiceRingingStateObserver( * Handles incoming call state - plays ringtone and vibrates. */ private fun handleIncomingState(state: RingingState.Incoming) { - if (call.state.incomingRingtoneOwner.value == IncomingRingtoneOwner.Notification) { - return - } + if (call.state.incomingRingtoneOwner.value == IncomingRingtoneOwner.Legacy) { + if (!state.acceptedByMe) { + // Start vibration if allowed + if (shouldVibrate()) { + val pattern = streamVideo.vibrationConfig.vibratePattern + soundPlayer?.vibrate(pattern) + } - 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() } - - // 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() } } From b905157f9accf8f45b666797e5485c5ca73d97bf Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 9 Sep 2026 20:41:54 +0530 Subject: [PATCH 7/8] fix unit-tests --- .../CallServiceRingingStateObserverTest.kt | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) 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 index 18fd45d885f..44562def5e9 100644 --- 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 @@ -20,6 +20,7 @@ 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.IncomingRingtoneOwner import io.getstream.video.android.core.RingingState import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.model.RejectReason @@ -63,6 +64,8 @@ class CallServiceRingingStateObserverTest { private val ringingStateFlow = MutableStateFlow(RingingState.Idle) + private val incomingRingtoneOwnerFlow = + MutableStateFlow(IncomingRingtoneOwner.Legacy) private val onStopServiceInvoked = mutableListOf() @@ -75,6 +78,7 @@ class CallServiceRingingStateObserverTest { callState = mockk { every { this@mockk.ringingState } returns ringingStateFlow + every { this@mockk.incomingRingtoneOwner } returns incomingRingtoneOwnerFlow } call = mockk { @@ -123,28 +127,41 @@ class CallServiceRingingStateObserverTest { } @Test - fun `incoming not accepted plays sound and vibrates`() = runTest { + fun `legacy incoming call not accepted plays sound and vibrates`() = runTest { observer.observe { onStopServiceInvoked.add(Unit) } - advanceUntilIdle() ringingStateFlow.value = RingingState.Incoming(acceptedByMe = false) advanceUntilIdle() - advanceTimeBy(100L) - verify { + + verify(exactly = 1) { soundPlayer.vibrate(any()) soundPlayer.playCallSound(any(), true) } } @Test - fun `incoming accepted stops sound`() = runTest { + fun `notification owned incoming call does not play service ringtone`() = runTest { + incomingRingtoneOwnerFlow.value = IncomingRingtoneOwner.Notification + observer.observe { } + + ringingStateFlow.value = RingingState.Incoming(acceptedByMe = false) advanceUntilIdle() + verify(exactly = 0) { + soundPlayer.vibrate(any()) + soundPlayer.playCallSound(any(), any()) + } + } + + @Test + fun `legacy incoming call accepted stops sound`() = runTest { + observer.observe { } + ringingStateFlow.value = RingingState.Incoming(acceptedByMe = true) advanceUntilIdle() - advanceTimeBy(100L) - verify { soundPlayer.stopCallSound() } + + verify(exactly = 1) { soundPlayer.stopCallSound() } } @Test From 449457d79fb9298890456fd3e3e78110cbbf5f30 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Mon, 14 Sep 2026 13:59:55 +0530 Subject: [PATCH 8/8] [Telecom-4] Stabilize notification-owned incoming ringing (#1810) * fix(core): stabilize notification-owned incoming ringing * update the logic to delay incoming ringing updates * don't include DismissNotificationActivity in full screen pending intent * maintain abi changes * refactor code * [Telecom-5] use Telecom-first incoming calls on Android 17 (#1804) * fix(core): stabilize notification-owned incoming ringing * fix(core): use Telecom-first incoming calls on Android 17 * fix(core): select Android 17 low-importance ringing channel * test(core): cover Android 17 incoming-call flow * fix(core): retain observers for non-Telecom service routes * fix(core): preserve ongoing call service route * docs(core): describe incoming call ownership models * docs(core): describe incoming call coordinators * fix(core): preserve incoming notification on missed call * refactor(core): clarify pre-Android 17 observer check * fix(core): use Android 17 ringing channels in compatibility handler * test(core): align incoming call ownership expectations * chore(core): log notification alert configuration * 1. Restore OG code of DismissNotificationActivity.kt 2. Remove unnecessary method from TelecomPermissions.kt 3. Correctly use incoming-call-coordinator in ServiceLauncher.kt * 1. Update Tests * 1. Refactor code * 1. Fix loop ringing * 1. Update api * 1. Fix unit-tests * 1. Fix unit-tests * 1. fix tests --- .../api/stream-video-android-core.api | 3 + .../getstream/video/android/core/CallState.kt | 8 + .../core/ExternalCallRejectionHandler.kt | 3 +- .../android/core/IncomingRingtoneOwner.kt | 5 + .../video/android/core/StreamVideoBuilder.kt | 7 +- .../DefaultStreamIntentResolver.kt | 29 +++ .../IncomingCallNotificationPreparer.kt | 57 ----- .../core/notifications/NotificationConfig.kt | 35 ++- .../NotificationUpdateComparator.kt | 115 +++++++++ .../DefaultNotificationDispatcher.kt | 86 +++++++ .../handlers/ChannelInfoProvider.kt | 28 ++- .../CompatibilityStreamNotificationHandler.kt | 4 +- .../StreamDefaultNotificationHandler.kt | 153 +++++++++--- .../NotificationUpdateDeduplicator.kt | 49 ++++ .../internal/StreamNotificationManager.kt | 4 + .../internal/service/CallService.kt | 8 +- .../internal/service/CallServiceConfig.kt | 3 +- .../internal/service/IncomingCallPresenter.kt | 11 + .../internal/service/IncomingCallRequest.kt | 3 +- .../internal/service/ServiceLauncher.kt | 64 +++-- .../Android17IncomingCallCoordinator.kt | 152 ++++++++++++ .../IncomingCallCoordinator.kt | 9 + .../PreAndroid17IncomingCallCoordinator.kt | 20 +- .../internal/service/models/ServiceRoute.kt | 30 +++ .../observers/CallRejectionObserver.kt | 44 ++++ .../CallServiceNotificationUpdateObserver.kt | 81 +++++-- .../observers/NotificationUpdateObserver.kt | 30 +++ .../TelecomNotificationUpdateObserver.kt | 55 +++++ .../ForegroundServicePermissionManager.kt | 4 +- .../jetpack/JetpackTelecomRepository.kt | 23 ++ .../android/core/utils/AndroidVersionCodes.kt | 3 + .../src/main/res/values/strings.xml | 4 +- .../notifications/NotificationConfigTest.kt | 37 +++ .../IncomingCallNotificationAlertTest.kt | 54 +++++ .../RingingNotificationChannelTest.kt | 75 ++++++ .../StreamDefaultNotificationHandlerTest.kt | 96 +++++++- .../NotificationUpdateDeduplicatorTest.kt | 67 ++++++ .../CallServiceConfigAndroidVersionTest.kt | 61 +++++ .../internal/service/CallServiceTest.kt | 102 +++++++- .../service/IncomingCallPresenterTest.kt | 29 +++ .../internal/service/ServiceLauncherTest.kt | 109 +++++++++ .../Android17IncomingCallCoordinatorTest.kt | 224 ++++++++++++++++++ ...PreAndroid17IncomingCallCoordinatorTest.kt | 165 +++++++++++++ .../observers/CallRejectionObserverTest.kt | 84 +++++++ ...llServiceNotificationUpdateObserverTest.kt | 151 +++++++++++- .../TelecomNotificationUpdateObserverTest.kt | 91 +++++++ .../telecom/TelecomPermissionsTest.kt | 15 ++ .../jetpack/JetpackTelecomRepositoryTest.kt | 146 ++++++++++++ .../core/utils/AndroidVersionCodesTest.kt | 10 + 49 files changed, 2488 insertions(+), 158 deletions(-) delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationUpdateComparator.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicator.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinator.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceRoute.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserver.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserver.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/NotificationConfigTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/RingingNotificationChannelTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicatorTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigAndroidVersionTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinatorTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinatorTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserverTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserverTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepositoryTest.kt 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 3f2e807f280..61e9c1dce5d 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -13866,6 +13866,8 @@ public final class io/getstream/video/android/core/notifications/NotificationCon public fun ()V public fun (Ljava/util/List;Lkotlin/jvm/functions/Function0;Lio/getstream/video/android/core/notifications/NotificationHandler;ZLkotlin/jvm/functions/Function0;ZZLjava/util/Set;Lkotlin/jvm/functions/Function1;)V public synthetic fun (Ljava/util/List;Lkotlin/jvm/functions/Function0;Lio/getstream/video/android/core/notifications/NotificationHandler;ZLkotlin/jvm/functions/Function0;ZZLjava/util/Set;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/List;Lkotlin/jvm/functions/Function0;Lio/getstream/video/android/core/notifications/NotificationHandler;ZLkotlin/jvm/functions/Function0;ZZLjava/util/Set;Lkotlin/jvm/functions/Function1;J)V + public synthetic fun (Ljava/util/List;Lkotlin/jvm/functions/Function0;Lio/getstream/video/android/core/notifications/NotificationHandler;ZLkotlin/jvm/functions/Function0;ZZLjava/util/Set;Lkotlin/jvm/functions/Function1;JILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/List; public final fun component2 ()Lkotlin/jvm/functions/Function0; public final fun component3 ()Lio/getstream/video/android/core/notifications/NotificationHandler; @@ -13881,6 +13883,7 @@ public final class io/getstream/video/android/core/notifications/NotificationCon public final fun getAutoRegisterPushDevice ()Z public final fun getEnableCallNotificationUpdates ()Z public final fun getHideRingingNotificationInForeground ()Z + public final fun getIncomingRingingNotificationUpdateDelayMillis ()J public final fun getMediaNotificationCallTypes ()Ljava/util/Set; public final fun getNotificationHandler ()Lio/getstream/video/android/core/notifications/NotificationHandler; public final fun getNotificationUpdateTriggers ()Lkotlin/jvm/functions/Function1; 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 96ec8c090b1..a471e52099d 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 @@ -115,6 +115,7 @@ 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.service.models.ServiceRoute import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCall import io.getstream.video.android.core.permission.PermissionRequest @@ -840,6 +841,9 @@ public class CallState( IncomingRingtoneOwner.Legacy, ) + private val _serviceRoute = MutableStateFlow(ServiceRoute.UNDECIDED) + internal val serviceRoute: StateFlow = _serviceRoute.asStateFlow() + fun handleEvent(event: VideoEvent) { logger.d { "[handleEvent] ${event::class.java.name.split(".").last()}" } @@ -1947,6 +1951,10 @@ public class CallState( activeStateGate.cleanup() cancelTimeout() } + + internal fun updateServiceRoute(route: ServiceRoute) { + _serviceRoute.value = route + } } private fun MemberResponse.toMemberState(): MemberState { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ExternalCallRejectionHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ExternalCallRejectionHandler.kt index f2a45e4af02..2c1aa5ca62f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ExternalCallRejectionHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ExternalCallRejectionHandler.kt @@ -23,7 +23,6 @@ import io.getstream.log.taggedLogger import io.getstream.result.Result import io.getstream.video.android.core.model.RejectReason import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController -import io.getstream.video.android.model.StreamCallId internal class ExternalCallRejectionHandler() { private val logger by taggedLogger("CallRejectionHandler") @@ -51,7 +50,7 @@ internal class ExternalCallRejectionHandler() { logger.d { "[onRejectCall] source:$source, #ringing; callId: ${call.id}, action: ${intent.action}" } StreamVideo.instance().state.serviceLauncher.removeIncomingCall( - StreamCallId.fromCallCid(call.cid), + call, StreamVideo.instance().state.callConfigRegistry.get(call.type), ) when (source) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt index bfb823f576c..b2021122252 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/IncomingRingtoneOwner.kt @@ -16,7 +16,12 @@ package io.getstream.video.android.core +/** Identifies which component owns incoming-call ringing. */ internal sealed interface IncomingRingtoneOwner { + + /** The incoming-call notification produces the ringtone and vibration. */ data object Notification : IncomingRingtoneOwner + + /** The SDK sound and vibration player produces the ringtone and vibration. */ data object Legacy : IncomingRingtoneOwner } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index 372ee52db7d..d9539817d0e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -46,6 +46,7 @@ import io.getstream.video.android.core.sounds.defaultResourcesRingingConfig import io.getstream.video.android.core.sounds.disableVibrationConfig import io.getstream.video.android.core.sounds.toSounds import io.getstream.video.android.core.user.StreamUserRepositoryImpl +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.model.ApiKey import io.getstream.video.android.model.User import io.getstream.video.android.model.UserToken @@ -318,7 +319,11 @@ public class StreamVideoBuilder @JvmOverloads constructor( enableStatsCollection = enableStatsReporting, vibrationConfig = vibrationConfig, enableStereoForSubscriber = enableStereoForSubscriber, - telecomConfig = telecomConfig, + telecomConfig = telecomConfig ?: if (isAndroid17OrHigher()) { + TelecomConfig(context.packageName) + } else { + null + }, tokenRepository = tokenRepository, rejectCallWhenBusy = rejectCallWhenBusy, ) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultStreamIntentResolver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultStreamIntentResolver.kt index f43bd47e8a5..99b8e3cb081 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultStreamIntentResolver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultStreamIntentResolver.kt @@ -62,6 +62,35 @@ public class DefaultStreamIntentResolver( ) } + /** + * Creates the incoming-call full-screen intent without the notification-dismiss trampoline. + * Android can launch this intent without user interaction, so launching it must not dismiss a + * notification that owns the incoming ringtone. + */ + internal fun searchIncomingCallFullScreenPendingIntent( + callId: StreamCallId, + notificationId: Int = callId.getNotificationId(NotificationType.Incoming), + payload: Map, + ): PendingIntent? { + val intent = Intent(NotificationHandler.ACTION_INCOMING_CALL) + .putExtras( + notificationIntentBundleResolver.getIncomingCallBundle( + callId, + notificationId, + payload, + ), + ) + return searchResolveInfo { context.packageManager.queryIntentActivities(intent, 0) } + ?.let { resolveInfo -> + PendingIntent.getActivity( + context, + 0, + buildComponentIntent(intent, resolveInfo, callId), + DefaultNotificationHandler.PENDING_INTENT_FLAG, + ) + } + } + /** * Search for an activity that is used for outgoing calls. * Calls are considered outgoing until the call is accepted. diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt deleted file mode 100644 index 04113e27bc9..00000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/IncomingCallNotificationPreparer.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 - -import android.app.Notification -import androidx.core.app.NotificationCompat -import io.getstream.video.android.core.IncomingRingtoneOwner -import io.getstream.video.android.core.RingingState -import io.getstream.video.android.core.StreamVideoClient -import io.getstream.video.android.core.notifications.handlers.incomingCallNotificationFlags - -internal class IncomingCallNotificationPreparer( - private val streamVideo: StreamVideoClient, -) { - - private val context = streamVideo.context - - fun prepare( - nonRingingNotification: Notification, - owner: IncomingRingtoneOwner, - ringingState: RingingState.Incoming, - ): Notification = when (owner) { - IncomingRingtoneOwner.Notification -> prepareRingingNotification( - nonRingingNotification, - ringingState, - ) - IncomingRingtoneOwner.Legacy -> nonRingingNotification - } - - private fun prepareRingingNotification( - nonRingingNotification: Notification, - ringingState: RingingState.Incoming, - ): Notification { - val channelId = NotificationCompat.getChannelId(nonRingingNotification) - ?: return nonRingingNotification - return NotificationCompat.Builder(context, nonRingingNotification) - .setChannelId(channelId) - .build() - .apply { - flags = incomingCallNotificationFlags(flags, ringingState) - } - } -} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationConfig.kt index d7f96f458ab..11476453668 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationConfig.kt @@ -52,4 +52,37 @@ public data class NotificationConfig( */ @ExperimentalStreamVideoApi val notificationUpdateTriggers: (Call) -> Flow<*>? = { _ -> null }, -) +) { + /** + * Delay before replacing an unaccepted incoming-call notification whose channel owns ringing. + * Android may stop an insistent alert when that notification is immediately replaced. + * Set this to `0` to disable the delay. + */ + public var incomingRingingNotificationUpdateDelayMillis: Long = 1_300L + private set + + public constructor( + pushDeviceGenerators: List = emptyList(), + requestPermissionOnAppLaunch: () -> Boolean = { true }, + notificationHandler: NotificationHandler = NoOpNotificationHandler, + autoRegisterPushDevice: Boolean = true, + requestPermissionOnDeviceRegistration: () -> Boolean = { true }, + hideRingingNotificationInForeground: Boolean = false, + enableCallNotificationUpdates: Boolean = true, + mediaNotificationCallTypes: Set = hashSetOf(CallType.Livestream.name), + notificationUpdateTriggers: (Call) -> Flow<*>? = { _ -> null }, + incomingRingingNotificationUpdateDelayMillis: Long, + ) : this( + pushDeviceGenerators = pushDeviceGenerators, + requestPermissionOnAppLaunch = requestPermissionOnAppLaunch, + notificationHandler = notificationHandler, + autoRegisterPushDevice = autoRegisterPushDevice, + requestPermissionOnDeviceRegistration = requestPermissionOnDeviceRegistration, + hideRingingNotificationInForeground = hideRingingNotificationInForeground, + enableCallNotificationUpdates = enableCallNotificationUpdates, + mediaNotificationCallTypes = mediaNotificationCallTypes, + notificationUpdateTriggers = notificationUpdateTriggers, + ) { + this.incomingRingingNotificationUpdateDelayMillis = incomingRingingNotificationUpdateDelayMillis + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationUpdateComparator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationUpdateComparator.kt new file mode 100644 index 00000000000..7e980d15aa6 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationUpdateComparator.kt @@ -0,0 +1,115 @@ +/* + * 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 + +import android.app.Notification +import android.app.PendingIntent +import android.os.Bundle +import androidx.core.app.NotificationCompat +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RingingState + +internal fun interface NotificationUpdateComparator { + fun areEquivalent( + call: Call, + ringingState: RingingState, + existing: Notification, + updated: Notification, + ): Boolean +} + +internal object DefaultNotificationUpdateComparator : NotificationUpdateComparator { + override fun areEquivalent( + call: Call, + ringingState: RingingState, + existing: Notification, + updated: Notification, + ): Boolean { + if (existing.hasCustomRemoteViews() || updated.hasCustomRemoteViews()) return false + return existing.toContentFingerprint() == updated.toContentFingerprint() + } + + private fun Notification.hasCustomRemoteViews(): Boolean = + contentView != null || bigContentView != null || headsUpContentView != null + + @Suppress("DEPRECATION") + private fun Notification.toContentFingerprint(): List = listOf( + NotificationCompat.getChannelId(this), + icon, + iconLevel, + tickerText?.toString(), + number, + contentIntent, + deleteIntent, + fullScreenIntent, + sound?.toString(), + audioAttributes?.usage, + audioAttributes?.contentType, + vibrate?.toList(), + ledARGB, + ledOnMS, + ledOffMS, + defaults, + flags, + priority, + category, + group, + sortKey, + visibility, + color, + actions(), + extras.toComparableMap(), + ) + + private fun Notification.actions(): List> = buildList { + repeat(NotificationCompat.getActionCount(this@actions)) { index -> + NotificationCompat.getAction(this@actions, index)?.let { action -> + add( + listOf( + action.icon, + action.title?.toString(), + action.actionIntent as PendingIntent?, + action.semanticAction, + action.showsUserInterface, + action.allowGeneratedReplies, + ), + ) + } + } + } + + @Suppress("DEPRECATION") + private fun Bundle.toComparableMap(): Map = + keySet().sorted().associateWith { key -> get(key).toComparableValue() } + + private fun Any?.toComparableValue(): Any? = when (this) { + null -> null + is CharSequence -> toString() + is Bundle -> toComparableMap() + is Array<*> -> map { it.toComparableValue() } + is BooleanArray -> toList() + is ByteArray -> toList() + is CharArray -> toList() + is DoubleArray -> toList() + is FloatArray -> toList() + is IntArray -> toList() + is LongArray -> toList() + is ShortArray -> toList() + is Collection<*> -> map { it.toComparableValue() } + else -> this + } +} 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 88725bf12de..291cf254ad5 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 @@ -19,6 +19,7 @@ package io.getstream.video.android.core.notifications.dispatchers import android.Manifest import android.app.Notification import androidx.annotation.RequiresPermission +import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.getstream.log.taggedLogger import io.getstream.video.android.core.StreamVideo @@ -33,9 +34,94 @@ class DefaultNotificationDispatcher( @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) override fun notify(streamCallId: StreamCallId, id: Int, notification: Notification) { logger.d { "[notify] callId: ${streamCallId.id}, notificationId: $id" } + logNotificationDebugInfo(streamCallId, id, notification) StreamVideo.instanceOrNull()?.call(streamCallId.type, streamCallId.id) ?.state?.updateNotification(id, notification) notificationManager.notify(id, notification) } + + @Suppress("DEPRECATION") + private fun logNotificationDebugInfo( + streamCallId: StreamCallId, + notificationId: Int, + notification: Notification, + ) { + val channelId = NotificationCompat.getChannelId(notification) + val channel = channelId?.let(notificationManager::getNotificationChannelCompat) + + logger.d { + "[NotificationDebug] " + + "callId=${streamCallId.id}, " + + "notificationId=$notificationId, " + + "notification=${NotificationDebugSnapshot( + channelId = channelId, + flagsHex = "0x${Integer.toHexString(notification.flags)}", + isInsistent = notification.hasFlag(Notification.FLAG_INSISTENT), + onlyAlertOnce = notification.hasFlag(Notification.FLAG_ONLY_ALERT_ONCE), + isOngoing = notification.hasFlag(Notification.FLAG_ONGOING_EVENT), + autoCancel = notification.hasFlag(Notification.FLAG_AUTO_CANCEL), + category = notification.category, + priority = notification.priority, + visibility = notification.visibility, + defaults = notification.defaults, + notificationSound = notification.sound?.toString(), + notificationVibration = notification.vibrate?.contentToString(), + notificationAudioUsage = notification.audioAttributes?.usage, + notificationAudioContentType = notification.audioAttributes?.contentType, + hasFullScreenIntent = notification.fullScreenIntent != null, + hasContentIntent = notification.contentIntent != null, + actionCount = notification.actions?.size ?: 0, + group = notification.group, + isGroupSummary = notification.hasFlag(Notification.FLAG_GROUP_SUMMARY), + )}, " + + "channel=${NotificationChannelDebugSnapshot( + id = channel?.id, + importance = channel?.importance, + sound = channel?.sound?.toString(), + vibrationEnabled = channel?.shouldVibrate(), + vibrationPattern = channel?.vibrationPattern?.contentToString(), + audioUsage = channel?.audioAttributes?.usage, + audioContentType = channel?.audioAttributes?.contentType, + bypassDnd = channel?.canBypassDnd(), + lockscreenVisibility = channel?.lockscreenVisibility, + )}" + } + } + + private fun Notification.hasFlag(flag: Int): Boolean = flags and flag != 0 + + private data class NotificationDebugSnapshot( + val channelId: String?, + val flagsHex: String, + val isInsistent: Boolean, + val onlyAlertOnce: Boolean, + val isOngoing: Boolean, + val autoCancel: Boolean, + val category: String?, + val priority: Int, + val visibility: Int, + val defaults: Int, + val notificationSound: String?, + val notificationVibration: String?, + val notificationAudioUsage: Int?, + val notificationAudioContentType: Int?, + val hasFullScreenIntent: Boolean, + val hasContentIntent: Boolean, + val actionCount: Int, + val group: String?, + val isGroupSummary: Boolean, + ) + + private data class NotificationChannelDebugSnapshot( + val id: String?, + val importance: Int?, + val sound: String?, + val vibrationEnabled: Boolean?, + val vibrationPattern: String?, + val audioUsage: Int?, + val audioContentType: Int?, + val bypassDnd: Boolean?, + val lockscreenVisibility: Int?, + ) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt index 9df1bcca376..85195c72b68 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt @@ -20,11 +20,35 @@ import android.app.NotificationManager import android.content.Context import android.media.AudioAttributes import android.net.Uri +import android.os.Build import androidx.annotation.StringRes import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat +import io.getstream.video.android.core.R +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.core.utils.safeCall +internal fun shouldNotificationOwnIncomingRingtone( + sdkInt: Int = Build.VERSION.SDK_INT, +): Boolean = isAndroid17OrHigher(sdkInt) + +@StringRes +internal fun defaultIncomingCallChannelIdRes(sdkInt: Int = Build.VERSION.SDK_INT): Int = + if (isAndroid17OrHigher(sdkInt)) { + R.string.stream_video_incoming_call_ringing_notification_channel_id + } else { + R.string.stream_video_incoming_call_notification_channel_id + } + +@StringRes +internal fun defaultIncomingCallLowImportanceChannelIdRes( + sdkInt: Int = Build.VERSION.SDK_INT, +): Int = if (isAndroid17OrHigher(sdkInt)) { + R.string.stream_video_incoming_call_ringing_low_priority_notification_channel_id +} else { + R.string.stream_video_incoming_call_low_priority_notification_channel_id +} + /** * Provides a way to create a custom channel for the notification. * @@ -124,7 +148,9 @@ data class StreamNotificationChannelInfo( /** * Provides the channel information for the notification. * - * @param incomingCallChannel High importance channel for incoming calls (Notification will pop-up on screen) + * @param incomingCallChannel High importance channel for incoming calls. On Android 17 and above, + * notification-owned ringing requires a channel ID that has not previously been created without + * sound. Integrators supplying a custom channel should migrate to a new ID for Android 17. * @param ongoingCallChannel Low importance channel for ongoing calls. * @param outgoingCallChannel Low importance channel for call setup. * @param missedCallChannel High importance channel for missed call. 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 c03c374bfcc..5ede94cce71 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 @@ -132,7 +132,7 @@ constructor( notificationChannels: StreamNotificationChannels = StreamNotificationChannels( incomingCallChannel = createChannelInfoFromResIds( application.applicationContext, - R.string.stream_video_incoming_call_notification_channel_id, + defaultIncomingCallChannelIdRes(), R.string.stream_video_incoming_call_notification_channel_title, R.string.stream_video_incoming_call_notification_channel_description, NotificationManager.IMPORTANCE_HIGH, @@ -167,7 +167,7 @@ constructor( ), incomingCallLowImportanceChannel = createChannelInfoFromResIds( application.applicationContext, - R.string.stream_video_incoming_call_low_priority_notification_channel_id, + defaultIncomingCallLowImportanceChannelIdRes(), R.string.stream_video_incoming_call_notification_channel_title, R.string.stream_video_incoming_call_low_priority_notification_channel_description, NotificationManager.IMPORTANCE_DEFAULT, 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 7954c2f207a..b278770a96c 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 @@ -38,6 +38,7 @@ import io.getstream.android.push.permissions.NotificationPermissionHandler 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.IncomingRingtoneOwner import io.getstream.video.android.core.MemberState import io.getstream.video.android.core.ParticipantState import io.getstream.video.android.core.R @@ -48,7 +49,6 @@ import io.getstream.video.android.core.call.CallBusyHandler import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver -import io.getstream.video.android.core.notifications.IncomingCallNotificationPreparer 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 @@ -89,7 +89,7 @@ constructor( private val notificationChannels: StreamNotificationChannels = StreamNotificationChannels( incomingCallChannel = createChannelInfoFromResIds( application.applicationContext, - R.string.stream_video_incoming_call_notification_channel_id, + defaultIncomingCallChannelIdRes(), R.string.stream_video_incoming_call_notification_channel_title, R.string.stream_video_incoming_call_notification_channel_description, NotificationManager.IMPORTANCE_HIGH, @@ -124,7 +124,7 @@ constructor( ), incomingCallLowImportanceChannel = createChannelInfoFromResIds( application.applicationContext, - R.string.stream_video_incoming_call_low_priority_notification_channel_id, + defaultIncomingCallLowImportanceChannelIdRes(), R.string.stream_video_incoming_call_notification_channel_title, R.string.stream_video_incoming_call_low_priority_notification_channel_description, NotificationManager.IMPORTANCE_DEFAULT, @@ -170,11 +170,7 @@ constructor( ) { logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" } val streamVideo = StreamVideo.instance() as StreamVideoClient - val notificationPreparer = IncomingCallNotificationPreparer(streamVideo) - if (shouldShowIncomingCallNotification( - (streamVideo as StreamVideoClient).callBusyHandler, - callId.cid, - ) + if (shouldShowIncomingCallNotification(streamVideo.callBusyHandler, callId.cid) ) { val canRunService = streamVideo.callServiceConfigRegistry.get(callId.type).runCallServiceInForeground @@ -185,7 +181,7 @@ constructor( streamVideo.state.callConfigRegistry.get(callId.type), isVideo = isVideoCall(callId, payload), payload = payload, - notificationProvider = { owner -> + notificationProvider = { val ringingState = RingingState.Incoming() getRingingCallNotification( ringingState, @@ -193,9 +189,7 @@ constructor( callDisplayName, shouldHaveContentIntent = true, payload, - )?.let { notification -> - notificationPreparer.prepare(notification, owner, ringingState) - } + ) }, ) } @@ -232,6 +226,7 @@ constructor( payload: Map, ) { logger.d { "[onMissedCall] #ringing; callId: ${callId.id}" } + notificationManager.cancel(callId.getNotificationId(NotificationType.Incoming)) val notificationId = callId.getNotificationId(NotificationType.Missed) getMissedCallNotification( callId, @@ -330,10 +325,15 @@ constructor( "[getRingingCallNotification] callId: ${callId.id}, ringingState: $ringingState, callDisplayName: $callDisplayName, shouldHaveContentIntent: $shouldHaveContentIntent" } return if (ringingState is RingingState.Incoming) { - val fullScreenPendingIntent = intentResolver.searchIncomingCallPendingIntent( + val contentPendingIntent = intentResolver.searchIncomingCallPendingIntent( callId, payload = payload, ) + val fullScreenPendingIntent = searchIncomingCallFullScreenPendingIntent( + callId, + payload, + contentPendingIntent, + ) val acceptCallPendingIntent = intentResolver.searchAcceptCallPendingIntent( callId, payload = payload, @@ -342,7 +342,7 @@ constructor( callId, payload = payload, ) - + var ringtoneOwner: IncomingRingtoneOwner = IncomingRingtoneOwner.Legacy val streamVideo = StreamVideo.instanceOrNull() streamVideo?.let { streamVideoInstance -> val call = streamVideoInstance.call(callId.type, callId.id) @@ -354,17 +354,37 @@ constructor( map[IncomingNotificationAction.Reject] = pendingIntent } call.state.incomingNotificationData = IncomingNotificationData(map) + + ringtoneOwner = call.state.incomingRingtoneOwner.value } - if (fullScreenPendingIntent != null && acceptCallPendingIntent != null && rejectCallPendingIntent != null) { - getIncomingCallNotification( + if ( + contentPendingIntent != null && + fullScreenPendingIntent != null && + acceptCallPendingIntent != null && + rejectCallPendingIntent != null + ) { + getIncomingCallNotificationInternal( + ringtoneOwner, + ringingState, fullScreenPendingIntent, + contentPendingIntent, acceptCallPendingIntent, rejectCallPendingIntent, callDisplayName, - shouldHaveContentIntent, payload, - ) + shouldHaveContentIntent, + ) { + initialNotificationBuilderInterceptor.onBuildIncomingCallNotification( + this, + fullScreenPendingIntent, + acceptCallPendingIntent, + rejectCallPendingIntent, + callDisplayName, + shouldHaveContentIntent, + payload, + ) + } } else { logger.e { "Ringing call notification not shown, one of the intents is null." } null @@ -395,22 +415,43 @@ constructor( } } + private fun searchIncomingCallFullScreenPendingIntent( + callId: StreamCallId, + payload: Map, + contentPendingIntent: PendingIntent?, + ): PendingIntent? = + if (isAndroid17OrHigher() && intentResolver is DefaultStreamIntentResolver) { + intentResolver.searchIncomingCallFullScreenPendingIntent( + callId = callId, + payload = payload, + ) + } else { + contentPendingIntent + } + private inline fun getRingingCallNotificationInternal( ringingState: RingingState, callId: StreamCallId, callDisplayName: String?, payload: Map, shouldHaveContentIntent: Boolean, + ringtoneOwner: IncomingRingtoneOwner, + existingChannelId: String? = null, intercept: NotificationCompat.Builder.() -> NotificationCompat.Builder, ): Notification? { logger.d { "[getRingingCallNotificationInternal] callId: ${callId.id}, ringingState: $ringingState, callDisplayName: $callDisplayName, shouldHaveContentIntent: $shouldHaveContentIntent" } return if (ringingState is RingingState.Incoming) { - val fullScreenPendingIntent = intentResolver.searchIncomingCallPendingIntent( + val contentPendingIntent = intentResolver.searchIncomingCallPendingIntent( callId, payload = payload, ) + val fullScreenPendingIntent = searchIncomingCallFullScreenPendingIntent( + callId, + payload, + contentPendingIntent, + ) val acceptCallPendingIntent = intentResolver.searchAcceptCallPendingIntent( callId, payload = payload, @@ -420,14 +461,23 @@ constructor( payload = payload, ) - if (fullScreenPendingIntent != null && acceptCallPendingIntent != null && rejectCallPendingIntent != null) { + if ( + contentPendingIntent != null && + fullScreenPendingIntent != null && + acceptCallPendingIntent != null && + rejectCallPendingIntent != null + ) { getIncomingCallNotificationInternal( + ringtoneOwner, + ringingState, fullScreenPendingIntent, + contentPendingIntent, acceptCallPendingIntent, rejectCallPendingIntent, callDisplayName, payload, shouldHaveContentIntent, + existingChannelId, intercept, ) } else { @@ -461,24 +511,37 @@ constructor( } private inline fun getIncomingCallNotificationInternal( + ringtoneOwner: IncomingRingtoneOwner, + ringingState: RingingState.Incoming, fullScreenPendingIntent: PendingIntent, + contentPendingIntent: PendingIntent, acceptCallPendingIntent: PendingIntent, rejectCallPendingIntent: PendingIntent, callerName: String?, payload: Map, shouldHaveContentIntent: Boolean, + existingChannelId: String? = null, intercept: NotificationCompat.Builder.() -> NotificationCompat.Builder, ): Notification { logger.d { "[getIncomingCallNotificationInternal] callerName: $callerName, shouldHaveContentIntent: $shouldHaveContentIntent" } - val notificationChannel = when { - isAppInForeground() && hideRingingNotificationInForeground -> + val notificationChannel = when (existingChannelId) { + notificationChannels.incomingCallChannel.id -> notificationChannels.incomingCallChannel + notificationChannels.incomingCallLowImportanceChannel.id -> notificationChannels.incomingCallLowImportanceChannel - else -> notificationChannels.incomingCallChannel + else -> when { + isAppInForeground() && hideRingingNotificationInForeground -> + notificationChannels.incomingCallLowImportanceChannel + else -> notificationChannels.incomingCallChannel + } } - return ensureIncomingCallChannelAndBuildNotification(notificationChannel) { + return ensureIncomingCallChannelAndBuildNotification( + notificationChannel, + ringtoneOwner, + ringingState, + ) { priority = if (hideRingingNotificationInForeground) { NotificationCompat.PRIORITY_LOW } else { @@ -494,7 +557,7 @@ constructor( setCategory(NotificationCompat.CATEGORY_CALL) setFullScreenIntent(fullScreenPendingIntent, true) if (shouldHaveContentIntent) { - setContentIntent(fullScreenPendingIntent) + setContentIntent(contentPendingIntent) } else { val emptyIntent = PendingIntent.getActivity( application, @@ -510,6 +573,10 @@ constructor( } } + /** + * This is a dead method, it is not invoked from within SDK. + * It is present here for compatibility. Should be removed in v2 + */ override fun getIncomingCallNotification( fullScreenPendingIntent: PendingIntent, acceptCallPendingIntent: PendingIntent, @@ -522,6 +589,9 @@ constructor( "[getIncomingCallNotification] callerName: $callerName, shouldHaveContentIntent: $shouldHaveContentIntent" } return getIncomingCallNotificationInternal( + IncomingRingtoneOwner.Legacy, + RingingState.Incoming(), + fullScreenPendingIntent, fullScreenPendingIntent, acceptCallPendingIntent, rejectCallPendingIntent, @@ -596,10 +666,15 @@ constructor( else -> notificationChannels.incomingCallChannel } - val fullScreenPendingIntent = intentResolver.searchIncomingCallPendingIntent( + val contentPendingIntent = intentResolver.searchIncomingCallPendingIntent( callId, payload = emptyMap(), ) + val fullScreenPendingIntent = searchIncomingCallFullScreenPendingIntent( + callId = callId, + payload = emptyMap(), + contentPendingIntent = contentPendingIntent, + ) if (fullScreenPendingIntent == null) { logger.w { @@ -619,7 +694,7 @@ constructor( setOngoing(true) setCategory(NotificationCompat.CATEGORY_CALL) setFullScreenIntent(fullScreenPendingIntent, true) - setContentIntent(fullScreenPendingIntent) + setContentIntent(contentPendingIntent) } } @@ -930,15 +1005,23 @@ constructor( val callId = StreamCallId.fromCallCid(call.cid) val payload = emptyMap() return getRingingCallNotificationInternal( + ringtoneOwner = call.state.incomingRingtoneOwner.value, ringingState = call.state.ringingState.value, callId = callId, callDisplayName = callDisplayName, shouldHaveContentIntent = true, + existingChannelId = (call.state.atomicNotification.get() as? Notification) + ?.let(NotificationCompat::getChannelId), intercept = { - val fullScreenPendingIntent = intentResolver.searchIncomingCallPendingIntent( + val contentPendingIntent = intentResolver.searchIncomingCallPendingIntent( callId, payload = payload, ) + val fullScreenPendingIntent = searchIncomingCallFullScreenPendingIntent( + callId = callId, + payload = payload, + contentPendingIntent = contentPendingIntent, + ) val acceptCallPendingIntent = intentResolver.searchAcceptCallPendingIntent( callId, payload = payload, @@ -1055,6 +1138,7 @@ constructor( } val payload = emptyMap() return getRingingCallNotificationInternal( + ringtoneOwner = call.state.incomingRingtoneOwner.value, ringingState = call.state.ringingState.value, callId = StreamCallId.fromCallCid(call.cid), callDisplayName = callDisplayName, @@ -1245,10 +1329,12 @@ constructor( private inline fun ensureIncomingCallChannelAndBuildNotification( channelInfo: StreamNotificationChannelInfo, + ringtoneOwner: IncomingRingtoneOwner, + ringingState: RingingState.Incoming, builder: NotificationCompat.Builder.() -> NotificationCompat.Builder, ): Notification { val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient - if (isAndroid17OrHigher() && streamVideo != null) { + if (streamVideo != null && ringtoneOwner == IncomingRingtoneOwner.Notification) { val audioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) @@ -1265,7 +1351,14 @@ constructor( } else { channelInfo.create(notificationManager) } - return NotificationCompat.Builder(application, channelInfo.id).let(builder).build() + return NotificationCompat.Builder(application, channelInfo.id) + .let(builder) + .build() + .apply { + if (ringtoneOwner == IncomingRingtoneOwner.Notification) { + flags = incomingCallNotificationFlags(flags, ringingState) + } + } } @OptIn(ExperimentalStreamVideoApi::class) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicator.kt new file mode 100644 index 00000000000..83b56730ccd --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicator.kt @@ -0,0 +1,49 @@ +/* + * 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.app.Notification +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.notifications.NotificationUpdateComparator + +internal class NotificationUpdateDeduplicator( + private val comparator: NotificationUpdateComparator, +) { + private val logger by taggedLogger("NotificationUpdateDeduplicator") + + fun isDuplicate( + call: Call, + ringingState: RingingState, + existingNotificationId: Int?, + existingNotification: Notification?, + updatedNotificationId: Int, + updatedNotification: Notification, + ): Boolean { + if (ringingState !is RingingState.Incoming || ringingState.acceptedByMe) return false + if (existingNotification == null || existingNotificationId != updatedNotificationId) { + return false + } + return runCatching { + comparator.areEquivalent(call, ringingState, existingNotification, updatedNotification) + }.getOrElse { error -> + logger.e(error) { "[isDuplicate] Comparison failed for call: ${call.cid}" } + false + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/StreamNotificationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/StreamNotificationManager.kt index 0e1970b7607..896eb14a772 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/StreamNotificationManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/StreamNotificationManager.kt @@ -35,6 +35,7 @@ import io.getstream.result.Error import io.getstream.result.Result import io.getstream.result.flatMapSuspend import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.notifications.DefaultNotificationUpdateComparator import io.getstream.video.android.core.notifications.NotificationConfig import io.getstream.video.android.core.notifications.NotificationHandler import io.getstream.video.android.core.notifications.handlers.CompatibilityStreamNotificationHandler @@ -54,6 +55,9 @@ internal class StreamNotificationManager private constructor( private val notificationPermissionManager: NotificationPermissionManager?, ) : NotificationHandler by notificationConfig.notificationHandler { + internal val notificationUpdateDeduplicator: NotificationUpdateDeduplicator = + NotificationUpdateDeduplicator(DefaultNotificationUpdateComparator) + suspend fun registerPushDevice() { logger.d { "[registerPushDevice] no args" } // first get a push device generator that works for this device 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 1f7f485e66a..27f40a4f7db 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 @@ -40,10 +40,12 @@ import io.getstream.video.android.core.notifications.internal.service.controller import io.getstream.video.android.core.notifications.internal.service.managers.CallServiceLifecycleManager import io.getstream.video.android.core.notifications.internal.service.managers.CallServiceNotificationManager import io.getstream.video.android.core.notifications.internal.service.models.CallIntentParams +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute 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.isPreAndroid17 import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.core.utils.startForegroundWithServiceType import io.getstream.video.android.model.StreamCallId @@ -535,6 +537,10 @@ internal open class CallService : Service() { } private fun observeCall(call: Call, streamVideo: StreamVideoClient) { + val shouldUseCallServiceObservers = isPreAndroid17() || + call.state.serviceRoute.value != ServiceRoute.TELECOM + if (!shouldUseCallServiceObservers) return + CallServiceRingingStateObserver( call, serviceStateController.soundPlayer, @@ -570,7 +576,7 @@ internal open class CallService : Service() { foregroundServiceType, ) } - .observe(baseContext) + .observe() } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt index 14c7d60b2f7..d7ff2b29a88 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfig.kt @@ -18,6 +18,7 @@ package io.getstream.video.android.core.notifications.internal.service import android.media.AudioAttributes import io.getstream.video.android.core.moderations.ModerationConfig +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.model.StreamCallId // Constants @@ -47,7 +48,7 @@ public data class CallServiceConfig( Pair(ANY_MARKER, CallService::class.java), ), val serviceClass: Class<*> = CallService::class.java, - val enableTelecom: Boolean = false, + val enableTelecom: Boolean = isAndroid17OrHigher(), val moderationConfig: ModerationConfig = ModerationConfig(), ) 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 10f756da370..d4b4185454f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallPresenter.kt @@ -62,6 +62,17 @@ internal class IncomingCallPresenter(private val client: StreamVideo, private va return result } + fun showIncomingCallNotification( + context: Context, + callId: StreamCallId, + notification: Notification?, + ): ShowIncomingCallResult = showNotification( + context = context, + notification = notification, + callId = callId, + error = null, + ) + // ---------------------------------- // Decision branches // ---------------------------------- diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt index 55660edc01b..957f5772495 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt @@ -17,7 +17,6 @@ package io.getstream.video.android.core.notifications.internal.service import android.app.Notification -import io.getstream.video.android.core.IncomingRingtoneOwner import io.getstream.video.android.model.StreamCallId internal data class IncomingCallRequest( @@ -26,5 +25,5 @@ internal data class IncomingCallRequest( val callServiceConfiguration: CallServiceConfig, val isVideo: Boolean, val payload: Map, - val notificationProvider: (IncomingRingtoneOwner) -> Notification?, + val notificationProvider: () -> Notification?, ) 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 425e575ce37..8a01e81eddd 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 @@ -39,16 +39,18 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call -import io.getstream.video.android.core.IncomingRingtoneOwner import io.getstream.video.android.core.StreamVideoClient 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.incomingcallcoordinator.IncomingCallCoordinator +import io.getstream.video.android.core.notifications.internal.service.incomingcallcoordinator.Android17IncomingCallCoordinator import io.getstream.video.android.core.notifications.internal.service.incomingcallcoordinator.PreAndroid17IncomingCallCoordinator +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCall import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCallAction +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.model.StreamCallId import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -65,16 +67,25 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { private val telecomPermissions = TelecomPermissions() private val jetpackTelecomRepositoryProvider = JetpackTelecomRepositoryProvider(client) private val throttler = Throttler() - private val incomingCallCoordinator: IncomingCallCoordinator = - PreAndroid17IncomingCallCoordinator( - context = context, - client = client, - incomingCallPresenter = incomingCallPresenter, - serviceIntentBuilder = serviceIntentBuilder, - telecomPermissions = telecomPermissions, - telecomHelper = telecomHelper, - jetpackTelecomRepositoryProvider = jetpackTelecomRepositoryProvider, - ) + private val preAndroid17IncomingCallCoordinator = PreAndroid17IncomingCallCoordinator( + context = context, + client = client, + incomingCallPresenter = incomingCallPresenter, + serviceIntentBuilder = serviceIntentBuilder, + telecomPermissions = telecomPermissions, + telecomHelper = telecomHelper, + jetpackTelecomRepositoryProvider = jetpackTelecomRepositoryProvider, + ) + private val android17IncomingCallCoordinator = Android17IncomingCallCoordinator( + context = context, + client = client, + incomingCallPresenter = incomingCallPresenter, + telecomPermissions = telecomPermissions, + telecomHelper = telecomHelper, + jetpackTelecomRepositoryProvider = jetpackTelecomRepositoryProvider, + fallbackCoordinator = preAndroid17IncomingCallCoordinator, + telecomCallController = TelecomCallController(context), + ) @SuppressLint("MissingPermission", "NewApi") fun showIncomingCall( @@ -83,9 +94,15 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { callServiceConfiguration: CallServiceConfig, isVideo: Boolean, payload: Map, - notificationProvider: (IncomingRingtoneOwner) -> Notification?, + notificationProvider: () -> Notification?, ) { - incomingCallCoordinator.showIncomingCall( + val initialIncomingCallCoordinator = if (isAndroid17OrHigher()) { + android17IncomingCallCoordinator + } else { + preAndroid17IncomingCallCoordinator + } + + initialIncomingCallCoordinator.showIncomingCall( IncomingCallRequest( callId = callId, callDisplayName = callDisplayName, @@ -102,6 +119,9 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { if (!callConfig.runCallServiceInForeground) { return } + if (call.state.serviceRoute.value == ServiceRoute.UNDECIDED) { + call.state.updateServiceRoute(ServiceRoute.LEGACY_CALL_SERVICE) + } val callId = StreamCallId.fromCallCid(call.cid) val serviceIntent = ServiceIntentBuilder().buildStartIntent( context, @@ -170,10 +190,15 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { } fun removeIncomingCall( - callId: StreamCallId, + call: Call, config: CallServiceConfig = DefaultCallConfigurations.default, ) { - incomingCallCoordinator.dismissIncomingCall(callId, config) + val incomingCallCoordinator = if (call.state.serviceRoute.value == ServiceRoute.TELECOM) { + android17IncomingCallCoordinator + } else { + preAndroid17IncomingCallCoordinator + } + incomingCallCoordinator.dismissIncomingCall(StreamCallId.fromCallCid(call.cid), config) } /** @@ -190,6 +215,13 @@ internal class ServiceLauncher(private val client: StreamVideoClient) { private fun stopCallServiceInternal(call: Call) { logger.d { "[stopCallServiceInternal]" } val callConfig = client.callServiceConfigRegistry.get(call.type) + if (isAndroid17OrHigher() && + call.state.serviceRoute.value == ServiceRoute.TELECOM && + !serviceIntentBuilder.isServiceRunning(context, callConfig.serviceClass) + ) { + android17IncomingCallCoordinator.finishIncomingCall(call) + return + } if (callConfig.runCallServiceInForeground) { val serviceIntent = serviceIntentBuilder.buildStopIntent( context, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinator.kt new file mode 100644 index 00000000000..719af17a83b --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinator.kt @@ -0,0 +1,152 @@ +/* + * 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.incomingcallcoordinator + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.IncomingRingtoneOwner +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.VideoPushDelegate.Companion.DEFAULT_CALL_TEXT +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig +import io.getstream.video.android.core.notifications.internal.service.DefaultCallConfigurations +import io.getstream.video.android.core.notifications.internal.service.IncomingCallPresenter +import io.getstream.video.android.core.notifications.internal.service.IncomingCallRequest +import io.getstream.video.android.core.notifications.internal.service.JetpackTelecomRepositoryProvider +import io.getstream.video.android.core.notifications.internal.service.ShowIncomingCallResult +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute +import io.getstream.video.android.core.notifications.internal.service.observers.CallRejectionObserver +import io.getstream.video.android.core.notifications.internal.service.observers.CallServiceEventObserver +import io.getstream.video.android.core.notifications.internal.service.observers.TelecomNotificationUpdateObserver +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController +import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper +import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.launch + +/** + * Coordinates the Telecom-first incoming-call flow on Android 17 and newer. + * + * Registers the call with Telecom before posting a notification-owned ringing + * notification. Falls back to the CallService-first flow when Telecom cannot be used. + */ +internal class Android17IncomingCallCoordinator( + private val context: Context, + private val client: StreamVideoClient, + private val incomingCallPresenter: IncomingCallPresenter, + private val telecomPermissions: TelecomPermissions, + private val telecomHelper: TelecomHelper, + private val jetpackTelecomRepositoryProvider: JetpackTelecomRepositoryProvider, + private val fallbackCoordinator: PreAndroid17IncomingCallCoordinator, + private val telecomCallController: TelecomCallController, +) : IncomingCallCoordinator { + private val logger by taggedLogger("Android17IncomingCallCoordinator") + + @SuppressLint("MissingPermission", "NewApi") + override fun showIncomingCall(request: IncomingCallRequest) { + if (!telecomPermissions.canUseTelecom(request.callServiceConfiguration, context) || + !telecomHelper.canUseJetpackTelecom() || + !hasNotificationPermission() + ) { + fallbackToCallService(request) + return + } + + val call = client.call(request.callId.type, request.callId.id) + call.state.updateServiceRoute(ServiceRoute.TELECOM) + val repository = initTelecomRepository(call, request) + val address = "${client.telecomConfig?.schema}:${request.callId.id}".toUri() + val displayName = request.callDisplayName?.takeIf(String::isNotBlank) ?: DEFAULT_CALL_TEXT + + call.scope.launch { + repository.registerCall( + displayName = displayName, + address = address, + isIncoming = true, + isVideoCall = request.isVideo, + onRegistered = { + call.state.updateIncomingRingtoneOwner(IncomingRingtoneOwner.Notification) + val notification = request.notificationProvider() + val result = incomingCallPresenter.showIncomingCallNotification( + context, + request.callId, + notification, + ) + if (result == ShowIncomingCallResult.ONLY_NOTIFICATION) { + client.state.addRingingCall(call, RingingState.Incoming()) + call.state.updateRingingState() + connectCoordinatorWebSocket(call) + CallRejectionObserver(call, client).observe() + CallServiceEventObserver(call, client, call.scope).observe({}, {}) + if (client.enableCallNotificationUpdates) { + TelecomNotificationUpdateObserver(call, client, call.scope).observe() + } + } + }, + onException = { error -> + logger.e(error) { "[showIncomingCall] Telecom registration failed" } + fallbackToCallService(request) + }, + ) + } + } + + override fun dismissIncomingCall(callId: StreamCallId, config: CallServiceConfig) { + NotificationManagerCompat.from(context) + .cancel(callId.getNotificationId(NotificationType.Incoming)) + } + + override fun finishIncomingCall(call: Call) { + dismissIncomingCall(StreamCallId.fromCallCid(call.cid), DefaultCallConfigurations.default) + telecomCallController.leaveCall(call) + } + + private fun fallbackToCallService(request: IncomingCallRequest) { + logger.w { "[showIncomingCall] Telecom unavailable; falling back to CallService" } + fallbackCoordinator.showIncomingCall(request) + } + + private fun connectCoordinatorWebSocket(call: Call) { + call.scope.launch { client.connectIfNotAlreadyConnected() } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun initTelecomRepository( + call: Call, + request: IncomingCallRequest, + ): JetpackTelecomRepository = call.state.jetpackTelecomRepository + ?: jetpackTelecomRepositoryProvider.get(request.callId).also { + call.state.jetpackTelecomRepository = it + } + + private fun hasNotificationPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/IncomingCallCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/IncomingCallCoordinator.kt index bd462f7196c..88ab92b4fae 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/IncomingCallCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/IncomingCallCoordinator.kt @@ -16,10 +16,17 @@ package io.getstream.video.android.core.notifications.internal.service.incomingcallcoordinator +import io.getstream.video.android.core.Call import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig import io.getstream.video.android.core.notifications.internal.service.IncomingCallRequest import io.getstream.video.android.model.StreamCallId +/** + * Coordinates registration, presentation, and cleanup of incoming calls. + * + * Implementations select the service route and ringtone owner before starting + * the corresponding incoming-call flow. + */ internal interface IncomingCallCoordinator { fun showIncomingCall(request: IncomingCallRequest) @@ -28,4 +35,6 @@ internal interface IncomingCallCoordinator { callId: StreamCallId, config: CallServiceConfig, ) + + fun finishIncomingCall(call: Call) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt index 223aad3defc..1fab9cf5fe6 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinator.kt @@ -21,6 +21,7 @@ import android.content.Context import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call import io.getstream.video.android.core.IncomingRingtoneOwner import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationType @@ -33,6 +34,7 @@ import io.getstream.video.android.core.notifications.internal.service.JetpackTel import io.getstream.video.android.core.notifications.internal.service.ServiceIntentBuilder import io.getstream.video.android.core.notifications.internal.service.ShowIncomingCallResult import io.getstream.video.android.core.notifications.internal.service.StartServiceParam +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions import io.getstream.video.android.core.utils.safeCallWithResult @@ -54,7 +56,10 @@ internal class PreAndroid17IncomingCallCoordinator( @SuppressLint("MissingPermission", "NewApi") override fun showIncomingCall(request: IncomingCallRequest) { - val notification = request.notificationProvider(IncomingRingtoneOwner.Legacy) + val call = client.call(request.callId.type, request.callId.id) + call.state.updateServiceRoute(ServiceRoute.LEGACY_CALL_SERVICE) + call.state.updateIncomingRingtoneOwner(IncomingRingtoneOwner.Legacy) + val notification = request.notificationProvider() val result = incomingCallPresenter.showIncomingCall( context = context, callId = request.callId, @@ -71,15 +76,15 @@ internal class PreAndroid17IncomingCallCoordinator( return } - updateIncomingCallNotification(request, client, notification) - val jetpackTelecomRepository = jetpackTelecomRepositoryProvider.get(request.callId) + updateIncomingCallNotification(request, notification) + val jetpackTelecomRepository = call.state.jetpackTelecomRepository + ?: jetpackTelecomRepositoryProvider.get(request.callId).also { + call.state.jetpackTelecomRepository = it + } val addressUri = "${client.telecomConfig?.schema}:${request.callId.id}".toUri() val formattedCallDisplayName = request.callDisplayName ?.takeIf { it.isNotBlank() } ?: DEFAULT_CALL_TEXT - val call = client.call(request.callId.type, request.callId.id) - - call.state.jetpackTelecomRepository = jetpackTelecomRepository call.scope.launch { jetpackTelecomRepository.registerCall( displayName = formattedCallDisplayName, @@ -90,6 +95,8 @@ internal class PreAndroid17IncomingCallCoordinator( } } + override fun finishIncomingCall(call: Call) = Unit + override fun dismissIncomingCall( callId: StreamCallId, config: CallServiceConfig, @@ -114,7 +121,6 @@ internal class PreAndroid17IncomingCallCoordinator( private fun updateIncomingCallNotification( request: IncomingCallRequest, - client: StreamVideoClient, notification: android.app.Notification?, ) { notification?.let { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceRoute.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceRoute.kt new file mode 100644 index 00000000000..4d9a14111fb --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/models/ServiceRoute.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.models + +/** Identifies the route selected by the SDK to manage a call. */ +internal enum class ServiceRoute { + + /** A route has not been selected yet. */ + UNDECIDED, + + /** The call is managed through CallService. */ + LEGACY_CALL_SERVICE, + + /** The call is managed through Telecom. */ + TELECOM, +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserver.kt new file mode 100644 index 00000000000..e898f11b958 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserver.kt @@ -0,0 +1,44 @@ +/* + * 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.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 kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +internal class CallRejectionObserver( + private val call: Call, + private val streamVideo: StreamVideoClient, +) { + fun observe() { + call.scope.launch { + call.state.ringingState.collectLatest { state -> + if (state is RingingState.RejectedByAll) { + streamVideo.scope.launch { + call.reject( + source = "RingingState.RejectedByAll", + reason = RejectReason.Decline, + ) + } + } + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt index 6e63d9d2d3f..7f44f843f1e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallServiceNotificationUpdateObserver.kt @@ -17,24 +17,25 @@ 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.handlers.shouldNotificationOwnIncomingRingtone 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.delay 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( +internal open class CallServiceNotificationUpdateObserver( private val call: Call, private val streamVideo: StreamVideoClient, private val scope: CoroutineScope, @@ -45,22 +46,30 @@ internal class CallServiceNotificationUpdateObserver( trigger: String, foregroundServiceType: Int, ) -> Unit, -) { +) : NotificationUpdateObserver { private val logger by taggedLogger("NotificationUpdateObserver") + private suspend fun delayIncomingRingingNotificationUpdate() { + val delayMillis = streamVideo.streamNotificationManager + .notificationConfig + .incomingRingingNotificationUpdateDelayMillis + if (delayMillis > 0) { + delay(delayMillis) + } + } /** * Starts observing notification update triggers. */ @OptIn(ExperimentalStreamVideoApi::class) - fun observe(context: Context) { + override fun observe() { scope.launch { logger.d { "Observing notification updates for call: ${call.cid}" } val updateTriggers = getUpdateTriggers() updateTriggers.collectLatest { _ -> - updateNotification(context) + updateNotification() } } } @@ -92,7 +101,7 @@ internal class CallServiceNotificationUpdateObserver( /** * Updates the notification based on current call state. */ - private suspend fun updateNotification(context: Context) { + override suspend fun updateNotification() { val ringingState = call.state.ringingState.value val notification = streamVideo.onCallNotificationUpdate(call) logger.d { @@ -100,17 +109,49 @@ internal class CallServiceNotificationUpdateObserver( } if (notification != null) { - showNotificationForState(context, ringingState, notification) + if (shouldSkipIncomingRingingNotificationUpdate(ringingState, notification)) { + logger.d { "[updateNotification] Skipping equivalent incoming-call update" } + return + } + if ( + shouldStabilizeIncomingRingingNotification(ringingState) && + call.state.notificationIdFlow.value != null + ) { + delayIncomingRingingNotificationUpdate() + } + showNotificationForState(ringingState, notification) } else { logger.w { "[updateNotification] No notification generated" } } } + private fun shouldStabilizeIncomingRingingNotification(ringingState: RingingState): Boolean = + ringingState is RingingState.Incoming && + !ringingState.acceptedByMe && + shouldNotificationOwnIncomingRingtone() + + private fun shouldSkipIncomingRingingNotificationUpdate( + ringingState: RingingState, + updatedNotification: Notification, + ): Boolean { + if (!shouldStabilizeIncomingRingingNotification(ringingState)) return false + val callId = StreamCallId(call.type, call.id) + val updatedNotificationId = call.state.notificationIdFlow.value + ?: callId.getNotificationId(NotificationType.Incoming) + return streamVideo.streamNotificationManager.notificationUpdateDeduplicator.isDuplicate( + call = call, + ringingState = ringingState, + existingNotificationId = call.state.notificationIdFlow.value, + existingNotification = call.state.atomicNotification.get(), + updatedNotificationId = updatedNotificationId, + updatedNotification = updatedNotification, + ) + } + /** * Shows the appropriate notification based on ringing state. */ - private fun showNotificationForState( - context: Context, + override fun showNotificationForState( ringingState: RingingState, notification: Notification, ) { @@ -121,10 +162,10 @@ internal class CallServiceNotificationUpdateObserver( showActiveCallNotification(callId, notification) } is RingingState.Outgoing -> { - showOutgoingCallNotification(context, callId, notification) + showOutgoingCallNotification(callId, notification) } is RingingState.Incoming -> { - showIncomingCallNotification(context, callId, notification) + showIncomingCallNotification(callId, notification) } else -> { logger.d { "[updateNotification] Unhandled ringing state: $ringingState" } @@ -132,7 +173,7 @@ internal class CallServiceNotificationUpdateObserver( } } - private fun showActiveCallNotification( + override fun showActiveCallNotification( callId: StreamCallId, notification: Notification, ) { @@ -149,8 +190,7 @@ internal class CallServiceNotificationUpdateObserver( ) } - private fun showOutgoingCallNotification( - context: Context, + override fun showOutgoingCallNotification( callId: StreamCallId, notification: Notification, ) { @@ -161,12 +201,14 @@ internal class CallServiceNotificationUpdateObserver( notificationId, notification, CallService.Companion.TRIGGER_OUTGOING_CALL, - permissionManager.getServiceType(context, CallService.Companion.TRIGGER_OUTGOING_CALL), + permissionManager.getServiceType( + streamVideo.context, + CallService.Companion.TRIGGER_OUTGOING_CALL, + ), ) } - private fun showIncomingCallNotification( - context: Context, + override fun showIncomingCallNotification( callId: StreamCallId, notification: Notification, ) { @@ -177,7 +219,10 @@ internal class CallServiceNotificationUpdateObserver( notificationId, notification, CallService.Companion.TRIGGER_INCOMING_CALL, - permissionManager.getServiceType(context, CallService.Companion.TRIGGER_INCOMING_CALL), + permissionManager.getServiceType( + streamVideo.context, + CallService.Companion.TRIGGER_INCOMING_CALL, + ), ) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.kt new file mode 100644 index 00000000000..383c824c559 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/NotificationUpdateObserver.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.observers + +import android.app.Notification +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.model.StreamCallId + +internal interface NotificationUpdateObserver { + fun observe() + suspend fun updateNotification() + fun showNotificationForState(ringingState: RingingState, notification: Notification) + fun showActiveCallNotification(callId: StreamCallId, notification: Notification) + fun showOutgoingCallNotification(callId: StreamCallId, notification: Notification) + fun showIncomingCallNotification(callId: StreamCallId, notification: Notification) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserver.kt new file mode 100644 index 00000000000..d7338a94afa --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserver.kt @@ -0,0 +1,55 @@ +/* + * 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 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.permissions.ForegroundServicePermissionManager +import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.CoroutineScope + +internal class TelecomNotificationUpdateObserver( + private val call: Call, + private val streamVideo: StreamVideoClient, + scope: CoroutineScope, +) : CallServiceNotificationUpdateObserver( + call = call, + streamVideo = streamVideo, + scope = scope, + permissionManager = ForegroundServicePermissionManager(), + onStartService = { _, _, _, _ -> }, +) { + override fun showOutgoingCallNotification(callId: StreamCallId, notification: Notification) { + showNotification(callId, notification, NotificationType.Outgoing) + } + + override fun showIncomingCallNotification(callId: StreamCallId, notification: Notification) { + showNotification(callId, notification, NotificationType.Incoming) + } + + private fun showNotification( + callId: StreamCallId, + notification: Notification, + fallbackType: NotificationType, + ) { + val notificationId = call.state.notificationIdFlow.value + ?: callId.getNotificationId(fallbackType) + streamVideo.getStreamNotificationDispatcher().notify(callId, notificationId, notification) + } +} 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 index 0727d59be82..41ac291dd54 100644 --- 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 @@ -25,7 +25,7 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import io.getstream.video.android.core.notifications.internal.service.CallService -import io.getstream.video.android.core.utils.BUILD_VERSION_CODES_CINNAMON_BUN +import io.getstream.video.android.core.utils.isAndroid17OrHigher internal open class ForegroundServicePermissionManager { @SuppressLint("InlinedApi") @@ -102,7 +102,7 @@ internal open class ForegroundServicePermissionManager { @SuppressLint("InlinedApi") internal open fun incomingRingingServiceType(): Int = if (requiredForegroundTypes.contains(ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL) && - Build.VERSION.SDK_INT >= BUILD_VERSION_CODES_CINNAMON_BUN + isAndroid17OrHigher() ) { ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL } else { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepository.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepository.kt index e8b156de9a2..f27e12a6f8d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepository.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepository.kt @@ -29,6 +29,7 @@ import androidx.core.telecom.CallsManager import io.getstream.log.taggedLogger import io.getstream.video.android.core.notifications.internal.telecom.IncomingCallTelecomAction import io.getstream.video.android.model.StreamCallId +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -59,6 +60,24 @@ internal class JetpackTelecomRepository( address: Uri, isIncoming: Boolean, isVideoCall: Boolean, + ) = registerCall( + displayName, + address, + isIncoming, + isVideoCall, + onRegistered = {}, + onException = {}, + ) + + @RequiresPermission(Manifest.permission.MANAGE_OWN_CALLS) + @RequiresApi(Build.VERSION_CODES.O) + suspend fun registerCall( + displayName: String, + address: Uri, + isIncoming: Boolean, + isVideoCall: Boolean, + onRegistered: () -> Unit, + onException: (Exception) -> Unit, ) { logger.d { "[registerCall]" } // For simplicity we don't support multiple calls @@ -118,6 +137,7 @@ internal class JetpackTelecomRepository( actionSource = actionSource, ) logger.d { "[registerCall] _currentCall set to Registered" } + onRegistered() launch { currentCallEndpoint.collect { updateCurrentCall { @@ -140,7 +160,10 @@ internal class JetpackTelecomRepository( } } } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { + onException(ex) logger.e(ex) { "[registerCall] exception: ${ex.message}" } } finally { logger.d { "[registerCall] finally" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt index 2fa78d598f8..e01fdbb0b75 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodes.kt @@ -25,3 +25,6 @@ internal const val BUILD_VERSION_CODES_CINNAMON_BUN = 37 // Build.VERSION_CODES. internal fun isAndroid17OrHigher(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = sdkInt >= BUILD_VERSION_CODES_CINNAMON_BUN + +internal fun isPreAndroid17(sdkInt: Int = Build.VERSION.SDK_INT): Boolean = + sdkInt < BUILD_VERSION_CODES_CINNAMON_BUN diff --git a/stream-video-android-core/src/main/res/values/strings.xml b/stream-video-android-core/src/main/res/values/strings.xml index 3ef2d13f385..a05e4ae85a0 100644 --- a/stream-video-android-core/src/main/res/values/strings.xml +++ b/stream-video-android-core/src/main/res/values/strings.xml @@ -26,8 +26,10 @@ Screen-sharing Required to be enabled for screen sharing incoming_calls + incoming_calls_ringing Incoming Calls incoming_calls_low_priority + incoming_calls_low_priority_ringing Incoming audio and video call alerts Incoming audio and video call alerts Incoming call @@ -57,4 +59,4 @@ Missed call Missed call from: %s Unknown caller - \ No newline at end of file + diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/NotificationConfigTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/NotificationConfigTest.kt new file mode 100644 index 00000000000..a11a34a6ede --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/NotificationConfigTest.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 + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NotificationConfigTest { + + @Test + fun `incoming ringing notification update delay has the expected default`() { + val config = NotificationConfig() + + assertEquals(1_300L, config.incomingRingingNotificationUpdateDelayMillis) + } + + @Test + fun `incoming ringing notification update delay can be configured`() { + val config = NotificationConfig(incomingRingingNotificationUpdateDelayMillis = 0L) + + assertEquals(0L, config.incomingRingingNotificationUpdateDelayMillis) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt index 943d070885e..f47e3d2e819 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/IncomingCallNotificationAlertTest.kt @@ -17,13 +17,67 @@ package io.getstream.video.android.core.notifications.handlers import android.app.Notification +import io.getstream.video.android.core.R import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.utils.BUILD_VERSION_CODES_CINNAMON_BUN import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Test class IncomingCallNotificationAlertTest { + @Test + fun `incoming call channel uses legacy ID before Android 17`() { + assertEquals( + R.string.stream_video_incoming_call_notification_channel_id, + defaultIncomingCallChannelIdRes(BUILD_VERSION_CODES_CINNAMON_BUN - 1), + ) + } + + @Test + fun `incoming call channel uses ringing ID on Android 17`() { + assertEquals( + R.string.stream_video_incoming_call_ringing_notification_channel_id, + defaultIncomingCallChannelIdRes(BUILD_VERSION_CODES_CINNAMON_BUN), + ) + } + + @Test + fun `low importance incoming call channel uses legacy ID before Android 17`() { + assertEquals( + R.string.stream_video_incoming_call_low_priority_notification_channel_id, + defaultIncomingCallLowImportanceChannelIdRes(BUILD_VERSION_CODES_CINNAMON_BUN - 1), + ) + } + + @Test + fun `low importance incoming call channel uses ringing ID on Android 17`() { + assertEquals( + R.string.stream_video_incoming_call_ringing_low_priority_notification_channel_id, + defaultIncomingCallLowImportanceChannelIdRes(BUILD_VERSION_CODES_CINNAMON_BUN), + ) + } + + @Test + fun `before Android 17 keeps legacy ringtone ownership`() { + assertFalse( + shouldNotificationOwnIncomingRingtone( + sdkInt = BUILD_VERSION_CODES_CINNAMON_BUN - 1, + ), + ) + } + + @Test + fun `Android 17 uses notification ringtone ownership by default`() { + assertTrue( + shouldNotificationOwnIncomingRingtone( + sdkInt = BUILD_VERSION_CODES_CINNAMON_BUN, + ), + ) + } + @Test fun `ringing notification loops and can alert again`() { val flags = incomingCallNotificationFlags( diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/RingingNotificationChannelTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/RingingNotificationChannelTest.kt new file mode 100644 index 00000000000..fe5c7508e9e --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/RingingNotificationChannelTest.kt @@ -0,0 +1,75 @@ +/* + * 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.handlers + +import android.app.NotificationManager +import android.media.AudioAttributes +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.slot +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]) +class RingingNotificationChannelTest { + + @Test + fun `ringing channel contains ringtone sound attributes and vibration`() { + val notificationManager = mockk() + val createdChannel = slot() + every { + notificationManager.createNotificationChannel(capture(createdChannel)) + } just runs + val soundUri = Uri.parse("android.resource://test/ringtone") + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + val vibrationPattern = longArrayOf(0L, 300L, 200L, 300L) + val channelInfo = StreamNotificationChannelInfo( + id = "incoming-ringing", + name = "Incoming calls", + description = "Incoming call alerts", + importance = NotificationManager.IMPORTANCE_HIGH, + ) + + channelInfo.createRingingChannel( + manager = notificationManager, + soundUri = soundUri, + audioAttributes = audioAttributes, + vibrationPattern = vibrationPattern, + ) + + assertEquals("incoming-ringing", createdChannel.captured.id) + assertEquals(soundUri, createdChannel.captured.sound) + assertEquals( + AudioAttributes.USAGE_NOTIFICATION_RINGTONE, + createdChannel.captured.audioAttributes?.usage, + ) + assertEquals(vibrationPattern.toList(), createdChannel.captured.vibrationPattern?.toList()) + } +} 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 cef86a048c3..b13ea46337c 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 @@ -18,7 +18,9 @@ package io.getstream.video.android.core.notifications.handlers import android.app.Application import android.app.Notification +import android.app.NotificationManager import android.app.PendingIntent +import android.content.Context import android.content.pm.PackageManager import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat @@ -32,6 +34,7 @@ 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.call.CallBusyHandler +import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver 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 @@ -39,6 +42,7 @@ import io.getstream.video.android.core.notifications.internal.service.CallServic import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.model.StreamCallId import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -121,11 +125,17 @@ class StreamDefaultNotificationHandlerTest { val mockState = mockk(relaxed = true) val mockCallConfigRegistry = mockk(relaxed = true) val mockCallServiceConfig = mockk(relaxed = true) - every { StreamVideo.instance() } returns mockStreamVideo + every { mockStreamVideo.context } returns mockApplication every { mockStreamVideo.state } returns mockState serviceLauncher = mockk(relaxed = true) every { mockState.serviceLauncher } returns serviceLauncher + every { + serviceLauncher.showIncomingCall(any(), any(), any(), any(), any(), any()) + } answers { + arg<() -> Notification?>(5)() + Unit + } every { mockState.callConfigRegistry } returns mockCallConfigRegistry every { mockStreamVideo.callServiceConfigRegistry } returns mockCallConfigRegistry every { mockCallConfigRegistry.get(any()) } returns mockCallServiceConfig @@ -179,12 +189,16 @@ class StreamDefaultNotificationHandlerTest { every { mockApplication.getString(any()) } returns "Test String" every { mockApplication.getString(any(), any()) } returns "Test String with param" every { mockApplication.applicationInfo } returns mockk(relaxed = true) + every { + mockApplication.getSystemService(Context.NOTIFICATION_SERVICE) + } returns mockk(relaxed = true) // Mock call state every { mockCall.cid } returns "default:test-call-123" every { mockCall.state } returns mockCallState every { mockCall.state.createdBy } returns MutableStateFlow(null) every { mockCallState.ringingState } returns MutableStateFlow(RingingState.Incoming()) + every { mockCall.state.incomingRingtoneOwner.value } returns IncomingRingtoneOwner.Legacy every { mockCallState.members } returns MutableStateFlow(emptyList()) every { mockCallState.remoteParticipants } returns MutableStateFlow(emptyList()) every { mockCallState.atomicNotification } returns java.util.concurrent.atomic.AtomicReference(null) @@ -228,7 +242,7 @@ class StreamDefaultNotificationHandlerTest { fun `onRingingCall shows incoming call notification when caller is not busy with comprehensive verification`() { // Given val callDisplayName = "John Doe" - val notificationProvider = slot<(IncomingRingtoneOwner) -> Notification?>() + val notificationProvider = slot<() -> Notification?>() every { callBusyHandler.isBusyWithAnotherCall(testCallId.cid) } returns false every { serviceLauncher.showIncomingCall( @@ -270,7 +284,7 @@ class StreamDefaultNotificationHandlerTest { // When testHandler.onRingingCall(testCallId, callDisplayName, payload) - notificationProvider.captured(IncomingRingtoneOwner.Legacy) + notificationProvider.captured() // Then - Verify all intent resolver calls verify { mockIntentResolver.searchIncomingCallPendingIntent(testCallId, payload = payload) } @@ -298,6 +312,77 @@ class StreamDefaultNotificationHandlerTest { } } + @Test + fun `Android 17 incoming notification uses separate full screen and content intents`() { + mockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") + every { isAndroid17OrHigher() } returns true + val contentPendingIntent = mockk() + val fullScreenPendingIntent = mockk() + val defaultIntentResolver = mockk(relaxed = true) + every { + defaultIntentResolver.searchIncomingCallPendingIntent( + testCallId, + payload = payload, + ) + } returns contentPendingIntent + every { + defaultIntentResolver.searchIncomingCallFullScreenPendingIntent( + testCallId, + payload = payload, + ) + } returns fullScreenPendingIntent + every { + defaultIntentResolver.searchAcceptCallPendingIntent( + testCallId, + payload = payload, + ) + } returns mockPendingIntent + every { + defaultIntentResolver.searchRejectCallPendingIntent( + testCallId, + payload = payload, + ) + } returns mockPendingIntent + every { + mockInitialInterceptor.onBuildIncomingCallNotification( + any(), + fullScreenPendingIntent, + mockPendingIntent, + mockPendingIntent, + "John Doe", + true, + payload, + ) + } answers { firstArg() } + testHandler = StreamDefaultNotificationHandler( + application = mockApplication, + notificationManager = mockNotificationManager, + notificationPermissionHandler = mockNotificationPermissionHandler, + intentResolver = defaultIntentResolver, + hideRingingNotificationInForeground = false, + initialNotificationBuilderInterceptor = mockInitialInterceptor, + updateNotificationBuilderInterceptor = mockUpdateInterceptor, + ) + + testHandler.getRingingCallNotification( + ringingState = RingingState.Incoming(), + callId = testCallId, + callDisplayName = "John Doe", + shouldHaveContentIntent = true, + payload = payload, + ) + + verify { + anyConstructed().setFullScreenIntent( + fullScreenPendingIntent, + true, + ) + } + verify { + anyConstructed().setContentIntent(contentPendingIntent) + } + } + @Test fun `onMissedCall creates and shows missed call notification`() { // Given @@ -352,6 +437,11 @@ class StreamDefaultNotificationHandlerTest { ) } + verify { + mockNotificationManager.cancel( + testCallId.getNotificationId(NotificationType.Incoming), + ) + } // Verify notification manager is called to show notification verify { mockNotificationManager.notify(notificationId, any()) } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicatorTest.kt new file mode 100644 index 00000000000..84cab90179b --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/NotificationUpdateDeduplicatorTest.kt @@ -0,0 +1,67 @@ +/* + * 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.app.Notification +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.notifications.NotificationUpdateComparator +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NotificationUpdateDeduplicatorTest { + private val call = mockk(relaxed = true) + private val existing = mockk() + private val updated = mockk() + private val comparator = mockk() + private val deduplicator = NotificationUpdateDeduplicator(comparator) + + @Test + fun `equivalent unaccepted incoming update with same id is duplicate`() { + val state = RingingState.Incoming() + every { comparator.areEquivalent(call, state, existing, updated) } returns true + assertTrue( + deduplicator.isDuplicate( + call, + state, + 1, + existing, + 1, + updated, + ), + ) + } + + @Test + fun `accepted incoming update is never duplicate`() { + assertFalse( + deduplicator.isDuplicate( + call, + RingingState.Incoming(acceptedByMe = true), + 1, + existing, + 1, + updated, + ), + ) + verify(exactly = 0) { comparator.areEquivalent(any(), any(), any(), any()) } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigAndroidVersionTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigAndroidVersionTest.kt new file mode 100644 index 00000000000..0d80a77e279 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceConfigAndroidVersionTest.kt @@ -0,0 +1,61 @@ +/* + * 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 + +import io.getstream.video.android.core.utils.isAndroid17OrHigher +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CallServiceConfigAndroidVersionTest { + + @Before + fun setup() { + mockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") + } + + @After + fun tearDown() { + unmockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") + } + + @Test + fun `Telecom is enabled by default on Android 17`() { + every { isAndroid17OrHigher() } returns true + + assertTrue(CallServiceConfig().enableTelecom) + } + + @Test + fun `Telecom remains disabled by default before Android 17`() { + every { isAndroid17OrHigher() } returns false + + assertFalse(CallServiceConfig().enableTelecom) + } + + @Test + fun `explicit Telecom configuration overrides Android default`() { + every { isAndroid17OrHigher() } returns true + + assertFalse(CallServiceConfig(enableTelecom = false).enableTelecom) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt index e618d0ea190..a6c56b2ea8e 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/CallServiceTest.kt @@ -25,6 +25,8 @@ import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Build import androidx.core.content.ContextCompat +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.notifications.NotificationHandler import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_CID @@ -34,15 +36,25 @@ import io.getstream.video.android.core.notifications.internal.service.CallServic import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_ONGOING_CALL import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_OUTGOING_CALL import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_REMOVE_INCOMING_CALL +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute +import io.getstream.video.android.core.notifications.internal.service.observers.CallServiceEventObserver +import io.getstream.video.android.core.notifications.internal.service.observers.CallServiceRingingStateObserver +import io.getstream.video.android.core.utils.isPreAndroid17 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.clearAllMocks import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor import io.mockk.mockkStatic -import io.mockk.unmockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull @@ -83,8 +95,8 @@ class CallServiceTest { @After fun tearDown() { - unmockkStatic(ContextCompat::class) - clearAllMocks() + callService.serviceScope.cancel() + unmockkAll() } // Test companion object constants @@ -213,6 +225,78 @@ class CallServiceTest { INTENT_EXTRA_CALL_DISPLAY_NAME, ) } + + @Test + fun `Android 17 Telecom route does not install CallService observers`() { + mockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") + every { isPreAndroid17() } returns false + mockkConstructor(CallServiceRingingStateObserver::class) + mockkConstructor(CallServiceEventObserver::class) + every { anyConstructed().observe(any()) } just runs + every { anyConstructed().observe(any(), any()) } just runs + val call = mockCall(ServiceRoute.TELECOM) + every { mockStreamVideoClient.enableCallNotificationUpdates } returns false + + callService.observeCallForTest(call, mockStreamVideoClient) + + verify(exactly = 0) { + anyConstructed().observe(any()) + } + verify(exactly = 0) { + anyConstructed().observe(any(), any()) + } + } + + @Test + fun `Android 17 legacy route installs CallService observers`() { + mockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") + every { isPreAndroid17() } returns false + mockkConstructor(CallServiceRingingStateObserver::class) + mockkConstructor(CallServiceEventObserver::class) + every { anyConstructed().observe(any()) } just runs + every { anyConstructed().observe(any(), any()) } just runs + val call = mockCall(ServiceRoute.LEGACY_CALL_SERVICE) + every { mockStreamVideoClient.enableCallNotificationUpdates } returns false + + callService.observeCallForTest(call, mockStreamVideoClient) + + verify(exactly = 1) { + anyConstructed().observe(any()) + } + verify(exactly = 1) { + anyConstructed().observe(any(), any()) + } + } + + @Test + fun `Android 17 undecided route installs CallService observers`() { + mockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") + every { isPreAndroid17() } returns false + mockkConstructor(CallServiceRingingStateObserver::class) + mockkConstructor(CallServiceEventObserver::class) + every { anyConstructed().observe(any()) } just runs + every { anyConstructed().observe(any(), any()) } just runs + val call = mockCall(ServiceRoute.UNDECIDED) + every { mockStreamVideoClient.enableCallNotificationUpdates } returns false + + callService.observeCallForTest(call, mockStreamVideoClient) + + verify(exactly = 1) { + anyConstructed().observe(any()) + } + verify(exactly = 1) { + anyConstructed().observe(any(), any()) + } + } + + private fun mockCall(route: ServiceRoute): Call { + val callState = mockk(relaxed = true) { + every { serviceRoute } returns MutableStateFlow(route) + } + return mockk(relaxed = true) { + every { state } returns callState + } + } } internal fun CallService.attachContext(context: Context) { @@ -223,3 +307,13 @@ internal fun CallService.attachContext(context: Context) { method.isAccessible = true method.invoke(this, context) } + +private fun CallService.observeCallForTest(call: Call, client: StreamVideoClient) { + val method = CallService::class.java.getDeclaredMethod( + "observeCall", + Call::class.java, + StreamVideoClient::class.java, + ) + method.isAccessible = true + method.invoke(this, call, client) +} 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 0286fe1ca1e..0e256359e2a 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 @@ -218,6 +218,35 @@ class IncomingCallPresenterTest { assertEquals(ShowIncomingCallResult.ERROR, result) } + @Test + fun `showIncomingCallNotification dispatches incoming notification when permitted`() { + mockNotificationPermission(granted = true) + val dispatcher = mockk(relaxed = true) + every { streamVideoClient.getStreamNotificationDispatcher() } returns dispatcher + + val result = presenter.showIncomingCallNotification(context, callId, notification) + + assertEquals(ShowIncomingCallResult.ONLY_NOTIFICATION, result) + verify { + dispatcher.notify( + callId, + callId.getNotificationId( + io.getstream.video.android.core.notifications.NotificationType.Incoming, + ), + notification, + ) + } + } + + @Test + fun `showIncomingCallNotification returns error when notification is absent`() { + mockNotificationPermission(granted = true) + + val result = presenter.showIncomingCallNotification(context, callId, null) + + assertEquals(ShowIncomingCallResult.ERROR, result) + } + // ---------- helpers ---------- private fun mockNoActiveCall() { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt index 3c0aeced063..5e671497af6 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncherTest.kt @@ -25,11 +25,14 @@ import android.os.Build import android.telecom.TelecomManager import androidx.core.content.ContextCompat import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.core.utils.isAndroid17OrHigher import io.getstream.video.android.model.StreamCallId import io.mockk.coVerify import io.mockk.every @@ -40,6 +43,7 @@ import io.mockk.mockkStatic import io.mockk.unmockkAll 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.resetMain @@ -91,6 +95,7 @@ class ServiceLauncherTest { jetpackTelecomRepository = mockk(relaxed = true) mockkStatic(ContextCompat::class) + mockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") mockkObject(StreamVideo) mockkConstructor(JetpackTelecomRepository::class) mockkConstructor(JetpackTelecomRepositoryProvider::class) @@ -123,6 +128,7 @@ class ServiceLauncherTest { every { StreamVideo.instance() } returns streamVideo every { streamVideo.context } returns context every { jetpackTelecomRepositoryProvider.get(any()) } returns jetpackTelecomRepository + every { isAndroid17OrHigher() } returns false serviceLauncher = ServiceLauncher(streamVideo) } @@ -135,6 +141,39 @@ class ServiceLauncherTest { // region showIncomingCall() + @Test + fun `showIncomingCall registers Telecom first on Android 17`() = runTest { + val call = mockk(relaxed = true) + every { isAndroid17OrHigher() } returns true + every { + anyConstructed().canUseTelecom(callServiceConfig, context) + } returns true + every { streamVideo.call(any(), any()) } returns call + every { call.scope } returns TestScope(StandardTestDispatcher(testScheduler)) + every { call.state.jetpackTelecomRepository } returns null + + serviceLauncher.showIncomingCall( + callId = callId, + callDisplayName = "Test Caller", + callServiceConfiguration = callServiceConfig, + isVideo = true, + payload = emptyMap(), + notificationProvider = { notification }, + ) + testScheduler.advanceUntilIdle() + + coVerify { + jetpackTelecomRepository.registerCall( + any(), + any(), + true, + true, + any(), + any(), + ) + } + } + @Test fun `showIncomingCall starts telecom registration when all conditions pass`() = runTest { val testDispatcher = StandardTestDispatcher(testScheduler) @@ -143,6 +182,7 @@ class ServiceLauncherTest { val call = mockk(relaxed = true) every { streamVideo.call(any(), any()) } returns call every { call.state } returns mockk(relaxed = true) + every { call.state.jetpackTelecomRepository } returns null every { call.scope } returns testScope mockkStatic(ContextCompat::class) @@ -182,6 +222,57 @@ class ServiceLauncherTest { coVerify(exactly = 0) { jetpackTelecomRepository.registerCall(any(), any(), any(), any()) } } + // endregion + + // region showOnGoingCall() + + @Test + fun `showOnGoingCall selects legacy route when route is undecided`() { + val (call, callState) = ongoingCall(ServiceRoute.UNDECIDED) + + serviceLauncher.showOnGoingCall(call, "ongoing_call") + + verify(exactly = 1) { + callState.updateServiceRoute(ServiceRoute.LEGACY_CALL_SERVICE) + } + verify(exactly = 1) { ContextCompat.startForegroundService(context, any()) } + } + + @Test + fun `showOnGoingCall preserves Telecom route`() { + val (call, callState) = ongoingCall(ServiceRoute.TELECOM) + + serviceLauncher.showOnGoingCall(call, "ongoing_call") + + verify(exactly = 0) { callState.updateServiceRoute(any()) } + verify(exactly = 1) { ContextCompat.startForegroundService(context, any()) } + } + + @Test + fun `showOnGoingCall preserves legacy route`() { + val (call, callState) = ongoingCall(ServiceRoute.LEGACY_CALL_SERVICE) + + serviceLauncher.showOnGoingCall(call, "ongoing_call") + + verify(exactly = 0) { callState.updateServiceRoute(any()) } + verify(exactly = 1) { ContextCompat.startForegroundService(context, any()) } + } + + @Test + fun `showOnGoingCall does not select route when foreground service is disabled`() { + val (call, callState) = ongoingCall( + route = ServiceRoute.UNDECIDED, + runCallServiceInForeground = false, + ) + + serviceLauncher.showOnGoingCall(call, "ongoing_call") + + verify(exactly = 0) { callState.updateServiceRoute(any()) } + verify(exactly = 0) { ContextCompat.startForegroundService(context, any()) } + } + + // endregion + // // // endregion // @@ -232,4 +323,22 @@ class ServiceLauncherTest { } // endregion + + private fun ongoingCall( + route: ServiceRoute, + runCallServiceInForeground: Boolean = true, + ): Pair { + val callState = mockk(relaxed = true) { + every { serviceRoute } returns MutableStateFlow(route) + } + val call = mockk(relaxed = true) { + every { type } returns "default" + every { cid } returns "default:cid-123" + every { state } returns callState + } + every { streamVideo.callServiceConfigRegistry.get("default") } returns CallServiceConfig( + runCallServiceInForeground = runCallServiceInForeground, + ) + return call to callState + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinatorTest.kt new file mode 100644 index 00000000000..dce797644cd --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/Android17IncomingCallCoordinatorTest.kt @@ -0,0 +1,224 @@ +/* + * 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.incomingcallcoordinator + +import android.Manifest +import android.app.Notification +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +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.IncomingRingtoneOwner +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig +import io.getstream.video.android.core.notifications.internal.service.IncomingCallPresenter +import io.getstream.video.android.core.notifications.internal.service.IncomingCallRequest +import io.getstream.video.android.core.notifications.internal.service.JetpackTelecomRepositoryProvider +import io.getstream.video.android.core.notifications.internal.service.ShowIncomingCallResult +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute +import io.getstream.video.android.core.notifications.internal.service.observers.CallRejectionObserver +import io.getstream.video.android.core.notifications.internal.service.observers.CallServiceEventObserver +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController +import io.getstream.video.android.core.notifications.internal.telecom.TelecomConfig +import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper +import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.model.StreamCallId +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.After +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]) +class Android17IncomingCallCoordinatorTest { + + private val context = mockk(relaxed = true) + private val client = mockk(relaxed = true) + private val presenter = mockk() + private val telecomPermissions = mockk() + private val telecomHelper = mockk() + private val repositoryProvider = mockk() + private val fallbackCoordinator = mockk(relaxed = true) + private val telecomCallController = mockk(relaxed = true) + private val repository = mockk() + private val call = mockk() + private val callState = mockk(relaxed = true) + private val clientState = mockk(relaxed = true) + private val notification = mockk() + private val callId = StreamCallId("default", "call-id") + private lateinit var callScope: TestScope + private lateinit var coordinator: Android17IncomingCallCoordinator + + @Before + fun setup() { + mockkStatic(ContextCompat::class) + mockkConstructor(CallRejectionObserver::class) + mockkConstructor(CallServiceEventObserver::class) + every { anyConstructed().observe() } just runs + every { anyConstructed().observe(any(), any()) } just runs + callScope = TestScope(StandardTestDispatcher()) + every { telecomPermissions.canUseTelecom(any(), context) } returns true + every { telecomHelper.canUseJetpackTelecom() } returns true + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + } returns PackageManager.PERMISSION_GRANTED + every { client.call(callId.type, callId.id) } returns call + every { client.telecomConfig } returns TelecomConfig("stream") + every { client.state } returns clientState + every { client.enableCallNotificationUpdates } returns false + every { call.state } returns callState + every { call.scope } returns callScope + every { callState.jetpackTelecomRepository } returns null + every { repositoryProvider.get(callId) } returns repository + every { presenter.showIncomingCallNotification(any(), any(), any()) } returns + ShowIncomingCallResult.ONLY_NOTIFICATION + coEvery { client.connectIfNotAlreadyConnected() } just runs + coordinator = Android17IncomingCallCoordinator( + context, + client, + presenter, + telecomPermissions, + telecomHelper, + repositoryProvider, + fallbackCoordinator, + telecomCallController, + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `falls back to legacy coordinator when Telecom is unavailable`() { + val request = request() + every { + telecomPermissions.canUseTelecom(request.callServiceConfiguration, context) + } returns false + + coordinator.showIncomingCall(request) + + verify { fallbackCoordinator.showIncomingCall(request) } + coVerify(exactly = 0) { repository.registerCall(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `registers Telecom before creating and posting ringing notification`() { + val events = mutableListOf() + lateinit var onRegistered: () -> Unit + val request = request { + events += "notification-created" + } + every { + callState.updateIncomingRingtoneOwner(IncomingRingtoneOwner.Notification) + } answers { + events += "ringtone-owner-updated" + } + every { + presenter.showIncomingCallNotification(context, callId, notification) + } answers { + events += "notification-posted" + ShowIncomingCallResult.ONLY_NOTIFICATION + } + coEvery { + repository.registerCall(any(), any(), true, true, any(), any()) + } coAnswers { + events += "telecom-registration-requested" + onRegistered = arg(4) + } + + coordinator.showIncomingCall(request) + callScope.advanceUntilIdle() + + assertEquals(listOf("telecom-registration-requested"), events) + verify(exactly = 0) { + presenter.showIncomingCallNotification(any(), any(), any()) + } + + onRegistered() + callScope.advanceUntilIdle() + + assertEquals( + listOf( + "telecom-registration-requested", + "ringtone-owner-updated", + "notification-created", + "notification-posted", + ), + events, + ) + verify { callState.updateServiceRoute(ServiceRoute.TELECOM) } + verify { callState.jetpackTelecomRepository = repository } + verify { + callState.updateIncomingRingtoneOwner(IncomingRingtoneOwner.Notification) + } + verify { presenter.showIncomingCallNotification(context, callId, notification) } + verify { clientState.addRingingCall(call, any()) } + coVerify { client.connectIfNotAlreadyConnected() } + } + + @Test + fun `Telecom registration failure falls back to CallService`() { + val request = request() + val failure = IllegalStateException("registration failed") + coEvery { + repository.registerCall(any(), any(), any(), any(), any(), any()) + } coAnswers { + arg<(Exception) -> Unit>(5).invoke(failure) + } + + coordinator.showIncomingCall(request) + callScope.advanceUntilIdle() + + verify { fallbackCoordinator.showIncomingCall(request) } + } + + private fun request( + onNotificationRequested: () -> Unit = {}, + ): IncomingCallRequest = IncomingCallRequest( + callId = callId, + callDisplayName = "Caller", + callServiceConfiguration = CallServiceConfig(enableTelecom = true), + isVideo = true, + payload = emptyMap(), + notificationProvider = { + onNotificationRequested() + notification + }, + ) +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinatorTest.kt new file mode 100644 index 00000000000..aeeac7409fa --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/incomingcallcoordinator/PreAndroid17IncomingCallCoordinatorTest.kt @@ -0,0 +1,165 @@ +/* + * 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.incomingcallcoordinator + +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.IncomingRingtoneOwner +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.handlers.shouldNotificationOwnIncomingRingtone +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig +import io.getstream.video.android.core.notifications.internal.service.IncomingCallPresenter +import io.getstream.video.android.core.notifications.internal.service.IncomingCallRequest +import io.getstream.video.android.core.notifications.internal.service.JetpackTelecomRepositoryProvider +import io.getstream.video.android.core.notifications.internal.service.ServiceIntentBuilder +import io.getstream.video.android.core.notifications.internal.service.ShowIncomingCallResult +import io.getstream.video.android.core.notifications.internal.service.models.ServiceRoute +import io.getstream.video.android.core.notifications.internal.telecom.TelecomConfig +import io.getstream.video.android.core.notifications.internal.telecom.TelecomHelper +import io.getstream.video.android.core.notifications.internal.telecom.TelecomPermissions +import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository +import io.getstream.video.android.model.StreamCallId +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +class PreAndroid17IncomingCallCoordinatorTest { + + private val context = mockk(relaxed = true) + private val client = mockk(relaxed = true) + private val presenter = mockk() + private val serviceIntentBuilder = mockk(relaxed = true) + private val telecomPermissions = mockk() + private val telecomHelper = mockk() + private val repositoryProvider = mockk() + private val repository = mockk() + private val call = mockk() + private val callState = mockk(relaxed = true) + private val notification = mockk() + private val callId = StreamCallId("default", "call-id") + private lateinit var callScope: TestScope + private lateinit var coordinator: PreAndroid17IncomingCallCoordinator + + @Before + fun setup() { + mockkStatic("io.getstream.video.android.core.notifications.handlers.ChannelInfoProviderKt") + every { shouldNotificationOwnIncomingRingtone() } returns false + callScope = TestScope(StandardTestDispatcher()) + every { client.call(callId.type, callId.id) } returns call + every { client.telecomConfig } returns TelecomConfig("stream") + every { call.state } returns callState + every { call.scope } returns callScope + every { callState.jetpackTelecomRepository } returns null + every { presenter.showIncomingCall(any(), any(), any(), any(), any()) } returns + ShowIncomingCallResult.ERROR + every { telecomPermissions.canUseTelecom(any(), any()) } returns false + every { telecomHelper.canUseJetpackTelecom() } returns false + coordinator = PreAndroid17IncomingCallCoordinator( + context, + client, + presenter, + serviceIntentBuilder, + telecomPermissions, + telecomHelper, + repositoryProvider, + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `pre Android 17 route requests legacy ringtone and marks CallService ownership`() { + val events = mutableListOf() + every { + callState.updateIncomingRingtoneOwner(IncomingRingtoneOwner.Legacy) + } answers { + events += "legacy-owner-selected" + } + + coordinator.showIncomingCall(request { events += "notification-created" }) + + assertEquals(listOf("legacy-owner-selected", "notification-created"), events) + verify { callState.updateServiceRoute(ServiceRoute.LEGACY_CALL_SERVICE) } + verify { callState.updateIncomingRingtoneOwner(IncomingRingtoneOwner.Legacy) } + verify { + presenter.showIncomingCall( + context, + callId, + "Caller", + any(), + notification, + ) + } + } + + @Test + fun `foreground service path reuses existing Telecom repository`() = runTest { + every { presenter.showIncomingCall(any(), any(), any(), any(), any()) } returns + ShowIncomingCallResult.FG_SERVICE + every { telecomPermissions.canUseTelecom(any(), context) } returns true + every { telecomHelper.canUseJetpackTelecom() } returns true + every { callState.jetpackTelecomRepository } returns repository + coEvery { repository.registerCall(any(), any(), true, true) } just runs + + coordinator.showIncomingCall(request()) + callScope.advanceUntilIdle() + + verify(exactly = 0) { repositoryProvider.get(any()) } + coVerify { repository.registerCall("Caller", any(), true, true) } + verify { + callState.updateNotification( + callId.getNotificationId( + io.getstream.video.android.core.notifications.NotificationType.Incoming, + ), + notification, + ) + } + } + + private fun request( + onNotificationRequested: () -> Unit = {}, + ): IncomingCallRequest = IncomingCallRequest( + callId = callId, + callDisplayName = "Caller", + callServiceConfiguration = CallServiceConfig(enableTelecom = true), + isVideo = true, + payload = emptyMap(), + notificationProvider = { + onNotificationRequested() + notification + }, + ) +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserverTest.kt new file mode 100644 index 00000000000..10f2d7bf50e --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/CallRejectionObserverTest.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.service.observers + +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.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class CallRejectionObserverTest { + + @Test + fun `rejected by all state declines the call`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val callScope = TestScope(dispatcher) + val clientScope = TestScope(dispatcher) + val ringingState = MutableStateFlow(RingingState.Incoming()) + val callState = mockk { + every { this@mockk.ringingState } returns ringingState + } + val call = mockk { + every { scope } returns callScope + every { state } returns callState + coEvery { reject(any(), any()) } returns mockk(relaxed = true) + } + val streamVideo = mockk { + every { scope } returns clientScope + } + CallRejectionObserver(call, streamVideo).observe() + advanceUntilIdle() + + ringingState.value = RingingState.RejectedByAll + advanceUntilIdle() + + coVerify(exactly = 1) { + call.reject( + source = "RingingState.RejectedByAll", + reason = RejectReason.Decline, + ) + } + } + + @Test + fun `non-rejected state does not decline the call`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val callScope = TestScope(dispatcher) + val ringingState = MutableStateFlow(RingingState.Incoming()) + val call = mockk(relaxed = true) { + every { scope } returns callScope + every { state.ringingState } returns ringingState + } + val streamVideo = mockk(relaxed = true) + CallRejectionObserver(call, streamVideo).observe() + + advanceUntilIdle() + + coVerify(exactly = 0) { call.reject(any(), any()) } + } +} 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 index c97cc58eb95..295374b1468 100644 --- 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 @@ -25,14 +25,20 @@ 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.NotificationConfig import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.core.notifications.dispatchers.DefaultNotificationDispatcher +import io.getstream.video.android.core.notifications.internal.NotificationUpdateDeduplicator +import io.getstream.video.android.core.notifications.internal.StreamNotificationManager 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.core.utils.isAndroid17OrHigher import io.getstream.video.android.model.StreamCallId import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -42,12 +48,15 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before +import java.util.concurrent.atomic.AtomicReference import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) @@ -61,10 +70,14 @@ class CallServiceNotificationUpdateObserverTest { private lateinit var streamVideo: StreamVideoClient private lateinit var streamState: ClientState private lateinit var permissionManager: ForegroundServicePermissionManager + private lateinit var notificationConfig: NotificationConfig + private lateinit var notificationUpdateDeduplicator: NotificationUpdateDeduplicator private lateinit var observer: CallServiceNotificationUpdateObserver private val context: Context = mockk(relaxed = true) private val notification: Notification = mockk() + private val onStartService = + mockk<(Int, Notification, String, Int) -> Unit>(relaxed = true) // StateFlows private val ringingStateFlow = MutableStateFlow(RingingState.Idle) @@ -72,6 +85,7 @@ class CallServiceNotificationUpdateObserverTest { private val testNotificationIdFlow: MutableStateFlow = MutableStateFlow(null) private val remoteParticipantsFlow = MutableStateFlow(emptyList()) private val backstageFlow = MutableStateFlow(false) + private val atomicNotification = AtomicReference(null) // Captured callback private var startArgs: Quadruple? = null @@ -79,6 +93,8 @@ class CallServiceNotificationUpdateObserverTest { @Before fun setup() { Dispatchers.setMain(dispatcher) + mockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") + every { isAndroid17OrHigher() } returns false callState = mockk { every { ringingState } returns ringingStateFlow @@ -86,6 +102,9 @@ class CallServiceNotificationUpdateObserverTest { every { remoteParticipants } returns remoteParticipantsFlow every { backstage } returns backstageFlow every { notificationIdFlow } returns testNotificationIdFlow + every { + atomicNotification + } returns this@CallServiceNotificationUpdateObserverTest.atomicNotification } call = mockk { @@ -96,15 +115,23 @@ class CallServiceNotificationUpdateObserverTest { } streamState = mockk(relaxed = true) + notificationUpdateDeduplicator = mockk(relaxed = true) + notificationConfig = mockk { + every { notificationUpdateTriggers(call) } returns null + every { incomingRingingNotificationUpdateDelayMillis } returns 1_300L + } + val streamNotificationManager = mockk { + every { this@mockk.notificationConfig } returns + this@CallServiceNotificationUpdateObserverTest.notificationConfig + every { notificationUpdateDeduplicator } returns + this@CallServiceNotificationUpdateObserverTest.notificationUpdateDeduplicator + } streamVideo = mockk { + every { context } returns this@CallServiceNotificationUpdateObserverTest.context every { state } returns streamState coEvery { onCallNotificationUpdate(call) } returns notification - every { streamNotificationManager } returns mockk { - every { notificationConfig } returns mockk { - every { notificationUpdateTriggers(call) } returns null - } - } + every { this@mockk.streamNotificationManager } returns streamNotificationManager } permissionManager = mockk { @@ -118,12 +145,14 @@ class CallServiceNotificationUpdateObserverTest { permissionManager = permissionManager, onStartService = { id, notif, trigger, type -> startArgs = Quadruple(id, notif, trigger, type) + onStartService(id, notif, trigger, type) }, ) } @After fun tearDown() { + unmockkStatic("io.getstream.video.android.core.utils.AndroidVersionCodesKt") Dispatchers.resetMain() } @@ -136,7 +165,7 @@ class CallServiceNotificationUpdateObserverTest { @Test fun `incoming ringing state starts incoming foreground notification`() = runTest { - observer.observe(context) + observer.observe() ringingStateFlow.value = RingingState.Incoming() advanceUntilIdle() @@ -153,9 +182,113 @@ class CallServiceNotificationUpdateObserverTest { assertEquals(42, args.fourth) } + @Test + fun `duplicate incoming notification update is skipped`() = runTest { + every { isAndroid17OrHigher() } returns true + every { + notificationUpdateDeduplicator.isDuplicate( + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns true + testNotificationIdFlow.value = 123 + atomicNotification.set(notification) + ringingStateFlow.value = RingingState.Incoming() + + observer.observe() + runCurrent() + + assertNull(startArgs) + verify(exactly = 1) { + notificationUpdateDeduplicator.isDuplicate( + call, + any(), + 123, + notification, + 123, + notification, + ) + } + verify(exactly = 0) { + notificationConfig.incomingRingingNotificationUpdateDelayMillis + } + verify(exactly = 0) { permissionManager.getServiceType(any(), any()) } + verify(exactly = 0) { onStartService(any(), any(), any(), any()) } + } + + @Test + fun `incoming notification below Android 17 is not deduplicated`() = runTest { + testNotificationIdFlow.value = 123 + atomicNotification.set(notification) + ringingStateFlow.value = RingingState.Incoming() + + observer.observe() + runCurrent() + + assertNotNull(startArgs) + verify(exactly = 0) { + notificationUpdateDeduplicator.isDuplicate( + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `non duplicate incoming notification update is delayed`() = runTest { + every { isAndroid17OrHigher() } returns true + testNotificationIdFlow.value = 123 + atomicNotification.set(notification) + ringingStateFlow.value = RingingState.Incoming() + + observer.observe() + runCurrent() + + assertNull(startArgs) + + advanceTimeBy(1_299L) + assertNull(startArgs) + + advanceTimeBy(1L) + runCurrent() + assertNotNull(startArgs) + } + + @Test + fun `null notification id does not disable delay for later incoming update`() = runTest { + every { isAndroid17OrHigher() } returns true + observer.observe() + runCurrent() + + ringingStateFlow.value = RingingState.Incoming() + advanceTimeBy(100L) + runCurrent() + + assertNotNull(startArgs) + + startArgs = null + testNotificationIdFlow.value = 123 + membersFlow.value = listOf(mockk(relaxed = true)) + + advanceTimeBy(1_299L) + assertNull(startArgs) + + advanceTimeBy(1L) + runCurrent() + assertNotNull(startArgs) + } + @Test fun `outgoing ringing state starts outgoing foreground notification`() = runTest { - observer.observe(context) + observer.observe() advanceUntilIdle() ringingStateFlow.value = RingingState.Outgoing() @@ -179,7 +312,7 @@ class CallServiceNotificationUpdateObserverTest { every { streamVideo.getStreamNotificationDispatcher() } returns notificationDispatcher coEvery { streamVideo.onCallNotificationUpdate(call) } returns mockNotification - observer.observe(context) + observer.observe() advanceUntilIdle() @@ -203,7 +336,7 @@ class CallServiceNotificationUpdateObserverTest { fun `no notification generated does not start foreground service`() = runTest { coEvery { streamVideo.onCallNotificationUpdate(call) } returns null - observer.observe(context) + observer.observe() advanceUntilIdle() ringingStateFlow.value = RingingState.Incoming() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserverTest.kt new file mode 100644 index 00000000000..c8cab6aa3f2 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/observers/TelecomNotificationUpdateObserverTest.kt @@ -0,0 +1,91 @@ +/* + * 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 io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +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.model.StreamCallId +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlin.test.Test + +class TelecomNotificationUpdateObserverTest { + + private val callId = StreamCallId("default", "call-id") + private val call = mockk() + private val callState = mockk() + private val streamVideo = mockk() + private val dispatcher = mockk(relaxed = true) + private val notification = mockk() + private val notificationId = MutableStateFlow(null) + private val observer = TelecomNotificationUpdateObserver( + call, + streamVideo, + TestScope(UnconfinedTestDispatcher()), + ) + + init { + every { call.type } returns callId.type + every { call.id } returns callId.id + every { call.state } returns callState + every { callState.notificationIdFlow } returns notificationId + every { streamVideo.getStreamNotificationDispatcher() } returns dispatcher + } + + @Test + fun `incoming update reuses existing notification ID`() { + notificationId.value = 42 + + observer.showIncomingCallNotification(callId, notification) + + verify { dispatcher.notify(callId, 42, notification) } + } + + @Test + fun `incoming update uses incoming ID when no notification was posted`() { + observer.showIncomingCallNotification(callId, notification) + + verify { + dispatcher.notify( + callId, + callId.getNotificationId(NotificationType.Incoming), + notification, + ) + } + } + + @Test + fun `outgoing update uses outgoing ID when no notification was posted`() { + observer.showOutgoingCallNotification(callId, notification) + + verify { + dispatcher.notify( + callId, + callId.getNotificationId(NotificationType.Outgoing), + notification, + ) + } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissionsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissionsTest.kt index 3c7f1d2dc2c..78821901780 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissionsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/TelecomPermissionsTest.kt @@ -134,6 +134,21 @@ class TelecomPermissionsTest { assertTrue(result) } + @Test + fun `canUseTelecom without call service config returns true when all platform conditions pass`() { + val client = mockk { + every { telecomConfig } returns mockk() + } + every { StreamVideo.instanceOrNull() } returns client + every { packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) } returns true + every { telecomManager.defaultDialerPackage } returns "com.android.dialer" + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.MANAGE_OWN_CALLS) + } returns PackageManager.PERMISSION_GRANTED + val callConfigService = CallServiceConfig(enableTelecom = true) + assertTrue(telecomPermissions.canUseTelecom(callConfigService, context)) + } + @Test fun `canUseTelecom returns false when telecom disabled in config`() { val result = telecomPermissions.canUseTelecom( diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepositoryTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepositoryTest.kt new file mode 100644 index 00000000000..962c49d1f86 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/telecom/jetpack/JetpackTelecomRepositoryTest.kt @@ -0,0 +1,146 @@ +/* + * 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.telecom.jetpack + +import android.net.Uri +import android.os.Build +import android.os.ParcelUuid +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallsManager +import io.getstream.video.android.core.notifications.internal.telecom.IncomingCallTelecomAction +import io.getstream.video.android.model.StreamCallId +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class JetpackTelecomRepositoryTest { + + private lateinit var callsManager: CallsManager + private lateinit var repository: JetpackTelecomRepository + + @Before + fun setup() { + callsManager = mockk() + repository = JetpackTelecomRepository( + callsManager = callsManager, + callId = StreamCallId("default", "call-id"), + incomingCallTelecomAction = mockk(relaxed = true), + ) + } + + @Test + fun `registerCall invokes callback after Telecom enters call scope`() = runTest { + val attributes = slot() + val callControlScope = mockk(relaxed = true) { + every { coroutineContext } returns EmptyCoroutineContext + every { getCallId() } returns ParcelUuid(UUID.randomUUID()) + every { currentCallEndpoint } returns emptyFlow() + every { availableEndpoints } returns emptyFlow() + every { isMuted } returns emptyFlow() + } + coEvery { + callsManager.addCall( + capture(attributes), + any(), + any(), + any(), + any(), + any(), + ) + } coAnswers { + arg Unit>(5).invoke(callControlScope) + } + var registeredStateSeen = false + + repository.registerCall( + displayName = "Caller", + address = Uri.parse("stream:call-id"), + isIncoming = true, + isVideoCall = true, + onRegistered = { + registeredStateSeen = repository.currentCall.value is TelecomCall.Registered + }, + onException = { throw AssertionError("Unexpected registration failure", it) }, + ) + + assertTrue(registeredStateSeen) + assertEquals(CallAttributesCompat.DIRECTION_INCOMING, attributes.captured.direction) + assertEquals(CallAttributesCompat.CALL_TYPE_VIDEO_CALL, attributes.captured.callType) + assertTrue(repository.currentCall.value is TelecomCall.None) + } + + @Test + fun `registerCall reports non-cancellation failures`() = runTest { + val failure = IllegalStateException("Telecom rejected call") + coEvery { + callsManager.addCall(any(), any(), any(), any(), any(), any()) + } throws failure + val onException = mockk<(Exception) -> Unit>(relaxed = true) + + repository.registerCall( + "Caller", + Uri.parse("stream:call-id"), + true, + false, + onRegistered = { error("Registration must not succeed") }, + onException = onException, + ) + + verify { onException(failure) } + assertTrue(repository.currentCall.value is TelecomCall.None) + } + + @Test + fun `registerCall rethrows coroutine cancellation without reporting failure`() = runTest { + val cancellation = CancellationException("test cancelled") + coEvery { + callsManager.addCall(any(), any(), any(), any(), any(), any()) + } throws cancellation + val onException = mockk<(Exception) -> Unit>(relaxed = true) + + assertFailsWith { + repository.registerCall( + "Caller", + Uri.parse("stream:call-id"), + true, + false, + onRegistered = {}, + onException = onException, + ) + } + + verify(exactly = 0) { onException(any()) } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt index db33336e90a..0c04cc6ef3d 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/utils/AndroidVersionCodesTest.kt @@ -31,4 +31,14 @@ class AndroidVersionCodesTest { fun `Android 17 SDK is Android 17 or higher`() { assertTrue(isAndroid17OrHigher(BUILD_VERSION_CODES_CINNAMON_BUN)) } + + @Test + fun `SDK below Android 17 is pre Android 17`() { + assertTrue(isPreAndroid17(BUILD_VERSION_CODES_CINNAMON_BUN - 1)) + } + + @Test + fun `Android 17 SDK is not pre Android 17`() { + assertFalse(isPreAndroid17(BUILD_VERSION_CODES_CINNAMON_BUN)) + } }