Skip to content

Commit 98416cc

Browse files
committed
feat(core): support notification-owned incoming ringing
1 parent 9f185c2 commit 98416cc

16 files changed

Lines changed: 289 additions & 26 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
@@ -113,7 +113,8 @@ public open class DefaultNotificationHandler(
113113
payload: Map<String, Any?>,
114114
) {
115115
logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" }
116-
val streamVideo = StreamVideo.instance()
116+
val streamVideo = StreamVideo.instance() as StreamVideoClient
117+
val notificationPreparer = IncomingCallNotificationPreparer(streamVideo)
117118
serviceLauncher.showIncomingCall(
118119
application,
119120
callId,
@@ -122,13 +123,18 @@ public open class DefaultNotificationHandler(
122123
isVideo = isVideoCall(callId, payload),
123124
payload = payload,
124125
streamVideo,
125-
notification = getRingingCallNotification(
126-
RingingState.Incoming(),
127-
callId,
128-
callDisplayName,
129-
shouldHaveContentIntent = true,
130-
payload,
131-
),
126+
notificationProvider = { owner ->
127+
val ringingState = RingingState.Incoming()
128+
getRingingCallNotification(
129+
ringingState,
130+
callId,
131+
callDisplayName,
132+
shouldHaveContentIntent = true,
133+
payload,
134+
)?.let { notification ->
135+
notificationPreparer.prepare(notification, owner, ringingState)
136+
}
137+
},
132138
)
133139
}
134140

@@ -410,6 +416,21 @@ public open class DefaultNotificationHandler(
410416
}
411417
this.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
412418
this.setShowBadge(true)
419+
val streamVideo = StreamVideo.instanceOrNull() as? StreamVideoClient
420+
if (streamVideo?.debugUseNotificationRingtoneForIncomingCalls == true) {
421+
val audioAttributes = android.media.AudioAttributes.Builder()
422+
.setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
423+
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION)
424+
.build()
425+
setSound(
426+
streamVideo.sounds.ringingConfig.incomingCallSoundUri,
427+
audioAttributes,
428+
)
429+
streamVideo.vibrationConfig
430+
.takeIf { it.enabled }
431+
?.vibratePattern
432+
?.let { vibrationPattern = it }
433+
}
413434
}
414435
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
415436
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
@@ -168,7 +170,8 @@ constructor(
168170
payload: Map<String, Any?>,
169171
) {
170172
logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" }
171-
val streamVideo = StreamVideo.instance()
173+
val streamVideo = StreamVideo.instance() as StreamVideoClient
174+
val notificationPreparer = IncomingCallNotificationPreparer(streamVideo)
172175
if (shouldShowIncomingCallNotification(
173176
(streamVideo as StreamVideoClient).callBusyHandler,
174177
callId.cid,
@@ -185,13 +188,18 @@ constructor(
185188
isVideo = isVideoCall(callId, payload),
186189
payload = payload,
187190
streamVideo,
188-
notification = getRingingCallNotification(
189-
RingingState.Incoming(),
190-
callId,
191-
callDisplayName,
192-
shouldHaveContentIntent = true,
193-
payload,
194-
),
191+
notificationProvider = { owner ->
192+
val ringingState = RingingState.Incoming()
193+
getRingingCallNotification(
194+
ringingState,
195+
callId,
196+
callDisplayName,
197+
shouldHaveContentIntent = true,
198+
payload,
199+
)?.let { notification ->
200+
notificationPreparer.prepare(notification, owner, ringingState)
201+
}
202+
},
195203
)
196204
}
197205
}
@@ -473,7 +481,7 @@ constructor(
473481
else -> notificationChannels.incomingCallChannel
474482
}
475483

476-
return ensureChannelAndBuildNotification(notificationChannel) {
484+
return ensureIncomingCallChannelAndBuildNotification(notificationChannel) {
477485
priority = if (hideRingingNotificationInForeground) {
478486
NotificationCompat.PRIORITY_LOW
479487
} else {
@@ -1238,6 +1246,31 @@ constructor(
12381246
return NotificationCompat.Builder(application, channelInfo.id).let(builder).build()
12391247
}
12401248

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

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.core.StreamVideo
2122
import io.getstream.video.android.model.StreamCallId
2223

@@ -27,5 +28,5 @@ internal data class IncomingCallRequest(
2728
val isVideo: Boolean,
2829
val payload: Map<String, Any?>,
2930
val streamVideo: StreamVideo,
30-
val notification: Notification?,
31+
val notificationProvider: (IncomingRingtoneOwner) -> Notification?,
3132
)

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.StreamVideo
4445
import io.getstream.video.android.core.StreamVideoClient
4546
import io.getstream.video.android.core.notifications.internal.Throttler
@@ -85,7 +86,7 @@ internal class ServiceLauncher(val context: Context) {
8586
isVideo: Boolean,
8687
payload: Map<String, Any?>,
8788
streamVideo: StreamVideo,
88-
notification: Notification?,
89+
notificationProvider: (IncomingRingtoneOwner) -> Notification?,
8990
) {
9091
incomingCallCoordinator.showIncomingCall(
9192
IncomingCallRequest(
@@ -95,7 +96,7 @@ internal class ServiceLauncher(val context: Context) {
9596
isVideo = isVideo,
9697
payload = payload,
9798
streamVideo = streamVideo,
98-
notification = notification,
99+
notificationProvider = notificationProvider,
99100
),
100101
)
101102
}

0 commit comments

Comments
 (0)