Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react-native-callingx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ await CallingxModule.displayIncomingCall(
- `setOnHoldCall(callId, isOnHold)`.
- `addEventListener(eventName, callback)`.
- `getInitialEvents()` and `getInitialVoipEvents()`.
- `registerBackgroundTask(taskProvider)` / `startBackgroundTask()` / `stopBackgroundTask()` (Android).
- `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.

## Event names

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />

<application>
<activity
Expand All @@ -23,7 +25,7 @@
android:name="io.getstream.rn.callingx.CallService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="phoneCall"
android:foregroundServiceType="phoneCall|microphone|camera"
android:stopWithTask="true" />

<service
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package io.getstream.rn.callingx

import android.Manifest
import android.app.Notification
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Binder
Expand All @@ -24,6 +26,7 @@ import io.getstream.rn.callingx.notifications.NotificationsConfig
import io.getstream.rn.callingx.repo.CallRepository
import io.getstream.rn.callingx.repo.CallRepositoryFactory
import io.getstream.rn.callingx.utils.AudioEndpointUtils
import io.getstream.rn.callingx.utils.LifecycleListener
import io.getstream.rn.callingx.utils.SettingsStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -108,7 +111,7 @@ class CallService : Service(), CallRepository.Listener {
)
return
}

val createdById = data["created_by_id"]
val createdName = data["created_by_display_name"].orEmpty()
val displayName = data["call_display_name"].orEmpty()
Expand Down Expand Up @@ -143,8 +146,11 @@ class CallService : Service(), CallRepository.Listener {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob())
private val actionProcessingLock = Object()

@Volatile
private var isInForeground = false

private val onAppForeground = Runnable { repromoteForegroundTypeIfNeeded() }

private val optimisticNotificationReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Expand Down Expand Up @@ -227,12 +233,16 @@ class CallService : Service(), CallRepository.Listener {
@Suppress("UnspecifiedRegisterReceiverFlag")
registerReceiver(optimisticNotificationReceiver, filter)
}

LifecycleListener.addOnForegroundListener(onAppForeground)
}

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

LifecycleListener.removeOnForegroundListener(onAppForeground)

unregisterReceiver(optimisticNotificationReceiver)

notificationManager.cancelAllNotifications()
Expand Down Expand Up @@ -571,11 +581,7 @@ class CallService : Service(), CallRepository.Listener {
private fun startForegroundSafely(notificationId: Int, notification: Notification) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
notificationId,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
)
startForeground(notificationId, notification, computeForegroundServiceType())
} else {
startForeground(notificationId, notification)
}
Expand All @@ -589,6 +595,76 @@ class CallService : Service(), CallRepository.Listener {
}
}

/**
* Always includes `phoneCall`. Adds the while-in-use types `microphone`/`camera` only when:
* - the platform is Android 11+ (R) β€” these FGS types were added in API 30; passing them to
* startForeground() on older versions is unsupported, so we keep `phoneCall`-only there, AND
* - the corresponding runtime permission is granted, AND
* - the app currently holds while-in-use access (foreground).
*/
private fun computeForegroundServiceType(): Int {
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL

// microphone/camera FGS types require API 30 (R) β€” keep phoneCall-only below it.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
return type
}
Comment thread
santhoshvai marked this conversation as resolved.
if (!LifecycleListener.isInForeground) {
return type
}
Comment thread
santhoshvai marked this conversation as resolved.

val hasMicPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (hasMicPermission) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}

val hasCameraPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
if (hasCameraPermission) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
Comment thread
greenfrvr marked this conversation as resolved.

return type
}

/**
* Re-issues [startForeground] for the current foreground call with the full
* [computeForegroundServiceType] bitmask. Called when the app enters the foreground (via
* [LifecycleListener]) so the microphone/camera types β€” which cannot be acquired from the
* background β€” get activated during the foreground window and then persist when backgrounded.
*
* This does NOT recreate the service: calling startForeground again on a running FGS only
* updates its active type and notification in place.
*/
private fun repromoteForegroundTypeIfNeeded() {
if (!isInForeground) return // service is not foreground yet β€” nothing to upgrade
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return

val type = computeForegroundServiceType()
if (type == ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL) {
// Nothing extra to promote (no while-in-use permissions, or not foreground).
return
}

val foregroundCallId = notificationManager.getForegroundCallId() ?: return
val call = callRepository.getCall(foregroundCallId) ?: return
val notificationId = notificationManager.getOrCreateNotificationId(foregroundCallId)
val notification = notificationManager.createNotification(foregroundCallId, call)

try {
startForeground(notificationId, notification, type)
} catch (e: Exception) {
Log.e(
TAG,
"[service] repromoteForegroundType: failed to upgrade FGS type: ${e.message}",
e
)
}
}

/**
* Cancels the notification for [callId]. If that notification was the foreground one
* and other calls remain, re-promotes the service with the next call's notification.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
package io.getstream.rn.callingx.utils

import android.util.Log
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.common.LifecycleState
import java.util.concurrent.CopyOnWriteArrayList

object LifecycleListener : LifecycleEventListener {

private const val TAG = "[Callingx] LifecycleListener"

@Volatile
var isInForeground: Boolean = false
private set

private var registered: Boolean = false
private var currentContext: ReactApplicationContext? = null

private val foregroundListeners = CopyOnWriteArrayList<Runnable>()

fun addOnForegroundListener(listener: Runnable) {
foregroundListeners.addIfAbsent(listener)
}

fun removeOnForegroundListener(listener: Runnable) {
foregroundListeners.remove(listener)
}

fun register(context: ReactApplicationContext) {
if (registered) return
registered = true
Expand All @@ -30,6 +44,14 @@ object LifecycleListener : LifecycleEventListener {

override fun onHostResume() {
isInForeground = true
// Notify after isInForeground is set so listeners observe the foreground state.
foregroundListeners.forEach { listener ->
try {
listener.run()
} catch (e: Throwable) {
Log.w(TAG, "foreground listener threw: ${e.message}")
}
}
}

override fun onHostPause() {
Expand Down
Loading
Loading