From ba4d34fa6620ff83b18233eb32e80773663642df Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 17 Jun 2026 20:00:52 +0530 Subject: [PATCH 01/12] temp: force-commit --- .../BatteryRestrictionsDetector.kt | 33 ++++++++ .../StreamDefaultNotificationHandler.kt | 47 +++-------- .../core/notifications/style/StyleProvider.kt | 77 +++++++++++++++++++ 3 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/style/StyleProvider.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt new file mode 100644 index 0000000000..c282db3c94 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt @@ -0,0 +1,33 @@ +/* + * 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.ActivityManager +import android.content.Context +import android.os.Build + +internal class BatteryRestrictionsDetector(private val context: Context) { + + fun isRestricted(): Boolean { + val am = context.getSystemService(ActivityManager::class.java) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + am.isBackgroundRestricted + } else { + false + } + } +} 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 f0ccc79963..c610048ece 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 @@ -31,10 +31,7 @@ import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationCompat.CallStyle import androidx.core.app.NotificationManagerCompat -import androidx.core.app.Person -import androidx.core.graphics.drawable.IconCompat import io.getstream.android.push.permissions.DefaultNotificationPermissionHandler import io.getstream.android.push.permissions.NotificationPermissionHandler import io.getstream.android.video.generated.models.LocalCallMissedEvent @@ -48,6 +45,7 @@ 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.internal.ExperimentalStreamVideoApi +import io.getstream.video.android.core.notifications.BatteryRestrictionsDetector import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver import io.getstream.video.android.core.notifications.IncomingNotificationAction @@ -61,6 +59,7 @@ import io.getstream.video.android.core.notifications.dispatchers.NotificationDis import io.getstream.video.android.core.notifications.extractor.DefaultNotificationContentExtractor import io.getstream.video.android.core.notifications.internal.service.CallService.Companion.TRIGGER_INCOMING_CALL import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher +import io.getstream.video.android.core.notifications.style.StyleProvider import io.getstream.video.android.core.utils.isAppInForeground import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.model.StreamCallId @@ -151,6 +150,8 @@ constructor( private val logger by taggedLogger("Video:StreamNotificationHandler") private val serviceLauncher = ServiceLauncher(application) + private val styleProvider = StyleProvider(application) + private val batteryRestrictionsDetector = BatteryRestrictionsDetector(application) internal fun shouldShowIncomingCallNotification( callBusyHandler: CallBusyHandler, @@ -1088,28 +1089,11 @@ constructor( logger.d { "[addHangUpAction] Adding hang up action for $callDisplayName (remoteParticipantCount=$remoteParticipantCount)" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && batteryRestrictionsDetector.isRestricted()) { setStyle( - CallStyle.forOngoingCall( - Person.Builder().setName(callDisplayName).apply { - if (remoteParticipantCount == 0) { - // Just one user in the call - setIcon( - IconCompat.createWithResource( - application, - R.drawable.stream_video_ic_user, - ), - ) - } else if (remoteParticipantCount > 1) { - // More than one user in the call - setIcon( - IconCompat.createWithResource( - application, - R.drawable.stream_video_ic_user_group, - ), - ) - } - }.build(), + styleProvider.getOutgoingCallStyle( + callDisplayName, + remoteParticipantCount, hangUpIntent, ), ) @@ -1124,19 +1108,10 @@ constructor( callDisplayName: String?, ): NotificationCompat.Builder = apply { logger.d { "[addCallActions] callDisplayName: $callDisplayName" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && batteryRestrictionsDetector.isRestricted()) { setStyle( - CallStyle.forIncomingCall( - Person.Builder().setName(callDisplayName ?: "Unknown").apply { - if (callDisplayName == null) { - setIcon( - IconCompat.createWithResource( - application, - R.drawable.stream_video_ic_user, - ), - ) - } - }.build(), + styleProvider.getIncomingCallStyle( + callDisplayName, rejectCallPendingIntent, acceptCallPendingIntent, ), diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/style/StyleProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/style/StyleProvider.kt new file mode 100644 index 0000000000..f47631df01 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/style/StyleProvider.kt @@ -0,0 +1,77 @@ +/* + * 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.style + +import android.app.Application +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.CallStyle +import androidx.core.app.Person +import androidx.core.graphics.drawable.IconCompat +import io.getstream.video.android.core.R + +internal class StyleProvider(val application: Application) { + fun getOutgoingCallStyle( + callDisplayName: String, + remoteParticipantCount: Int, + hangUpIntent: PendingIntent, + ): NotificationCompat.CallStyle { + return CallStyle.forOngoingCall( + Person.Builder().setName(callDisplayName).apply { + if (remoteParticipantCount == 0) { + // Just one user in the call + setIcon( + IconCompat.createWithResource( + application, + R.drawable.stream_video_ic_user, + ), + ) + } else if (remoteParticipantCount > 1) { + // More than one user in the call + setIcon( + IconCompat.createWithResource( + application, + R.drawable.stream_video_ic_user_group, + ), + ) + } + }.build(), + hangUpIntent, + ) + } + + fun getIncomingCallStyle( + callDisplayName: String?, + rejectCallPendingIntent: PendingIntent, + acceptCallPendingIntent: PendingIntent, + ): CallStyle { + return CallStyle.forIncomingCall( + Person.Builder().setName(callDisplayName ?: "Unknown").apply { + if (callDisplayName == null) { + setIcon( + IconCompat.createWithResource( + application, + R.drawable.stream_video_ic_user, + ), + ) + } + }.build(), + rejectCallPendingIntent, + acceptCallPendingIntent, + ) + } +} From 9a2a3c1b744971d26b21865dc8366a78f29f8fa2 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 19 Jun 2026 11:39:03 +0530 Subject: [PATCH 02/12] fix: invoke updateRingingState() when setting active call --- .../io/getstream/video/android/core/CallState.kt | 2 +- .../io/getstream/video/android/core/ClientState.kt | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 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 51441b06a2..d573cba909 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 @@ -1307,7 +1307,7 @@ public class CallState( } } - private fun updateRingingState(rejectReason: RejectReason? = null) { + internal fun updateRingingState(rejectReason: RejectReason? = null) { when (ringingState.value) { RingingState.TimeoutNoAnswer, RingingState.RejectedByAll -> { return diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt index 364a3a3ab0..462a09359a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt @@ -201,9 +201,17 @@ class ClientState(private val client: StreamVideo) { } else -> { removeRingingCall(call) - call.scope.launch { - delay(serviceTransitionDelayMs) - maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + val callServiceConfig = callConfigRegistry.get(call.type) + if (callServiceConfig.runCallServiceInForeground) { + call.scope.launch { + delay(serviceTransitionDelayMs) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } + } else { + // So that we can transition to Active State for non-ringing calls + if (ringingState is RingingState.Idle) { + call.state.updateRingingState() + } } } } From e0d1a8fb8f11e07ce18618f82eabd43657983a99 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 19 Jun 2026 18:06:20 +0530 Subject: [PATCH 03/12] fix: add canRunService on incoming calls --- .../StreamDefaultNotificationHandler.kt | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index c610048ece..04fd5d8dff 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 @@ -174,22 +174,26 @@ constructor( callId.cid, ) ) { - serviceLauncher.showIncomingCall( - application, - callId, - callDisplayName, - streamVideo.state.callConfigRegistry.get(callId.type), - isVideo = isVideoCall(callId, payload), - payload = payload, - streamVideo, - notification = getRingingCallNotification( - RingingState.Incoming(), + val canRunService = + streamVideo.callServiceConfigRegistry.get(callId.type).runCallServiceInForeground + if (canRunService) { + serviceLauncher.showIncomingCall( + application, callId, callDisplayName, - shouldHaveContentIntent = true, - payload, - ), - ) + streamVideo.state.callConfigRegistry.get(callId.type), + isVideo = isVideoCall(callId, payload), + payload = payload, + streamVideo, + notification = getRingingCallNotification( + RingingState.Incoming(), + callId, + callDisplayName, + shouldHaveContentIntent = true, + payload, + ), + ) + } } } From a3c0c336d4999a8c802d7beb52187fe6648ca89f Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 19 Jun 2026 18:08:17 +0530 Subject: [PATCH 04/12] demo-app: add screen to configure call settings --- .../video/android/ui/CallSettingsUi.kt | 412 ++++++++++++++++++ .../video/android/ui/join/CallJoinScreen.kt | 27 +- .../android/util/StreamVideoInitHelper.kt | 10 + demo-app/src/main/res/values/strings.xml | 1 + 4 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt new file mode 100644 index 0000000000..06bc741ce3 --- /dev/null +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt @@ -0,0 +1,412 @@ +/* + * 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.ui + +import android.media.AudioAttributes +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.core.call.CallType +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.util.StreamVideoInitHelper + +/** + * A row in the call settings screen: one call type and its current [CallServiceConfig]. + */ +private data class CallTypeConfigItem( + val callType: CallType, + val label: String, + val config: CallServiceConfig, +) + +/** Selectable audio usage values exposed in the UI, mapped to [AudioAttributes] constants. */ +private data class AudioUsageOption(val label: String, val value: Int) + +private val audioUsageOptions = listOf( + AudioUsageOption("Voice comm", AudioAttributes.USAGE_VOICE_COMMUNICATION), + AudioUsageOption("Media", AudioAttributes.USAGE_MEDIA), +) + +/** The call types the demo app exposes for editing. */ +private val editableCallTypes = listOf( + CallType.Default to "Default", + CallType.AnyMarker to "All call types", + CallType.Livestream to "Livestream", + CallType.AudioCall to "Audio call", + CallType.AudioRoom to "Audio room", +) + +/** + * A debug screen that lets the user edit the live [CallServiceConfigRegistry] used by the active + * [io.getstream.video.android.core.StreamVideo] client. + * + * Each call type's configuration is read from the registry and any change is written straight + * back to it via [CallServiceConfigRegistry.register], so it takes effect for subsequent calls. + */ +@Composable +fun CallSettingsScreen( + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val registry = remember { StreamVideoInitHelper.callServiceConfigRegistry } + + // Intercept the system back press so it dismisses this overlay instead of finishing the activity. + BackHandler(onBack = onClose) + + var items by remember { + mutableStateOf( + registry?.let { reg -> + editableCallTypes.map { (callType, label) -> + CallTypeConfigItem(callType, label, reg.get(callType.name)) + } + }.orEmpty(), + ) + } + + fun updateConfig(callType: CallType, transform: (CallServiceConfig) -> CallServiceConfig) { + val reg = registry ?: return + items = items.map { item -> + if (item.callType == callType) { + val updated = transform(item.config) + // Write the change back to the live registry. copy() preserves the + // serviceClass and moderationConfig that the UI does not edit. + reg.register(callType.name, updated) + item.copy(config = updated) + } else { + item + } + } + } + + CallSettingsContent( + items = items, + registryAvailable = registry != null, + onClose = onClose, + onForegroundChanged = { callType, enabled -> + updateConfig(callType) { it.copy(runCallServiceInForeground = enabled) } + }, + onTelecomChanged = { callType, enabled -> + updateConfig(callType) { it.copy(enableTelecom = enabled) } + }, + onAudioUsageChanged = { callType, usage -> + updateConfig(callType) { it.copy(audioUsage = usage) } + }, + modifier = modifier, + ) +} + +/** + * Stateless content for [CallSettingsScreen]. Kept separate from the stateful screen so it can be + * rendered in a @Preview without a live [CallServiceConfigRegistry]. + */ +@Composable +private fun CallSettingsContent( + items: List, + registryAvailable: Boolean, + onClose: () -> Unit, + onForegroundChanged: (CallType, Boolean) -> Unit, + onTelecomChanged: (CallType, Boolean) -> Unit, + onAudioUsageChanged: (CallType, Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(VideoTheme.colors.baseSheetPrimary), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(VideoTheme.colors.baseSheetSecondary) + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Call Settings", + style = VideoTheme.typography.subtitleM, + color = VideoTheme.colors.basePrimary, + ) + + Box( + modifier = Modifier + .background( + color = VideoTheme.colors.baseSheetTertiary, + shape = RoundedCornerShape(999.dp), + ) + .clickable(onClick = onClose) + .padding(8.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = VideoTheme.colors.basePrimary, + ) + } + } + + if (!registryAvailable) { + Text( + text = "Call service config registry is not available yet. " + + "Sign in / start the SDK first.", + modifier = Modifier.padding(16.dp), + style = VideoTheme.typography.bodyM, + color = VideoTheme.colors.baseSecondary, + ) + return@Column + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(items, key = { it.callType.name }) { item -> + CallTypeConfigCard( + item = item, + onForegroundChanged = { enabled -> onForegroundChanged(item.callType, enabled) }, + onTelecomChanged = { enabled -> onTelecomChanged(item.callType, enabled) }, + onAudioUsageChanged = { usage -> onAudioUsageChanged(item.callType, usage) }, + ) + } + } + } +} + +@Composable +private fun CallTypeConfigCard( + item: CallTypeConfigItem, + onForegroundChanged: (Boolean) -> Unit, + onTelecomChanged: (Boolean) -> Unit, + onAudioUsageChanged: (Int) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = VideoTheme.colors.baseSheetSecondary, + shape = RoundedCornerShape(16.dp), + ) + .padding(16.dp), + ) { + Text( + text = item.label, + style = VideoTheme.typography.labelL, + color = VideoTheme.colors.basePrimary, + ) + Text( + text = "type: ${item.callType.name}", + modifier = Modifier.padding(top = 2.dp), + style = VideoTheme.typography.labelM, + color = VideoTheme.colors.baseSecondary, + ) + Text( + text = "service: ${item.config.serviceClass.simpleName}", + modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), + style = VideoTheme.typography.labelM, + color = VideoTheme.colors.baseSecondary, + ) + + ToggleRow( + label = "Run service in foreground", + checked = item.config.runCallServiceInForeground, + onCheckedChange = onForegroundChanged, + ) + ToggleRow( + label = "Enable Telecom", + checked = item.config.enableTelecom, + onCheckedChange = onTelecomChanged, + ) + + Text( + text = "Audio usage", + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + style = VideoTheme.typography.bodyS, + color = VideoTheme.colors.basePrimary, + ) + AudioUsageSelector( + selected = item.config.audioUsage, + onSelected = onAudioUsageChanged, + ) + } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = VideoTheme.typography.bodyS, + color = VideoTheme.colors.basePrimary, + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = VideoTheme.colors.brandPrimary, + checkedTrackColor = VideoTheme.colors.brandPrimary, + uncheckedThumbColor = VideoTheme.colors.baseSecondary, + uncheckedTrackColor = VideoTheme.colors.baseSheetTertiary, + ), + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AudioUsageSelector( + selected: Int, + onSelected: (Int) -> Unit, +) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + audioUsageOptions.forEach { option -> + val isSelected = option.value == selected + Text( + text = option.label, + modifier = Modifier + .background( + color = if (isSelected) { + VideoTheme.colors.brandPrimary + } else { + VideoTheme.colors.baseSheetTertiary + }, + shape = RoundedCornerShape(999.dp), + ) + .clickable { onSelected(option.value) } + .padding(horizontal = 12.dp, vertical = 8.dp), + style = VideoTheme.typography.bodyS, + color = if (isSelected) { + VideoTheme.colors.baseSheetPrimary + } else { + VideoTheme.colors.basePrimary + }, + ) + } + } +} + +private fun previewCallTypeConfigItems(): List = listOf( + CallTypeConfigItem( + callType = CallType.Default, + label = "Default", + config = CallServiceConfig( + runCallServiceInForeground = true, + audioUsage = AudioAttributes.USAGE_VOICE_COMMUNICATION, + enableTelecom = false, + ), + ), + CallTypeConfigItem( + callType = CallType.AudioCall, + label = "Audio call", + config = CallServiceConfig( + runCallServiceInForeground = false, + audioUsage = AudioAttributes.USAGE_MEDIA, + enableTelecom = true, + ), + ), + CallTypeConfigItem( + callType = CallType.Livestream, + label = "Livestream", + config = CallServiceConfig( + runCallServiceInForeground = true, + audioUsage = AudioAttributes.USAGE_VOICE_COMMUNICATION, + enableTelecom = false, + ), + ), +) + +@Preview( + name = "Call Settings", + showBackground = true, + device = "spec:width=411dp,height=891dp,dpi=420", +) +@Composable +private fun CallSettingsContentPreview() { + VideoTheme { + CallSettingsContent( + items = previewCallTypeConfigItems(), + registryAvailable = true, + onClose = {}, + onForegroundChanged = { _, _ -> }, + onTelecomChanged = { _, _ -> }, + onAudioUsageChanged = { _, _ -> }, + ) + } +} + +@Preview( + name = "Call Settings — registry unavailable", + showBackground = true, + device = "spec:width=411dp,height=891dp,dpi=420", +) +@Composable +private fun CallSettingsUnavailablePreview() { + VideoTheme { + CallSettingsContent( + items = emptyList(), + registryAvailable = false, + onClose = {}, + onForegroundChanged = { _, _ -> }, + onTelecomChanged = { _, _ -> }, + onAudioUsageChanged = { _, _ -> }, + ) + } +} diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt index 7c31cf4367..6374d81dd6 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt @@ -103,6 +103,7 @@ import io.getstream.video.android.mock.StreamPreviewDataUtils import io.getstream.video.android.mock.previewUsers import io.getstream.video.android.model.User import io.getstream.video.android.tooling.util.StreamBuildFlavorUtil +import io.getstream.video.android.ui.CallSettingsScreen import io.getstream.video.android.ui.LogFilesScreen import io.getstream.video.android.ui.SingleButtonDialog import io.getstream.video.android.util.config.AppConfig @@ -125,6 +126,7 @@ fun CallJoinScreen( val isNetworkAvailable by callJoinViewModel.isNetworkAvailable.collectAsStateWithLifecycle() var renderLogsFileUi by remember { mutableStateOf(false) } + var renderCallSettingsUi by remember { mutableStateOf(false) } HandleCallJoinUiState( callJoinUiState = uiState, @@ -151,6 +153,9 @@ fun CallJoinScreen( onLogsClick = { renderLogsFileUi = true }, + onCallSettingsClink = { + renderCallSettingsUi = true + } ) CallJoinBody( @@ -195,6 +200,12 @@ fun CallJoinScreen( renderLogsFileUi = false }) } + + if (renderCallSettingsUi) { + CallSettingsScreen(onClose = { + renderCallSettingsUi = false + }) + } } @Composable @@ -224,6 +235,7 @@ private fun CallJoinHeader( onDirectCallClick: () -> Unit, onSignOutClick: () -> Unit, onLogsClick: () -> Unit, + onCallSettingsClink: () -> Unit, ) { Row( modifier = Modifier @@ -327,6 +339,19 @@ private fun CallJoinHeader( } Spacer(modifier = Modifier.width(5.dp)) if (!isProduction) { + StreamButton( + modifier = Modifier + .fillMaxWidth() + .testTag("Stream_CallSettingsButton"), + icon = Icons.Default.Settings, + style = VideoTheme.styles.buttonStyles.tertiaryButtonStyle(), + text = stringResource(id = R.string.call_settings), + onClick = { + showMenu = false + onCallSettingsClink() + }, + ) + Spacer(modifier = Modifier.width(5.dp)) StreamButton( modifier = Modifier .fillMaxWidth() @@ -743,6 +768,6 @@ private fun CallJoinScreenLandscapePreview() { private fun CallJoinScreenHeader() { StreamPreviewDataUtils.initializeStreamVideo(LocalContext.current) VideoTheme { - CallJoinHeader(previewUsers[0], false, true, {}, {}, {}, {}) + CallJoinHeader(previewUsers[0], false, true, {}, {}, {}, {}, {}) } } diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index 7cd5e7818d..6805bf63eb 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -130,6 +130,15 @@ object StreamVideoInitHelper { public val initializedState: StateFlow = _initState private var testNotificationConfig: NotificationConfig? = null + /** + * The [CallServiceConfigRegistry] passed to the active [StreamVideo] client. + * + * It is exposed here so debug UI (e.g. the in-app Call Settings screen) can read and mutate + * the live registry. + */ + var callServiceConfigRegistry: CallServiceConfigRegistry? = null + private set + fun init(appContext: Context, testNotificationConfig: NotificationConfig? = null) { context = appContext.applicationContext this.testNotificationConfig = testNotificationConfig @@ -390,6 +399,7 @@ object StreamVideoInitHelper { ) } } + this.callServiceConfigRegistry = callServiceConfigRegistry return StreamVideoBuilder( context = context, diff --git a/demo-app/src/main/res/values/strings.xml b/demo-app/src/main/res/values/strings.xml index 1e5eedfce8..ccf47317cd 100644 --- a/demo-app/src/main/res/values/strings.xml +++ b/demo-app/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ Contact us Sign out Share Logs + Call Settings Start a new call, join a meeting by \nentering the call ID or by scanning \na QR code. Join Call Scan QR meeting code From 71c6d23e83aa7f63593fdf105b59a07c3cddc9d3 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 19 Jun 2026 18:21:47 +0530 Subject: [PATCH 05/12] fix: refactor --- .../io/getstream/video/android/ui/CallSettingsUi.kt | 7 ++++++- .../video/android/ui/join/CallJoinScreen.kt | 2 +- .../io/getstream/video/android/core/ClientState.kt | 13 ++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt index 06bc741ce3..9a405fd2e1 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt @@ -208,7 +208,12 @@ private fun CallSettingsContent( items(items, key = { it.callType.name }) { item -> CallTypeConfigCard( item = item, - onForegroundChanged = { enabled -> onForegroundChanged(item.callType, enabled) }, + onForegroundChanged = { enabled -> + onForegroundChanged( + item.callType, + enabled, + ) + }, onTelecomChanged = { enabled -> onTelecomChanged(item.callType, enabled) }, onAudioUsageChanged = { usage -> onAudioUsageChanged(item.callType, usage) }, ) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt index 6374d81dd6..56ef53eff1 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt @@ -155,7 +155,7 @@ fun CallJoinScreen( }, onCallSettingsClink = { renderCallSettingsUi = true - } + }, ) CallJoinBody( diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt index 462a09359a..5656561f71 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt @@ -175,12 +175,16 @@ class ClientState(private val client: StreamVideo) { this._activeCall.value = call val serviceTransitionDelayMs = 500L val ringingState = call.state.ringingState.value + val callServiceConfig = callConfigRegistry.get(call.type) + when (ringingState) { is RingingState.Incoming -> { transitionToAcceptCall(call) - call.scope.launch { - delay(serviceTransitionDelayMs) - maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + if (callServiceConfig.runCallServiceInForeground) { + call.scope.launch { + delay(serviceTransitionDelayMs) + maybeStartForegroundService(call, CallService.TRIGGER_ONGOING_CALL) + } } } is RingingState.Outgoing -> { @@ -189,7 +193,6 @@ class ClientState(private val client: StreamVideo) { } // Intentionally skipping maybeStartForegroundService because service should already be started // when initiating outgoing-call - val callServiceConfig = callConfigRegistry.get(call.type) val serviceClass = callServiceConfig.serviceClass val isServiceRunning = ServiceIntentBuilder() .isServiceRunning(this.client.context, serviceClass) @@ -201,7 +204,7 @@ class ClientState(private val client: StreamVideo) { } else -> { removeRingingCall(call) - val callServiceConfig = callConfigRegistry.get(call.type) + if (callServiceConfig.runCallServiceInForeground) { call.scope.launch { delay(serviceTransitionDelayMs) From 7397bf39cab6985da75b9e2dcb0d0d9dc61e06e7 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Jun 2026 13:20:52 +0530 Subject: [PATCH 06/12] fix: refactor --- .../handlers/StreamDefaultNotificationHandler.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index 04fd5d8dff..a2e39c8c04 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 @@ -45,7 +45,6 @@ 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.internal.ExperimentalStreamVideoApi -import io.getstream.video.android.core.notifications.BatteryRestrictionsDetector import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver import io.getstream.video.android.core.notifications.IncomingNotificationAction @@ -151,7 +150,6 @@ constructor( private val logger by taggedLogger("Video:StreamNotificationHandler") private val serviceLauncher = ServiceLauncher(application) private val styleProvider = StyleProvider(application) - private val batteryRestrictionsDetector = BatteryRestrictionsDetector(application) internal fun shouldShowIncomingCallNotification( callBusyHandler: CallBusyHandler, @@ -1093,7 +1091,7 @@ constructor( logger.d { "[addHangUpAction] Adding hang up action for $callDisplayName (remoteParticipantCount=$remoteParticipantCount)" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && batteryRestrictionsDetector.isRestricted()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setStyle( styleProvider.getOutgoingCallStyle( callDisplayName, @@ -1112,7 +1110,7 @@ constructor( callDisplayName: String?, ): NotificationCompat.Builder = apply { logger.d { "[addCallActions] callDisplayName: $callDisplayName" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && batteryRestrictionsDetector.isRestricted()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setStyle( styleProvider.getIncomingCallStyle( callDisplayName, From 33702abbbddf3565ae424a7166f87062aa3f2b34 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Jun 2026 13:35:09 +0530 Subject: [PATCH 07/12] fix: remove unused classes --- .../BatteryRestrictionsDetector.kt | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt deleted file mode 100644 index c282db3c94..0000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/BatteryRestrictionsDetector.kt +++ /dev/null @@ -1,33 +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.ActivityManager -import android.content.Context -import android.os.Build - -internal class BatteryRestrictionsDetector(private val context: Context) { - - fun isRestricted(): Boolean { - val am = context.getSystemService(ActivityManager::class.java) - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - am.isBackgroundRestricted - } else { - false - } - } -} From 6f893dd87fe23b00ed2197ccbac7c626f9aaed6f Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Jun 2026 16:06:02 +0530 Subject: [PATCH 08/12] fix: Fix a crash when application put to background restriction by rendering normal notification instead of CallStyle notification --- .../android/core/BackgroundRestrictions.kt | 33 +++++++++++++++++++ .../StreamDefaultNotificationHandler.kt | 16 +++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt new file mode 100644 index 0000000000..87c80c3e0b --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt @@ -0,0 +1,33 @@ +/* + * 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 + +import android.app.ActivityManager +import android.content.Context +import android.os.Build + +internal class BackgroundRestrictions(private val context: Context) { + + fun isRestricted(): Boolean { + val am = context.getSystemService(ActivityManager::class.java) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + am.isBackgroundRestricted + } else { + false + } + } +} 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 a2e39c8c04..63f52ec022 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 @@ -36,6 +36,7 @@ import io.getstream.android.push.permissions.DefaultNotificationPermissionHandle 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.BackgroundRestrictions import io.getstream.video.android.core.Call import io.getstream.video.android.core.MemberState import io.getstream.video.android.core.ParticipantState @@ -150,6 +151,7 @@ constructor( private val logger by taggedLogger("Video:StreamNotificationHandler") private val serviceLauncher = ServiceLauncher(application) private val styleProvider = StyleProvider(application) + private val batteryRestrictions = BackgroundRestrictions(application) internal fun shouldShowIncomingCallNotification( callBusyHandler: CallBusyHandler, @@ -1091,7 +1093,12 @@ constructor( logger.d { "[addHangUpAction] Adding hang up action for $callDisplayName (remoteParticipantCount=$remoteParticipantCount)" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + /** + * CallStyle notifications can trigger + * CannotPostForegroundServiceNotificationException ("Bad notification for startForeground") + * on Android 12+ when the app is background-restricted. Fall back to a standard notification. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !batteryRestrictions.isRestricted()) { setStyle( styleProvider.getOutgoingCallStyle( callDisplayName, @@ -1110,7 +1117,12 @@ constructor( callDisplayName: String?, ): NotificationCompat.Builder = apply { logger.d { "[addCallActions] callDisplayName: $callDisplayName" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + /** + * CallStyle notifications can trigger + * CannotPostForegroundServiceNotificationException ("Bad notification for startForeground") + * on Android 12+ when the app is background-restricted. Fall back to a standard notification. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !batteryRestrictions.isRestricted()) { setStyle( styleProvider.getIncomingCallStyle( callDisplayName, From 4b7a4c2f5f8a6c42d9048f1f8d0773e7c342aa44 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Jun 2026 16:43:28 +0530 Subject: [PATCH 09/12] fix: add safe null-check --- .../io/getstream/video/android/core/BackgroundRestrictions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt index 87c80c3e0b..fe00656878 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/BackgroundRestrictions.kt @@ -25,7 +25,7 @@ internal class BackgroundRestrictions(private val context: Context) { fun isRestricted(): Boolean { val am = context.getSystemService(ActivityManager::class.java) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - am.isBackgroundRestricted + am.isBackgroundRestricted ?: false } else { false } From 485ca6621a76bf8642b69208870330c4195100eb Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 22 Jun 2026 16:46:14 +0530 Subject: [PATCH 10/12] fix: fix unit-test --- .../handlers/StreamDefaultNotificationHandlerTest.kt | 2 ++ 1 file changed, 2 insertions(+) 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 ef1473b580..ec49be03cc 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 @@ -120,7 +120,9 @@ class StreamDefaultNotificationHandlerTest { every { StreamVideo.instance() } returns mockStreamVideo every { mockStreamVideo.state } returns mockState every { mockState.callConfigRegistry } returns mockCallConfigRegistry + every { mockStreamVideo.callServiceConfigRegistry } returns mockCallConfigRegistry every { mockCallConfigRegistry.get(any()) } returns mockCallServiceConfig + every { mockCallServiceConfig.runCallServiceInForeground } returns true every { mockStreamVideo.callBusyHandler } returns callBusyHandler // Mock NotificationCompat.Builder to avoid Android framework issues From 5e2d8dd8af5618c2c5d61dfa885be6be1e733d34 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 23 Jun 2026 10:26:03 +0530 Subject: [PATCH 11/12] fix: fix notification cleaning from leave button in notification --- .../DefaultStreamIntentResolver.kt | 4 ---- .../core/notifications/NotificationHandler.kt | 5 +++++ .../receivers/LeaveCallBroadcastReceiver.kt | 18 ++++++++++++++---- .../AbstractNotificationActivity.kt | 13 ++++++++----- 4 files changed, 27 insertions(+), 13 deletions(-) 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 ed075073d6..f43bd47e8a 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 @@ -367,10 +367,6 @@ public class DefaultStreamIntentResolver( resolveInfo.activityInfo.name, ) putExtra(NotificationHandler.INTENT_EXTRA_CALL_CID, callId) - putExtra( - NotificationHandler.INTENT_EXTRA_NOTIFICATION_ID, - callId.cid, - ) } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt index cf9ae85a0f..7f0c9890b1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/NotificationHandler.kt @@ -70,6 +70,11 @@ public interface NotificationHandler : const val INTENT_EXTRA_CALL_DISPLAY_NAME: String = "io.getstream.video.android.intent-extra.call_displayname" + @Deprecated( + message = "Notification ids are managed internally and this extra is no longer " + + "populated. Read the active notification id from CallState.notificationIdFlow, or " + + "derive it with StreamCallId.getNotificationId(NotificationType).", + ) const val INTENT_EXTRA_NOTIFICATION_ID: String = "io.getstream.video.android.intent-extra.notification_id" diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt index 53a79ce29b..a58aee070f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/receivers/LeaveCallBroadcastReceiver.kt @@ -24,7 +24,7 @@ import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallLeaveReason import io.getstream.video.android.core.UserActionCause import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_LEAVE_CALL -import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_NOTIFICATION_ID +import io.getstream.video.android.model.StreamCallId /** * Used to process any pending intents that feature the [ACTION_LEAVE_CALL] action. By consuming this @@ -39,13 +39,23 @@ internal class LeaveCallBroadcastReceiver : GenericCallActionBroadcastReceiver() override suspend fun onReceive(call: Call, context: Context, intent: Intent) { logger.d { "[onReceive] #ringing; callId: ${call.id}, action: ${intent.action}" } + // A call has at most one notification; its id is stored at creation time in + // CallState.notificationIdFlow (the source of truth, replacing the deprecated + // INTENT_EXTRA_NOTIFICATION_ID extra). + val notificationId = call.state.notificationIdFlow.value + // TODO: remove this legacy notification id once nothing posts under StreamCallId.hashCode(). + val legacyNotificationId = StreamCallId.fromCallCid(call.cid).hashCode() + call.leave( CallLeaveReason.UserAction( UserActionCause.LEAVE_FROM_NOTIFICATION, ), ) - val notificationId = intent.getIntExtra(INTENT_EXTRA_NOTIFICATION_ID, 0) - logger.d { "[onReceive], notificationId: $notificationId" } - NotificationManagerCompat.from(context).cancel(notificationId) + logger.d { + "[onReceive], notificationId: $notificationId, legacyNotificationId: $legacyNotificationId" + } + val notificationManager = NotificationManagerCompat.from(context) + notificationId?.let { notificationManager.cancel(it) } + notificationManager.cancel(legacyNotificationId) } } diff --git a/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/notification/AbstractNotificationActivity.kt b/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/notification/AbstractNotificationActivity.kt index b63545333a..be0d212607 100644 --- a/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/notification/AbstractNotificationActivity.kt +++ b/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/notification/AbstractNotificationActivity.kt @@ -22,7 +22,7 @@ import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.lifecycleScope import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_CALL_CID -import io.getstream.video.android.core.notifications.NotificationHandler.Companion.INTENT_EXTRA_NOTIFICATION_ID +import io.getstream.video.android.core.notifications.NotificationType import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.streamCallId import kotlinx.coroutines.launch @@ -56,7 +56,7 @@ public abstract class AbstractNotificationActivity : ComponentActivity() { lifecycleScope.launch { if (hasAcceptedCall) { - dismissIncomingCallNotifications() + dismissIncomingCallNotifications(callCid) } else { loadCallData(callCid) } @@ -74,15 +74,18 @@ public abstract class AbstractNotificationActivity : ComponentActivity() { // is Result.Success -> Unit // is Result.Failure -> finish() // } - dismissIncomingCallNotifications() + dismissIncomingCallNotifications(guid) } /** * Dismisses any notifications that might be active with a given notification ID. * Used to clear up the notification state if the call has been accepted or rejected. */ - private fun dismissIncomingCallNotifications() { - val notificationId = intent.getIntExtra(INTENT_EXTRA_NOTIFICATION_ID, 0) + private fun dismissIncomingCallNotifications(callCid: StreamCallId) { + // A call has a single notification; the incoming-call notification's id comes from the + // shared generator, so derive it from the call id and cancel it. (Replaces the deprecated + // INTENT_EXTRA_NOTIFICATION_ID extra.) + val notificationId = callCid.getNotificationId(NotificationType.Incoming) NotificationManagerCompat.from(this).cancel(notificationId) finish() } From 838d4b9ede2ae2247d0ddcce56f902f7c81ab447 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 23 Jun 2026 10:29:55 +0530 Subject: [PATCH 12/12] fix: add todos for future fixes --- .../handlers/StreamDefaultNotificationHandler.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 63f52ec022..034408afb0 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 @@ -203,6 +203,7 @@ constructor( payload: Map, ) { logger.d { "[onLiveCall] callId: ${callId.id}, callDisplayName: $callDisplayName" } + // TODO: Replace StreamCallId.hashCode with StreamCallId.getNotificationId(appropriateType) val notificationId = callId.hashCode() val liveCallPendingIntent = intentResolver.searchLiveCallPendingIntent(callId, notificationId, payload) @@ -253,6 +254,7 @@ constructor( payload: Map, ) { logger.d { "[onNotification] callId: ${callId.id}, callDisplayName: $callDisplayName" } + // TODO: Replace StreamCallId.hashCode with StreamCallId.getNotificationId(appropriateType) val notificationId = callId.hashCode() val intent = intentResolver.searchNotificationCallPendingIntent( callId, @@ -745,6 +747,7 @@ constructor( logger.d { "[getSimpleOngoingCallNotification] callId: ${callId.id}, callDisplayName: $callDisplayName, isOutgoingCall: $isOutgoingCall, remoteParticipantCount: $remoteParticipantCount" } + // TODO: Replace StreamCallId.hashCode with StreamCallId.getNotificationId(appropriateType) val notificationId = callId.hashCode() // Notification ID // Intents @@ -1180,6 +1183,7 @@ constructor( }, ): Notification { logger.d { "[getMinimalMediaStyleNotification] callId: ${callId.id}" } + // TODO: Replace StreamCallId.hashCode with StreamCallId.getNotificationId(appropriateType) val notificationId = callId.hashCode() // Notification ID // Intents val onClickIntent = intentResolver.searchOngoingCallPendingIntent(