diff --git a/packages/stream_video_push_notification/CHANGELOG.md b/packages/stream_video_push_notification/CHANGELOG.md index c18ca3eb4..d7d606c9f 100644 --- a/packages/stream_video_push_notification/CHANGELOG.md +++ b/packages/stream_video_push_notification/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +### 🐞 Fixed + +- [Android] Fixed the incoming call ringtone being silently muted on Android 17. The ringtone is now played from a `phoneCall` foreground service, which Android 17's background audio hardening requires for audio played while no activity is visible. + ## 1.4.3 ### 🐞 Fixed diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConstants.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConstants.kt index 1e8af180c..3330cfaac 100644 --- a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConstants.kt +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallConstants.kt @@ -26,6 +26,13 @@ object IncomingCallConstants { const val ACTION_CALL_UNHELD = "io.getstream.video.ACTION_CALL_UNHELD" const val ACTION_CALL_CONNECTED = "io.getstream.video.ACTION_CALL_CONNECTED" + /** Internal action that runs [IncomingCallNotificationService] as a foreground service while ringing. */ + const val ACTION_CALL_RINGING = "io.getstream.video.ACTION_CALL_RINGING" + + // Polyfill for a Build.VERSION_CODES value not yet available at the current compileSdk. + // TODO: delete once compileSdk covers it, replacing usages with the real constant. + const val SDK_INT_CINNAMON_BUN = 37 // Build.VERSION_CODES.CINNAMON_BUN - needs compileSdk 37 + const val EXTRA_CALL_INCOMING_DATA = "EXTRA_CALL_INCOMING_DATA" diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationManager.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationManager.kt index c1e63d1d2..d3380ea86 100644 --- a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationManager.kt +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationManager.kt @@ -47,6 +47,14 @@ class IncomingCallNotificationManager( private var dataNotificationPermission: Map = HashMap() + /** + * The incoming call notification currently on screen, so that + * [IncomingCallNotificationService] can adopt it as its foreground service notification + * instead of building and posting a second one. + */ + internal var currentIncomingNotification: IncomingCallNotification? = null + private set + private var notificationBuilder: NotificationCompat.Builder? = null private var notificationViews: RemoteViews? = null private var notificationSmallViews: RemoteViews? = null @@ -445,6 +453,10 @@ class IncomingCallNotificationManager( fun clearIncomingNotification(data: Bundle, isAccepted: Boolean) { incomingCallSoundPlayerManager?.stop() + currentIncomingNotification = null + // Covers accept, decline, ended and timeout, so the ringing foreground service never + // outlives the notification it was started for. + IncomingCallNotificationService.stopService(context) context.sendBroadcast(IncomingCallActivity.getIntentEnded(context, isAccepted)) @@ -609,14 +621,26 @@ class IncomingCallNotificationManager( @SuppressLint("MissingPermission") fun showIncomingNotification(data: Bundle) { val incomingCallNotification = getIncomingNotification(data) - if (incomingChannelEnabled()) { - incomingCallSoundPlayerManager?.play(data) - } + currentIncomingNotification = incomingCallNotification incomingCallNotification?.let { getNotificationManager().notify( it.id, incomingCallNotification.notification ) } + if (incomingChannelEnabled()) { + // The notification has to be posted first: the ringing service adopts it as its + // foreground service notification and only then starts the ringtone. + val ringingFromService = incomingCallNotification != null && + IncomingCallNotificationService.startRinging(context, data) + if (!ringingFromService) { + incomingCallSoundPlayerManager?.play(data) + } + } + } + + /** Rings from [IncomingCallNotificationService], once it is in the foreground. */ + internal fun playIncomingCallSound(data: Bundle) { + incomingCallSoundPlayerManager?.play(data) } fun requestNotificationPermission(activity: Activity?, map: Map) { diff --git a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationService.kt b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationService.kt index a996c0caa..f3e11da48 100644 --- a/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationService.kt +++ b/packages/stream_video_push_notification/android/src/main/kotlin/io/getstream/video/flutter/stream_video_push_notification/IncomingCallNotificationService.kt @@ -1,6 +1,5 @@ package io.getstream.video.flutter.stream_video_push_notification -import android.annotation.SuppressLint import android.app.Service import android.content.Context import android.content.Intent @@ -8,12 +7,16 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.Bundle import android.os.IBinder +import android.util.Log +import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat class IncomingCallNotificationService : Service() { companion object { + private const val TAG = "IncomingCallService" + private val ActionForeground = listOf( IncomingCallConstants.ACTION_CALL_START, IncomingCallConstants.ACTION_CALL_ACCEPT @@ -33,6 +36,36 @@ class IncomingCallNotificationService : Service() { } } + /** + * Runs this service in the foreground for the incoming call ringing window, and rings from + * there once the service is actually in the foreground. + * + * Android 17 mutes background audio unless the app has a visible activity or is running a + * foreground service that is not of type `SHORT_SERVICE`, so the ringtone of a push + * delivered incoming call is silently dropped without one. Below Android 17 nothing + * changes: this returns false and the caller keeps ringing the way it always has. + * + * @return true when the caller must not play the ringtone itself because this service took + * it over, false when it still has to. + */ + fun startRinging(context: Context, data: Bundle): Boolean { + if (Build.VERSION.SDK_INT < IncomingCallConstants.SDK_INT_CINNAMON_BUN) return false + + val intent = Intent(context, IncomingCallNotificationService::class.java).apply { + action = IncomingCallConstants.ACTION_CALL_RINGING + putExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA, data) + } + return try { + ContextCompat.startForegroundService(context, intent) + true + } catch (error: Exception) { + // Background start can be refused, e.g. when the push was downgraded from high + // priority. Ringing without a foreground service is better than not ringing. + Log.e(TAG, "Could not start the ringing foreground service", error) + false + } + } + fun stopService(context: Context) { val intent = Intent(context, IncomingCallNotificationService::class.java) context.stopService(intent) @@ -49,6 +82,19 @@ class IncomingCallNotificationService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == IncomingCallConstants.ACTION_CALL_RINGING) { + val data = intent.getBundleExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) + if (data == null) { + stopSelf() + return START_NOT_STICKY + } + // Ring either way: when the foreground service could not be started the playback may + // still be muted by the system, but that is no worse than not attempting it at all. + val inForeground = startRingingForeground() + incomingCallNotificationManager?.playIncomingCallSound(data) + if (!inForeground) stopSelf() + return START_NOT_STICKY + } if (intent?.action == IncomingCallConstants.ACTION_CALL_START) { intent.getBundleExtra(IncomingCallConstants.EXTRA_CALL_INCOMING_DATA) ?.let { @@ -65,6 +111,38 @@ class IncomingCallNotificationService : Service() { return START_NOT_STICKY } + /** + * Adopts the already posted incoming call notification as the foreground service notification. + * + * `phoneCall` is used because Android 17 only requires the type not to be `SHORT_SERVICE`, and + * its prerequisites (`FOREGROUND_SERVICE_PHONE_CALL` and `MANAGE_OWN_CALLS`) are declared by + * this package. + * + * Note this does not give the service while-in-use capabilities, which apps targeting API 37 + * additionally need: those are granted by how a service is started, not by its type, and a + * service started from an incoming push is started neither while visible nor from a user + * interaction. Covering that case needs Telecom system delegation (`CallsManager.addCall`). + */ + private fun startRingingForeground(): Boolean { + val notification = incomingCallNotificationManager?.currentIncomingNotification + if (notification == null) { + Log.w(TAG, "No incoming call notification to run the ringing service with") + return false + } + return try { + ServiceCompat.startForeground( + this, + notification.id, + notification.notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL, + ) + true + } catch (error: Exception) { + Log.e(TAG, "Could not move the ringing service to the foreground", error) + false + } + } + override fun onDestroy() { super.onDestroy() incomingCallNotificationManager?.destroy() @@ -88,4 +166,3 @@ class IncomingCallNotificationService : Service() { } -