Skip to content

Commit 07d5a79

Browse files
committed
feat(core): support notification-owned incoming ringing
1 parent bf41261 commit 07d5a79

16 files changed

Lines changed: 288 additions & 25 deletions

File tree

stream-video-android-core/api/stream-video-android-core.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9794,6 +9794,7 @@ public final class io/getstream/video/android/core/StreamVideoBuilder {
97949794
public fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ZZ)V
97959795
public synthetic fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
97969796
public final fun build ()Lio/getstream/video/android/core/StreamVideo;
9797+
public final fun debugUseNotificationRingtoneForIncomingCalls (Z)Lio/getstream/video/android/core/StreamVideoBuilder;
97979798
}
97989799

97999800
public abstract interface class io/getstream/video/android/core/StreamVideoConfig {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.video.android.core
18+
19+
internal sealed interface IncomingRingtoneOwner {
20+
data object Notification : IncomingRingtoneOwner
21+
data object Legacy : IncomingRingtoneOwner
22+
}

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@ public class StreamVideoBuilder @JvmOverloads constructor(
172172

173173
private var apiUrl: String? = null
174174
private var wssUrl: String? = null
175+
internal var debugUseNotificationRingtoneForIncomingCalls: Boolean = false
176+
private set
177+
178+
/**
179+
* Uses the incoming-call notification channel to play ringtone and vibration.
180+
*
181+
* This temporary opt-in is intended for validating notification-owned ringing before it
182+
* becomes the default on Android 17.
183+
*/
184+
public fun debugUseNotificationRingtoneForIncomingCalls(enabled: Boolean): StreamVideoBuilder =
185+
apply {
186+
debugUseNotificationRingtoneForIncomingCalls = enabled
187+
}
175188

176189
/**
177190
* Set the API URL to be used for the video client.
@@ -309,6 +322,8 @@ public class StreamVideoBuilder @JvmOverloads constructor(
309322
telecomConfig = telecomConfig,
310323
tokenRepository = tokenRepository,
311324
rejectCallWhenBusy = rejectCallWhenBusy,
325+
debugUseNotificationRingtoneForIncomingCalls =
326+
debugUseNotificationRingtoneForIncomingCalls,
312327
)
313328

314329
if (user.type == UserType.Guest) {

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ internal class StreamVideoClient internal constructor(
196196
internal val enableStereoForSubscriber: Boolean = true,
197197
internal val telecomConfig: TelecomConfig? = null,
198198
internal val rejectCallWhenBusy: Boolean = false,
199+
internal val debugUseNotificationRingtoneForIncomingCalls: Boolean = false,
199200
internal val analytics: AnalyticsModule = AnalyticsModule.getDefault(
200201
coordinatorConnectionModule.api,
201202
scope,

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/DefaultNotificationHandler.kt

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,26 @@ public open class DefaultNotificationHandler(
111111
payload: Map<String, Any?>,
112112
) {
113113
logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" }
114-
val streamVideo = StreamVideo.instance()
114+
val streamVideo = StreamVideo.instance() as StreamVideoClient
115+
val notificationPreparer = IncomingCallNotificationPreparer(streamVideo)
115116
streamVideo.state.serviceLauncher.showIncomingCall(
116117
callId,
117118
callDisplayName,
118119
streamVideo.state.callConfigRegistry.get(callId.type),
119120
isVideo = isVideoCall(callId, payload),
120121
payload = payload,
121-
notification = getRingingCallNotification(
122-
RingingState.Incoming(),
123-
callId,
124-
callDisplayName,
125-
shouldHaveContentIntent = true,
126-
payload,
127-
),
122+
notificationProvider = { owner ->
123+
val ringingState = RingingState.Incoming()
124+
getRingingCallNotification(
125+
ringingState,
126+
callId,
127+
callDisplayName,
128+
shouldHaveContentIntent = true,
129+
payload,
130+
)?.let { notification ->
131+
notificationPreparer.prepare(notification, owner, ringingState)
132+
}
133+
},
128134
)
129135
}
130136

@@ -406,6 +412,21 @@ public open class DefaultNotificationHandler(
406412
}
407413
this.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
408414
this.setShowBadge(true)
415+
val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient
416+
if (streamVideo?.debugUseNotificationRingtoneForIncomingCalls == true) {
417+
val audioAttributes = android.media.AudioAttributes.Builder()
418+
.setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
419+
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION)
420+
.build()
421+
setSound(
422+
streamVideo.sounds.ringingConfig.incomingCallSoundUri,
423+
audioAttributes,
424+
)
425+
streamVideo.vibrationConfig
426+
.takeIf { it.enabled }
427+
?.vibratePattern
428+
?.let { vibrationPattern = it }
429+
}
409430
}
410431
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
411432
this.setAllowBubbles(true)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.video.android.core.notifications
18+
19+
import android.app.Notification
20+
import androidx.core.app.NotificationCompat
21+
import io.getstream.video.android.core.IncomingRingtoneOwner
22+
import io.getstream.video.android.core.RingingState
23+
import io.getstream.video.android.core.StreamVideoClient
24+
import io.getstream.video.android.core.notifications.handlers.incomingCallNotificationFlags
25+
26+
internal class IncomingCallNotificationPreparer(
27+
private val streamVideo: StreamVideoClient,
28+
) {
29+
30+
private val context = streamVideo.context
31+
32+
fun prepare(
33+
nonRingingNotification: Notification,
34+
owner: IncomingRingtoneOwner,
35+
ringingState: RingingState.Incoming,
36+
): Notification = when (owner) {
37+
IncomingRingtoneOwner.Notification -> prepareRingingNotification(
38+
nonRingingNotification,
39+
ringingState,
40+
)
41+
IncomingRingtoneOwner.Legacy -> nonRingingNotification
42+
}
43+
44+
private fun prepareRingingNotification(
45+
nonRingingNotification: Notification,
46+
ringingState: RingingState.Incoming,
47+
): Notification {
48+
val channelId = requireNotNull(NotificationCompat.getChannelId(nonRingingNotification)) {
49+
"Incoming-call ringing requires a notification channel ID."
50+
}
51+
return NotificationCompat.Builder(context, nonRingingNotification)
52+
.setChannelId(channelId)
53+
.build()
54+
.apply {
55+
flags = incomingCallNotificationFlags(flags, ringingState)
56+
}
57+
}
58+
}

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/ChannelInfoProvider.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,17 @@ package io.getstream.video.android.core.notifications.handlers
1818

1919
import android.app.NotificationManager
2020
import android.content.Context
21+
import android.media.AudioAttributes
22+
import android.net.Uri
2123
import androidx.annotation.StringRes
2224
import androidx.core.app.NotificationChannelCompat
2325
import androidx.core.app.NotificationManagerCompat
2426
import io.getstream.video.android.core.utils.safeCall
2527

28+
internal fun shouldNotificationOwnIncomingRingtone(
29+
notificationRingtoneEnabled: Boolean,
30+
): Boolean = notificationRingtoneEnabled
31+
2632
/**
2733
* Provides a way to create a custom channel for the notification.
2834
*
@@ -87,6 +93,23 @@ internal fun StreamNotificationChannelInfo.create(manager: NotificationManagerCo
8793
)
8894
}
8995

96+
/** Creates an incoming-call channel whose sound and vibration are owned by the system. */
97+
internal fun StreamNotificationChannelInfo.createRingingChannel(
98+
manager: NotificationManagerCompat,
99+
soundUri: Uri?,
100+
audioAttributes: AudioAttributes,
101+
vibrationPattern: LongArray?,
102+
) = safeCall {
103+
manager.createNotificationChannel(
104+
NotificationChannelCompat.Builder(id, importance)
105+
.setName(name)
106+
.setDescription(description)
107+
.setSound(soundUri, audioAttributes)
108+
.setVibrationPattern(vibrationPattern)
109+
.build(),
110+
)
111+
}
112+
90113
/**
91114
* Provides the channel information for the notification.
92115
*

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import android.app.PendingIntent
2525
import android.content.Context
2626
import android.content.Intent
2727
import android.content.pm.PackageManager
28+
import android.media.AudioAttributes
2829
import android.os.Build
2930
import android.support.v4.media.MediaMetadataCompat
3031
import android.support.v4.media.session.MediaSessionCompat
@@ -47,6 +48,7 @@ import io.getstream.video.android.core.call.CallBusyHandler
4748
import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi
4849
import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver
4950
import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver
51+
import io.getstream.video.android.core.notifications.IncomingCallNotificationPreparer
5052
import io.getstream.video.android.core.notifications.IncomingNotificationAction
5153
import io.getstream.video.android.core.notifications.IncomingNotificationData
5254
import io.getstream.video.android.core.notifications.NotificationHandler.Companion.ACTION_LIVE_CALL
@@ -166,7 +168,8 @@ constructor(
166168
payload: Map<String, Any?>,
167169
) {
168170
logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" }
169-
val streamVideo = StreamVideo.instance()
171+
val streamVideo = StreamVideo.instance() as StreamVideoClient
172+
val notificationPreparer = IncomingCallNotificationPreparer(streamVideo)
170173
if (shouldShowIncomingCallNotification(
171174
(streamVideo as StreamVideoClient).callBusyHandler,
172175
callId.cid,
@@ -181,13 +184,18 @@ constructor(
181184
streamVideo.state.callConfigRegistry.get(callId.type),
182185
isVideo = isVideoCall(callId, payload),
183186
payload = payload,
184-
notification = getRingingCallNotification(
185-
RingingState.Incoming(),
186-
callId,
187-
callDisplayName,
188-
shouldHaveContentIntent = true,
189-
payload,
190-
),
187+
notificationProvider = { owner ->
188+
val ringingState = RingingState.Incoming()
189+
getRingingCallNotification(
190+
ringingState,
191+
callId,
192+
callDisplayName,
193+
shouldHaveContentIntent = true,
194+
payload,
195+
)?.let { notification ->
196+
notificationPreparer.prepare(notification, owner, ringingState)
197+
}
198+
},
191199
)
192200
}
193201
}
@@ -469,7 +477,7 @@ constructor(
469477
else -> notificationChannels.incomingCallChannel
470478
}
471479

472-
return ensureChannelAndBuildNotification(notificationChannel) {
480+
return ensureIncomingCallChannelAndBuildNotification(notificationChannel) {
473481
priority = if (hideRingingNotificationInForeground) {
474482
NotificationCompat.PRIORITY_LOW
475483
} else {
@@ -1234,6 +1242,31 @@ constructor(
12341242
return NotificationCompat.Builder(application, channelInfo.id).let(builder).build()
12351243
}
12361244

1245+
private inline fun ensureIncomingCallChannelAndBuildNotification(
1246+
channelInfo: StreamNotificationChannelInfo,
1247+
builder: NotificationCompat.Builder.() -> NotificationCompat.Builder,
1248+
): Notification {
1249+
val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient
1250+
if (streamVideo?.debugUseNotificationRingtoneForIncomingCalls == true) {
1251+
val audioAttributes = AudioAttributes.Builder()
1252+
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
1253+
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
1254+
.build()
1255+
val vibrationPattern = streamVideo.vibrationConfig
1256+
.takeIf { it.enabled }
1257+
?.vibratePattern
1258+
channelInfo.createRingingChannel(
1259+
manager = notificationManager,
1260+
soundUri = streamVideo.sounds.ringingConfig.incomingCallSoundUri,
1261+
audioAttributes = audioAttributes,
1262+
vibrationPattern = vibrationPattern,
1263+
)
1264+
} else {
1265+
channelInfo.create(notificationManager)
1266+
}
1267+
return NotificationCompat.Builder(application, channelInfo.id).let(builder).build()
1268+
}
1269+
12371270
@OptIn(ExperimentalStreamVideoApi::class)
12381271
internal fun mediaSession(callId: StreamCallId) = mediaSessionController.provideMediaSession(
12391272
application,
@@ -1255,3 +1288,14 @@ constructor(
12551288
return call?.isVideoEnabled() == true
12561289
}
12571290
}
1291+
1292+
internal fun incomingCallNotificationFlags(
1293+
currentFlags: Int,
1294+
ringingState: RingingState.Incoming,
1295+
): Int = if (ringingState.acceptedByMe) {
1296+
val withoutInsistent = currentFlags and Notification.FLAG_INSISTENT.inv()
1297+
withoutInsistent or Notification.FLAG_ONLY_ALERT_ONCE
1298+
} else {
1299+
val withoutOnlyAlertOnce = currentFlags and Notification.FLAG_ONLY_ALERT_ONCE.inv()
1300+
withoutOnlyAlertOnce or Notification.FLAG_INSISTENT
1301+
}

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/IncomingCallRequest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package io.getstream.video.android.core.notifications.internal.service
1818

1919
import android.app.Notification
20+
import io.getstream.video.android.core.IncomingRingtoneOwner
2021
import io.getstream.video.android.model.StreamCallId
2122

2223
internal data class IncomingCallRequest(
@@ -25,5 +26,5 @@ internal data class IncomingCallRequest(
2526
val callServiceConfiguration: CallServiceConfig,
2627
val isVideo: Boolean,
2728
val payload: Map<String, Any?>,
28-
val notification: Notification?,
29+
val notificationProvider: (IncomingRingtoneOwner) -> Notification?,
2930
)

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceLauncher.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import androidx.core.content.ContextCompat
4040
import androidx.core.net.toUri
4141
import io.getstream.log.taggedLogger
4242
import io.getstream.video.android.core.Call
43+
import io.getstream.video.android.core.IncomingRingtoneOwner
4344
import io.getstream.video.android.core.StreamVideoClient
4445
import io.getstream.video.android.core.notifications.internal.Throttler
4546
import io.getstream.video.android.core.notifications.internal.VideoPushDelegate.Companion.DEFAULT_CALL_TEXT
@@ -84,7 +85,7 @@ internal class ServiceLauncher(
8485
callServiceConfiguration: CallServiceConfig,
8586
isVideo: Boolean,
8687
payload: Map<String, Any?>,
87-
notification: Notification?,
88+
notificationProvider: (IncomingRingtoneOwner) -> Notification?,
8889
) {
8990
incomingCallCoordinator.showIncomingCall(
9091
IncomingCallRequest(
@@ -93,7 +94,7 @@ internal class ServiceLauncher(
9394
callServiceConfiguration = callServiceConfiguration,
9495
isVideo = isVideo,
9596
payload = payload,
96-
notification = notification,
97+
notificationProvider = notificationProvider,
9798
),
9899
)
99100
}

0 commit comments

Comments
 (0)