Skip to content

Commit a96df4f

Browse files
authored
fix: callingx background handling (#2310)
### 💡 Overview Fixes two Android bugs where callingx calls break when the app is backgrounded (locked screen cases) — e.g. answering a ringing call on the lock screen and then tabbing out: 1. Audio and video drop a few seconds after backgrounding with locked screen. 2. Call drops 2 minutes after backgrounding with locked screen. Both only affect the **callingx-managed** path (ringing calls, or non-ringing calls with `enableOngoingCalls`). ### 📝 Implementation notes Two independent root causes, two fixes: **1. Background mic/camera behavior** `CallService` ran as a `phoneCall`-only foreground service, which on Android 14+ doesn't grant microphone/camera access in the background — so the mic went silent and the camera was cut shortly after backgrounding. Fix: declare and use the `microphone`/`camera` FGS types. Since those types can't be activated from the background (the service starts from a push), `CallService` starts as `phoneCall` and is **re-promoted** to include mic/camera during the foreground answer window; the types then persist into the background. **2. JS timers suspended in background** Telecom + FGS with `phoneCall` keep the process alive, but without a background keep-alive task, React Native suspended JS timers while backgrounded. The client's healthcheck pings stopped, and the server dropped the participant. Fix: run a headless JS task on callingx's existing `CallService` (no extra notification) to keep RN/timers alive. It's shared by the ringing-push handler and the keep-call-alive hook, and stops once the call ends. `useAndroidKeepCallAliveEffect` and the push flow now use this instead of bailing out, with guards so only one keep-alive mechanism runs per call. 🎫 Ticket: https://linear.app/stream/issue/RN-404/callingx-background-with-locked-screen-issues 📑 Docs: GetStream/docs-content#1396 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added Android ref-counted background keep-alive via `acquireBackgroundTask(owner)` / `releaseBackgroundTask(owner)`. * Foreground service now declares microphone/camera types when available, with automatic upgrading when the app returns to the foreground. * **Bug Fixes** * Improved foreground/background transition handling to prevent unintended call shutdowns. * Enhanced cleanup for ended calls, including ringing calls triggered from push events. * **Documentation** * Updated public API docs to reflect the new background keep-alive workflow. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 23d9f49 commit a96df4f

8 files changed

Lines changed: 457 additions & 252 deletions

File tree

packages/react-native-callingx/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ await CallingxModule.displayIncomingCall(
5555
- `setOnHoldCall(callId, isOnHold)`.
5656
- `addEventListener(eventName, callback)`.
5757
- `getInitialEvents()` and `getInitialVoipEvents()`.
58-
- `registerBackgroundTask(taskProvider)` / `startBackgroundTask()` / `stopBackgroundTask()` (Android).
58+
- `acquireBackgroundTask(owner)` / `releaseBackgroundTask(owner)` (Android) — ref-counted keep-alive task that keeps the JS runtime/timers alive in the background; the underlying HeadlessJS task starts on the first acquire and stops once all owners release.
5959

6060
## Event names
6161

packages/react-native-callingx/android/src/main/AndroidManifest.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2-
xmlns:tools="http://schemas.android.com/tools">
2+
xmlns:tools="http://schemas.android.com/tools">
33

44
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
55
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
66
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
77
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
8+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
9+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
810

911
<application>
1012
<activity
@@ -23,7 +25,7 @@
2325
android:name="io.getstream.rn.callingx.CallService"
2426
android:enabled="true"
2527
android:exported="false"
26-
android:foregroundServiceType="phoneCall"
28+
android:foregroundServiceType="phoneCall|microphone|camera"
2729
android:stopWithTask="true" />
2830

2931
<service

packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package io.getstream.rn.callingx
22

3+
import android.Manifest
34
import android.app.Notification
45
import android.app.Service
56
import android.content.BroadcastReceiver
67
import android.content.Context
78
import android.content.Intent
89
import android.content.IntentFilter
10+
import android.content.pm.PackageManager
911
import android.content.pm.ServiceInfo
1012
import android.net.Uri
1113
import android.os.Binder
@@ -24,6 +26,7 @@ import io.getstream.rn.callingx.notifications.NotificationsConfig
2426
import io.getstream.rn.callingx.repo.CallRepository
2527
import io.getstream.rn.callingx.repo.CallRepositoryFactory
2628
import io.getstream.rn.callingx.utils.AudioEndpointUtils
29+
import io.getstream.rn.callingx.utils.LifecycleListener
2730
import io.getstream.rn.callingx.utils.SettingsStore
2831
import kotlinx.coroutines.CancellationException
2932
import kotlinx.coroutines.CoroutineScope
@@ -108,7 +111,7 @@ class CallService : Service(), CallRepository.Listener {
108111
)
109112
return
110113
}
111-
114+
112115
val createdById = data["created_by_id"]
113116
val createdName = data["created_by_display_name"].orEmpty()
114117
val displayName = data["call_display_name"].orEmpty()
@@ -143,8 +146,11 @@ class CallService : Service(), CallRepository.Listener {
143146
private val scope: CoroutineScope = CoroutineScope(SupervisorJob())
144147
private val actionProcessingLock = Object()
145148

149+
@Volatile
146150
private var isInForeground = false
147151

152+
private val onAppForeground = Runnable { repromoteForegroundTypeIfNeeded() }
153+
148154
private val optimisticNotificationReceiver =
149155
object : BroadcastReceiver() {
150156
override fun onReceive(context: Context, intent: Intent) {
@@ -227,12 +233,16 @@ class CallService : Service(), CallRepository.Listener {
227233
@Suppress("UnspecifiedRegisterReceiverFlag")
228234
registerReceiver(optimisticNotificationReceiver, filter)
229235
}
236+
237+
LifecycleListener.addOnForegroundListener(onAppForeground)
230238
}
231239

232240
override fun onDestroy() {
233241
super.onDestroy()
234242
debugLog(TAG, "[service] onDestroy: TelecomCallService destroyed")
235243

244+
LifecycleListener.removeOnForegroundListener(onAppForeground)
245+
236246
unregisterReceiver(optimisticNotificationReceiver)
237247

238248
notificationManager.cancelAllNotifications()
@@ -571,11 +581,7 @@ class CallService : Service(), CallRepository.Listener {
571581
private fun startForegroundSafely(notificationId: Int, notification: Notification) {
572582
try {
573583
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
574-
startForeground(
575-
notificationId,
576-
notification,
577-
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
578-
)
584+
startForeground(notificationId, notification, computeForegroundServiceType())
579585
} else {
580586
startForeground(notificationId, notification)
581587
}
@@ -589,6 +595,76 @@ class CallService : Service(), CallRepository.Listener {
589595
}
590596
}
591597

598+
/**
599+
* Always includes `phoneCall`. Adds the while-in-use types `microphone`/`camera` only when:
600+
* - the platform is Android 11+ (R) — these FGS types were added in API 30; passing them to
601+
* startForeground() on older versions is unsupported, so we keep `phoneCall`-only there, AND
602+
* - the corresponding runtime permission is granted, AND
603+
* - the app currently holds while-in-use access (foreground).
604+
*/
605+
private fun computeForegroundServiceType(): Int {
606+
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
607+
608+
// microphone/camera FGS types require API 30 (R) — keep phoneCall-only below it.
609+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
610+
return type
611+
}
612+
if (!LifecycleListener.isInForeground) {
613+
return type
614+
}
615+
616+
val hasMicPermission =
617+
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
618+
PackageManager.PERMISSION_GRANTED
619+
if (hasMicPermission) {
620+
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
621+
}
622+
623+
val hasCameraPermission =
624+
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
625+
PackageManager.PERMISSION_GRANTED
626+
if (hasCameraPermission) {
627+
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
628+
}
629+
630+
return type
631+
}
632+
633+
/**
634+
* Re-issues [startForeground] for the current foreground call with the full
635+
* [computeForegroundServiceType] bitmask. Called when the app enters the foreground (via
636+
* [LifecycleListener]) so the microphone/camera types — which cannot be acquired from the
637+
* background — get activated during the foreground window and then persist when backgrounded.
638+
*
639+
* This does NOT recreate the service: calling startForeground again on a running FGS only
640+
* updates its active type and notification in place.
641+
*/
642+
private fun repromoteForegroundTypeIfNeeded() {
643+
if (!isInForeground) return // service is not foreground yet — nothing to upgrade
644+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
645+
646+
val type = computeForegroundServiceType()
647+
if (type == ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL) {
648+
// Nothing extra to promote (no while-in-use permissions, or not foreground).
649+
return
650+
}
651+
652+
val foregroundCallId = notificationManager.getForegroundCallId() ?: return
653+
val call = callRepository.getCall(foregroundCallId) ?: return
654+
val notificationId = notificationManager.getOrCreateNotificationId(foregroundCallId)
655+
val notification = notificationManager.createNotification(foregroundCallId, call)
656+
657+
try {
658+
startForeground(notificationId, notification, type)
659+
} catch (e: Exception) {
660+
Log.e(
661+
TAG,
662+
"[service] repromoteForegroundType: failed to upgrade FGS type: ${e.message}",
663+
e
664+
)
665+
}
666+
}
667+
592668
/**
593669
* Cancels the notification for [callId]. If that notification was the foreground one
594670
* and other calls remain, re-promotes the service with the next call's notification.

packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/utils/LifecycleListener.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
11
package io.getstream.rn.callingx.utils
22

3+
import android.util.Log
34
import com.facebook.react.bridge.LifecycleEventListener
45
import com.facebook.react.bridge.ReactApplicationContext
56
import com.facebook.react.common.LifecycleState
7+
import java.util.concurrent.CopyOnWriteArrayList
68

79
object LifecycleListener : LifecycleEventListener {
810

11+
private const val TAG = "[Callingx] LifecycleListener"
12+
913
@Volatile
1014
var isInForeground: Boolean = false
1115
private set
1216

1317
private var registered: Boolean = false
1418
private var currentContext: ReactApplicationContext? = null
1519

20+
private val foregroundListeners = CopyOnWriteArrayList<Runnable>()
21+
22+
fun addOnForegroundListener(listener: Runnable) {
23+
foregroundListeners.addIfAbsent(listener)
24+
}
25+
26+
fun removeOnForegroundListener(listener: Runnable) {
27+
foregroundListeners.remove(listener)
28+
}
29+
1630
fun register(context: ReactApplicationContext) {
1731
if (registered) return
1832
registered = true
@@ -30,6 +44,14 @@ object LifecycleListener : LifecycleEventListener {
3044

3145
override fun onHostResume() {
3246
isInForeground = true
47+
// Notify after isInForeground is set so listeners observe the foreground state.
48+
foregroundListeners.forEach { listener ->
49+
try {
50+
listener.run()
51+
} catch (e: Throwable) {
52+
Log.w(TAG, "foreground listener threw: ${e.message}")
53+
}
54+
}
3355
}
3456

3557
override fun onHostPause() {

0 commit comments

Comments
 (0)