diff --git a/packages/react-native-callingx/README.md b/packages/react-native-callingx/README.md index c4aba0c91b..12f48fc3dc 100644 --- a/packages/react-native-callingx/README.md +++ b/packages/react-native-callingx/README.md @@ -56,6 +56,7 @@ await CallingxModule.displayIncomingCall( - `addEventListener(eventName, callback)`. - `getInitialEvents()` and `getInitialVoipEvents()`. - `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. +- `stopService()` (Android) — asks the call service to stop. A request, not a command: the service hosts every call, so it stays alive while any call is registered or is being registered. Use `endCallWithReason(callId, reason)` to tear down an individual call. ## Event names diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallRegistrationStore.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallRegistrationStore.kt index 7d5d6534d1..b3ec9c3d70 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallRegistrationStore.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallRegistrationStore.kt @@ -137,13 +137,26 @@ object CallRegistrationStore { synchronized(list) { return list.toList() } } - fun clearAll() { + /** Forgets a call entirely: it is gone, so neither its id nor its queued actions can apply. */ + fun discardCallState(callId: String) { + debugLog(TAG, "[store] discardCallState: Discarding all state for callId: $callId") + trackedCallIds.remove(callId) + pendingActionsByCallId.remove(callId) + } + + /** + * Drops the JS-coupled state only: the promises and their timeouts belong to a React context + * that is going away, so nothing can resolve them. + * + * [trackedCallIds] and [pendingActionsByCallId] are deliberately kept. They describe native + * calls, which outlive a JS teardown: a tracked id still makes `rejectCallWhenBusy` reject + * later pushes, and a queued action still has to be replayed once the call registers. + */ + fun clearPendingPromises() { synchronized(pendingPromises) { pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) } pendingTimeouts.clear() pendingPromises.clear() } - trackedCallIds.clear() - pendingActionsByCallId.clear() } } diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt index c3ad435c8c..4b19f80ac2 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt @@ -10,7 +10,6 @@ import android.content.IntentFilter import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.net.Uri -import android.os.Binder import android.os.Build import android.os.Bundle import android.os.IBinder @@ -28,6 +27,7 @@ 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 java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -76,6 +76,12 @@ class CallService : Service(), CallRepository.Listener { internal const val ACTION_PROCESS_ACTION = "execute_action" internal const val ACTION_REGISTRATION_FAILED = "registration_failed" + /** + * True while a [CallService] instance exists. Only meaningful in-process (the service has + * no `android:process`), and used to avoid creating the service just to stop it. + */ + @Volatile internal var isRunning: Boolean = false + fun startIncomingCallFromPush(context: Context, data: Map) { debugLog(TAG, "[service] startIncomingCallFromPush: Starting incoming call from push") @@ -130,25 +136,47 @@ class CallService : Service(), CallRepository.Listener { putExtra(EXTRA_IS_VIDEO, isVideo) } - ContextCompat.startForegroundService(context, intent) + try { + ContextCompat.startForegroundService(context, intent) + } catch (e: Exception) { + // The call was tracked above so a concurrent stop request could not race it. If + // the service never starts, drop the tracked id — a stale entry would block every + // subsequent stop and strand the foreground service. + Log.e( + TAG, + "[service] startIncomingCallFromPush: Failed to start service: ${e.message}", + e + ) + CallRegistrationStore.removeTrackedCall(callCid) + } } } - inner class CallServiceBinder : Binder() { - fun getService(): CallService = this@CallService - } - private lateinit var headlessJSManager: HeadlessTaskManager private lateinit var notificationManager: CallNotificationManager private lateinit var callRepository: CallRepository - private val binder = CallServiceBinder() private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) private val actionProcessingLock = Object() + /** + * Calls this instance has launched a registration for that have not reached the repository yet. + * Scoped to the instance on purpose: it vetoes stopping the service, so a stale entry must die + * with the instance rather than outlive it in process-global state. + */ + private val registeringCallIds: MutableSet = ConcurrentHashMap.newKeySet() + @Volatile private var isInForeground = false + /** + * Start id of the most recently *delivered* start command. ActivityManager bumps its own last + * start id when `startService` is called, before delivery, so passing this to [stopSelfResult] + * makes any stop lose against a start that is already queued. + */ + @Volatile + private var lastStartId = 0 + private val onAppForeground = Runnable { repromoteForegroundTypeIfNeeded() } private val optimisticNotificationReceiver = @@ -218,9 +246,12 @@ class CallService : Service(), CallRepository.Listener { debugLog(TAG, "[service] onCreate: TelecomCallService created") notificationManager = CallNotificationManager(applicationContext) - headlessJSManager = HeadlessTaskManager(applicationContext) callRepository = CallRepositoryFactory.create(applicationContext) callRepository.setListener(this) + // Constructed after callRepository: onTaskFinished reads it, and a task can only finish + // after onStartCommand has started one. + headlessJSManager = + HeadlessTaskManager(applicationContext) { stopServiceIfIdle(lastStartId) } val filter = IntentFilter().apply { @@ -235,26 +266,36 @@ class CallService : Service(), CallRepository.Listener { } LifecycleListener.addOnForegroundListener(onAppForeground) + + isRunning = true } override fun onDestroy() { super.onDestroy() debugLog(TAG, "[service] onDestroy: TelecomCallService destroyed") + isRunning = false + LifecycleListener.removeOnForegroundListener(onAppForeground) unregisterReceiver(optimisticNotificationReceiver) + demoteForeground() + notificationManager.cancelAllNotifications() notificationManager.stopRingtone() - callRepository.release() - headlessJSManager.release() - if (isInForeground) { - stopForeground(STOP_FOREGROUND_REMOVE) - isInForeground = false + // release() below disconnects every call without emitting Call.None — it cancels the + // observer — so nothing else would drop these process-global entries. A left-behind + // tracked id makes rejectCallWhenBusy skip later pushes, and a left-behind action would + // be replayed against a future call with the same id. + (callRepository.calls.value.keys + registeringCallIds).forEach { callId -> + CallRegistrationStore.discardCallState(callId) } + callRepository.release() + headlessJSManager.release() + scope.cancel() } @@ -266,8 +307,11 @@ class CallService : Service(), CallRepository.Listener { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { debugLog(TAG, "[service] onStartCommand: Received intent with action: ${intent?.action}") + lastStartId = startId + if (intent == null || intent.action == null) { Log.w(TAG, "[service] onStartCommand: Intent is null, returning START_NOT_STICKY") + stopServiceIfIdle(startId) return START_NOT_STICKY } @@ -281,43 +325,33 @@ class CallService : Service(), CallRepository.Listener { } ACTION_START_BACKGROUND_TASK -> { startBackgroundTask(intent) - return START_NOT_STICKY } ACTION_STOP_BACKGROUND_TASK -> { stopBackgroundTask() - return START_NOT_STICKY } ACTION_UPDATE_CALL -> { updateCall(intent) + // This action never registers anything, and plain startService may have created + // the service just to deliver it, so re-check whether it has a reason to run. + stopServiceIfIdle(startId) } ACTION_PROCESS_ACTION -> { processAction(intent) } ACTION_STOP_SERVICE -> { - if (isInForeground) { - stopForeground(STOP_FOREGROUND_REMOVE) - isInForeground = false - } - notificationManager.cancelAllNotifications() - notificationManager.stopRingtone() - stopSelf() + stopServiceIfIdle(startId) } else -> { Log.e(TAG, "[service] onStartCommand: Unknown action: ${intent.action}") - stopSelf() - return START_NOT_STICKY + stopServiceIfIdle(startId) } } - return START_STICKY + return START_NOT_STICKY } - override fun onBind(intent: Intent): IBinder? = binder - - override fun onUnbind(intent: Intent): Boolean { - debugLog(TAG, "[service] onUnbind: Service unbound") - return super.onUnbind(intent) - } + /** Started-only service: nothing binds to it. */ + override fun onBind(intent: Intent): IBinder? = null override fun onCallStateChanged(callId: String, call: Call) { debugLog( @@ -354,8 +388,11 @@ class CallService : Service(), CallRepository.Listener { "[service] onCallStateChanged[$callId]: Starting foreground for call" ) notificationManager.resetOptimisticState(callId) + // Recovery path: a registered call changed state while the service is not + // foreground (e.g. an earlier promote failed, or we demoted after a failed + // re-anchor). Promote here so the call keeps an FGS anchor. val notification = notificationManager.createNotification(callId, call) - startForegroundSafely(notificationId, notification) + startForegroundSafely(callId, notificationId, notification) } } is Call.None, is Call.Unregistered -> { @@ -364,18 +401,7 @@ class CallService : Service(), CallRepository.Listener { repromoteForegroundIfNeeded(callId) if (!callRepository.hasRingingCall()) notificationManager.stopRingtone() - // Stop service only when no calls remain - if (!callRepository.hasAnyCalls()) { - debugLog( - TAG, - "[service] onCallStateChanged[$callId]: No more calls, stopping service" - ) - if (isInForeground) { - stopForeground(STOP_FOREGROUND_REMOVE) - isInForeground = false - } - stopSelf() - } + stopServiceIfIdle(lastStartId) } } } @@ -414,6 +440,11 @@ class CallService : Service(), CallRepository.Listener { } override fun onCallRegistered(callId: String, incoming: Boolean) { + // The repository now reports this call, so hasAnyCalls() takes over the veto. This has to + // happen here rather than when registerCall returns: that only happens once the call has + // already been removed and Call.None has run the stop check. + registeringCallIds.remove(callId) + if (incoming) { sendBroadcastEvent(CallingxModuleImpl.CALL_REGISTERED_INCOMING_ACTION) { putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callId) @@ -494,6 +525,13 @@ class CallService : Service(), CallRepository.Listener { val callInfo = extractIntentParams(intent) + // Claimed before anything else: lastStartId is already this call's, so from here until the + // registration lands, this entry is the only thing stopping another call's teardown from + // taking the service down with a matching start id. + registeringCallIds.add(callInfo.callId) + + startForegroundForCall(callInfo, incoming) + // If this specific call is already registered, just notify val existingCall = callRepository.getCall(callInfo.callId) if (existingCall != null) { @@ -510,11 +548,11 @@ class CallService : Service(), CallRepository.Listener { putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callInfo.callId) } } + // hasAnyCalls() already covers an existing call, so hand the veto straight over. + registeringCallIds.remove(callInfo.callId) return } - startForegroundForCall(callInfo, incoming) - scope.launch { try { callRepository.registerCall( @@ -531,6 +569,10 @@ class CallService : Service(), CallRepository.Listener { TAG, "[service] registerCall: Registration canceled for ${callInfo.callId} during teardown" ) + // The call never made it into the repository, so nothing else will drop its tracked + // id — and a stale one wrongly marks the user as busy for later pushes. + CallRegistrationStore.removeTrackedCall(callInfo.callId) + registeringCallIds.remove(callInfo.callId) } catch (e: Exception) { Log.e(TAG, "[service] registerCall: Error registering call: ${e.message}") @@ -538,17 +580,19 @@ class CallService : Service(), CallRepository.Listener { putExtra(CallingxModuleImpl.EXTRA_CALL_ID, callInfo.callId) } + // CallingxModuleImpl also drops the tracked id when it receives the broadcast + // above, but only if a JS module instance is alive — which it need not be in a + // headless push flow. Removing it here too is idempotent. + CallRegistrationStore.removeTrackedCall(callInfo.callId) + registeringCallIds.remove(callInfo.callId) + repromoteForegroundIfNeeded(callInfo.callId) - // Only stop foreground/service when no other calls remain - if (!callRepository.hasAnyCalls()) { - if (isInForeground) { - stopForeground(STOP_FOREGROUND_REMOVE) - isInForeground = false - } - notificationManager.stopRingtone() - stopSelf() - } + stopServiceIfIdle(lastStartId) + } finally { + // Backstop for a registration that neither threw nor reached onCallRegistered. + // A no-op otherwise: the handover happens there, long before registerCall returns. + registeringCallIds.remove(callInfo.callId) } } } @@ -578,20 +622,76 @@ class CallService : Service(), CallRepository.Listener { } } - private fun startForegroundSafely(notificationId: Int, notification: Notification) { - try { + /** + * The only place this service stops itself. Callers are asking, not telling: the service hosts + * every call, so it may only go down once nothing needs it. + * + * Each check catches a new call at a different stage, so none is enough alone: + * - `hasAnyCalls` — registered in the repository. + * - `registeringCallIds` — this instance launched a registration that has not landed yet. + * Registration spends up to 1.5s resolving Telecom endpoints before the repository sees it. + * - `ownsTaskSlot` — JS still holds a keep-alive owner; stopping would end its task early. + * - [stopSelfResult] — a newer start is already queued. ActivityManager bumps its last start id + * when `startService` is called, before we see the intent. + * + * Teardown is [onDestroy]'s job, which always runs since nothing binds here. Demoting from the + * foreground here would also drop a still-valid anchor when the stop is refused. + */ + private fun stopServiceIfIdle(startId: Int) { + if (callRepository.hasAnyCalls() || + registeringCallIds.isNotEmpty() || + headlessJSManager.ownsTaskSlot() + ) { + debugLog( + TAG, + "[service] stopServiceIfIdle: Still in use (registering=$registeringCallIds, taskSlot=${headlessJSManager.ownsTaskSlot()}), keeping service alive" + ) + return + } + + if (!stopSelfResult(startId)) { + Log.w( + TAG, + "[service] stopServiceIfIdle: Stop refused (startId=$startId), a newer start is pending" + ) + } + } + + private fun demoteForeground() { + if (!isInForeground) return + debugLog(TAG, "[service] demoteForeground: leaving foreground") + stopForeground(STOP_FOREGROUND_REMOVE) + isInForeground = false + notificationManager.clearAnchor() + } + + /** + * Promotes the service using [callId]'s notification, and records the resulting FGS anchor. + * + * @return true when the platform accepted the promotion. On failure the recorded anchor is left + * untouched: a previously valid anchor must not be discarded because a new promote failed. + */ + private fun startForegroundSafely( + callId: String, + notificationId: Int, + notification: Notification, + ): Boolean { + return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground(notificationId, notification, computeForegroundServiceType()) } else { startForeground(notificationId, notification) } isInForeground = true + notificationManager.commitAnchor(callId, notification) + true } catch (e: Exception) { Log.e( TAG, "[service] startForegroundSafely: Failed to start foreground service: ${e.message}", e ) + false } } @@ -643,26 +743,28 @@ class CallService : Service(), CallRepository.Listener { 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) { + if (computeForegroundServiceType() == 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( + val notification = notificationManager.lastPostedNotification(foregroundCallId) + if (notification == null) { + Log.w( TAG, - "[service] repromoteForegroundType: failed to upgrade FGS type: ${e.message}", - e + "[service] repromoteForegroundType: nothing posted for anchor $foregroundCallId" ) + return } + + // Deliberately ignoring the result: a failed type upgrade leaves a valid FGS on the previous, + // narrower type. Unlike repromoteForegroundIfNeeded, this must NOT demote. + startForegroundSafely( + foregroundCallId, + notificationManager.getOrCreateNotificationId(foregroundCallId), + notification, + ) } /** @@ -670,20 +772,40 @@ class CallService : Service(), CallRepository.Listener { * and other calls remain, re-promotes the service with the next call's notification. */ private fun repromoteForegroundIfNeeded(callId: String) { - val newForegroundNotificationId = notificationManager.cancelNotification(callId) - if (newForegroundNotificationId != null && isInForeground) { - val newForegroundCallId = notificationManager.getForegroundCallId() - val call = if (newForegroundCallId != null) callRepository.getCall(newForegroundCallId) else null - if (call != null && newForegroundCallId != null) { - debugLog(TAG, "[service] repromoteForegroundIfNeeded: Re-promoting with call $newForegroundCallId (notificationId=$newForegroundNotificationId)") - val notification = notificationManager.createNotification(newForegroundCallId, call) - startForegroundSafely(newForegroundNotificationId, notification) - } + val wasAnchor = notificationManager.getForegroundCallId() == callId + if (!isInForeground || !wasAnchor) { + debugLog(TAG, "[service] repromoteForegroundIfNeeded: Another call still holds the anchor, not re-anchoring") + notificationManager.cancelNotification(callId) + return } + + val next = notificationManager.nextAnchorCandidate(excluding = callId) + val anchored = + if (next == null) { + debugLog(TAG, "[service] repromoteForegroundIfNeeded: No next anchor candidate, not re-anchoring") + false + } else { + debugLog( + TAG, + "[service] repromoteForegroundIfNeeded: Re-anchoring to ${next.callId} (notificationId=${next.notificationId})" + ) + startForegroundSafely(next.callId, next.notificationId, next.notification) + } + + if (!anchored) { + // Nothing to anchor to, or the promote failed. Never leave isInForeground claiming an + // anchor we do not have — that is what surfaces later as + // SecurityException: Invalid FGS notification. + debugLog(TAG, "[service] repromoteForegroundIfNeeded: No anchor available, demoting") + demoteForeground() + } + + notificationManager.cancelNotification(callId) } private fun startForegroundForCall(callInfo: CallInfo, incoming: Boolean) { - val tempCall = callRepository.getTempCall(callInfo, incoming) + val tempCall = callRepository.getCall(callInfo.callId) + ?: callRepository.getTempCall(callInfo, incoming) val notificationId = notificationManager.getOrCreateNotificationId(callInfo.callId) if (!isInForeground) { debugLog( @@ -691,9 +813,9 @@ class CallService : Service(), CallRepository.Listener { "[service] registerCall: Starting foreground for call: ${callInfo.callId}" ) val notification = notificationManager.createNotification(callInfo.callId, tempCall) - startForegroundSafely(notificationId, notification) - } else { - // Already in foreground from another call — just post the notification + startForegroundSafely(callInfo.callId, notificationId, notification) + } else if (!notificationManager.isNotificationPosted(callInfo.callId)) { + // Post only when this call has no notification yet (e.g. a second concurrent call). val notification = notificationManager.createNotification(callInfo.callId, tempCall) notificationManager.postNotification(callInfo.callId, notification) } diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt index bf59947538..8e7804f048 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt @@ -74,11 +74,26 @@ class CallingxModuleImpl( debugLog(TAG, "[module] invalidate: Invalidating module") LifecycleListener.unregister() - CallRegistrationStore.clearAll() + CallRegistrationStore.clearPendingPromises() AudioEndpointStore.clearAll() CallEventBus.unsubscribe(this) isModuleInitialized = false + + // The JS runtime is going away, so any keep-alive owner it held is gone with it. A React + // context teardown does not finish in-flight headless tasks, so the service would never + // otherwise be told to re-check whether it still has a reason to run. Native calls survive + // this — both registered ones and those still resolving Telecom endpoints — and veto the + // stop inside CallService, which is why the tracked ids above are left intact. + if (CallService.isRunning) { + try { + Intent(reactApplicationContext, CallService::class.java) + .apply { action = CallService.ACTION_STOP_SERVICE } + .also { reactApplicationContext.startService(it) } + } catch (e: Exception) { + Log.e(TAG, "[module] invalidate: Failed to sweep the call service: ${e.message}", e) + } + } } fun setShouldRejectCallWhenBusy(shouldReject: Boolean) { @@ -125,6 +140,15 @@ class CallingxModuleImpl( fun stopService(promise: Promise) { debugLog(TAG, "[module] stopService: Stopping CallService explicitly from JS") + + if (!CallService.isRunning) { + // Starting the service just to stop it would construct (and immediately release) a + // whole CallRepository, and `startService` from the background can throw on API 26+. + debugLog(TAG, "[module] stopService: Service is not running, nothing to stop") + promise.resolve(true) + return + } + try { Intent(reactApplicationContext, CallService::class.java) .apply { action = CallService.ACTION_STOP_SERVICE } @@ -250,20 +274,21 @@ class CallingxModuleImpl( return } - // for now only display options will be updated, rest of the parameters will be ignored try { - startCallService( - CallService.ACTION_UPDATE_CALL, - callId, - callerName, - phoneNumber, - true, - displayOptions, - ) + Intent(reactApplicationContext, CallService::class.java) + .apply { + this.action = CallService.ACTION_UPDATE_CALL + putExtra(CallService.EXTRA_CALL_ID, callId) + putExtra(CallService.EXTRA_NAME, callerName) + putExtra(CallService.EXTRA_URI, phoneNumber.toUri()) + putExtra(CallService.EXTRA_IS_VIDEO, true) + putExtra(CallService.EXTRA_DISPLAY_OPTIONS, Arguments.toBundle(displayOptions)) + } + .also { reactApplicationContext.startService(it) } promise.resolve(true) } catch (e: Exception) { - Log.e(TAG, "[module] updateDisplay: Failed to start foreground service: ${e.message}", e) - promise.reject("START_FOREGROUND_SERVICE_ERROR", e.message, e) + Log.e(TAG, "[module] updateDisplay: Failed to start service: ${e.message}", e) + promise.reject("START_SERVICE_ERROR", e.message, e) } } diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/HeadlessTaskManager.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/HeadlessTaskManager.kt index 362918ca56..614de40713 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/HeadlessTaskManager.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/HeadlessTaskManager.kt @@ -14,11 +14,32 @@ import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.jstasks.HeadlessJsTaskConfig import com.facebook.react.jstasks.HeadlessJsTaskContext import com.facebook.react.jstasks.HeadlessJsTaskEventListener +import java.lang.ref.WeakReference -class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventListener { +/** + * @param onTaskFinished invoked after the active task finishes, unless the manager is being + * released. [CallService] uses it to re-evaluate whether it still has a reason to run: a + * call-less service is started by `acquireBackgroundTask` alone, and nothing else would ever + * stop it. + */ +class HeadlessTaskManager( + private val context: Context, + private val onTaskFinished: () -> Unit = {}, +) : HeadlessJsTaskEventListener { - private var activeTaskId: Int? = null - private var isStarting: Boolean = false + /** Our ownership of the single HeadlessJS task slot. */ + private sealed interface TaskState { + /** Nothing of ours is running or being started. */ + object None : TaskState + /** A start was requested; React Native has not assigned an id yet. */ + object Starting : TaskState + /** React Native is running our task, on [context]. */ + class Active(val id: Int, val context: WeakReference) : TaskState + } + + /** Guards [state] so an inspection and the replacement that follows it cannot interleave. */ + private val stateLock = Any() + private var state: TaskState = TaskState.None private var pendingReactInstanceListener: ReactInstanceEventListener? = null @Volatile private var released: Boolean = false @@ -27,6 +48,53 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis private const val TAG = "[Callingx] HeadlessTaskManager" } + /** + * The running task's id, or null when we have none to act on. + * + * React Native drops in-flight headless tasks when a React context is reloaded or destroyed — + * there is no teardown for them — and these tasks are started with `timeout = 0`, so nothing + * would ever finish one. Ids also restart at 1 in a new context, so a stale one can name a + * *different* library's task. Both are avoided by trusting [TaskState.Active] only while the + * context it was started on is still the live one, and dropping it otherwise. + */ + private fun liveTaskId(): Int? = synchronized(stateLock) { + val active = state as? TaskState.Active ?: return@synchronized null + + val startedOn = active.context.get() + if (startedOn != null && startedOn === currentReactContextOrNull()) { + return@synchronized active.id + } + + debugLog( + TAG, + "[headless] liveTaskId: task ${active.id} was abandoned with its React context, dropping it" + ) + state = TaskState.None + return@synchronized null + } + + /** + * True while we own the task slot, whether or not the task has an id yet. The service treats this + * as a reason to stay alive, so a task abandoned by a context reload must not pin it — but a start + * that has been requested and not yet acknowledged must, since its id does not exist yet. + */ + fun ownsTaskSlot(): Boolean = synchronized(stateLock) { + state is TaskState.Starting || liveTaskId() != null + } + + /** + * [reactContext] throws when the host is not initialised, and this is read from the service's + * stop paths, which run off the main thread. If no context can be resolved there is no task + * either, so the caller treats null as "nothing running". + */ + private fun currentReactContextOrNull(): ReactContext? = + try { + reactContext + } catch (t: Throwable) { + debugLog(TAG, "[headless] currentReactContextOrNull: unavailable: ${t.message}") + null + } + private fun hasReactContext(): Boolean = reactContext != null // ensures the React context is running by booting it via the headless task if not already present @@ -45,16 +113,18 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis public fun startHeadlessTask(taskName: String, data: Bundle, timeout: Long) { debugLog( TAG, - "[headless] startHeadlessTask entry: activeTaskId=$activeTaskId isStarting=$isStarting" + "[headless] startHeadlessTask entry: state=${state::class.simpleName}" ) - if (activeTaskId != null || isStarting) { - Log.w( - TAG, - "[headless] startHeadlessTask: Task already starting or active, ignoring new task request" - ) - return + synchronized(stateLock) { + if (state is TaskState.Starting || liveTaskId() != null) { + Log.w( + TAG, + "[headless] startHeadlessTask: Task already starting or active, ignoring new task request" + ) + return + } + state = TaskState.Starting } - isStarting = true if (UiThreadUtil.isOnUiThread()) { startTask(HeadlessJsTaskConfig(taskName, Arguments.fromBundle(data), timeout, true)) @@ -69,7 +139,10 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis public fun stopHeadlessTask() { debugLog(TAG, "[headless] stopHeadlessTask: Stopping headless task") - activeTaskId?.let { taskId -> + // Deliberately via liveTaskId: HeadlessJsTaskContext.finishTask has no notion of task + // ownership, so finishing a stale id after a context reload would cut short whichever + // library's task inherited that number. + liveTaskId()?.let { taskId -> if (UiThreadUtil.isOnUiThread()) { stopTask(taskId) } else { @@ -100,14 +173,13 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis UiThreadUtil.runOnUiThread { val taskId = headlessJsTaskContext.startTask(taskConfig) - activeTaskId = taskId + synchronized(stateLock) { state = TaskState.Active(taskId, WeakReference(reactContext)) } debugLog(TAG, "[headless] invokeStartTask: Task started: $taskId") - isStarting = false } } private fun stopTask(taskId: Int) { - reactContext?.let { context -> + currentReactContextOrNull()?.let { context -> val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(context) if (headlessJsTaskContext.isTaskRunning(taskId)) { headlessJsTaskContext.finishTask(taskId) @@ -119,7 +191,8 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis fun release() { released = true stopHeadlessTask() - isStarting = false + // Give up the slot synchronously: a Starting state would otherwise outlive the service. + synchronized(stateLock) { state = TaskState.None } // Proactively unregister the pending React-context init listener. Otherwise the callback // would fire after CallService is destroyed, invokeStartTask a stale task on this dead // manager, and register `this` as a task listener on the live ReactContext (leak). @@ -131,8 +204,7 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis // posted by finishTask() drains first — otherwise we'd unregister the listener before // it fires and lose the finish log. UiThreadUtil.runOnUiThread { - activeTaskId = null - reactContext?.let { context -> + currentReactContextOrNull()?.let { context -> val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(context) headlessJsTaskContext.removeTaskEventListener(this) } @@ -156,16 +228,20 @@ class HeadlessTaskManager(private val context: Context) : HeadlessJsTaskEventLis } override fun onHeadlessJsTaskFinish(taskId: Int) { - if (taskId != activeTaskId) { - debugLog( - TAG, - "[headless] onHeadlessJsTaskFinish: IGNORED foreign taskId=$taskId (our=$activeTaskId)" - ) + if (taskId != liveTaskId()) { + debugLog(TAG, "[headless] onHeadlessJsTaskFinish: IGNORED foreign taskId=$taskId") + return + } + debugLog(TAG, "[headless] onHeadlessJsTaskFinish: Task finished: $taskId, slot released") + synchronized(stateLock) { state = TaskState.None } + + if (released) { + // release() runs from CallService.onDestroy and finishes the task itself; the service is + // already going down, so re-entering its stop logic here would be noise at best. + debugLog(TAG, "[headless] onHeadlessJsTaskFinish: released, skipping onTaskFinished") return } - debugLog(TAG, "[headless] onHeadlessJsTaskFinish Task finished: $taskId state cleared: activeTaskId=null isStarting=false") - activeTaskId = null - isStarting = false + onTaskFinished() } /** diff --git a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt index 66762f7c2e..8faac7918c 100644 --- a/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt +++ b/packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt @@ -70,6 +70,13 @@ class CallNotificationManager( val lastSnapshot: NotificationSnapshot? = null, val activeWhen: Long? = null, val hasBecameActive: Boolean = false, + val postedNotification: Notification? = null, + ) + + data class PromotionTarget( + val callId: String, + val notificationId: Int, + val notification: Notification, ) // Per-call state, all guarded by [lock] @@ -120,12 +127,39 @@ class CallNotificationManager( if (!notificationsState.containsKey(callId)) { notificationsState[callId] = CallNotificationState() } - if (foregroundCallId == null) { - foregroundCallId = callId - } return@synchronized getNotificationId(callId) } + /** + * Must be called from the code that actually performed a successful `startForeground()`. + */ + fun commitAnchor(callId: String, notification: Notification) = synchronized(lock) { + if (foregroundCallId != callId) { + debugLog(TAG, "[notifications] commitAnchor: foreground anchor is now $callId") + } + foregroundCallId = callId + recordPostedLocked(callId, notification) + } + + fun clearAnchor() = synchronized(lock) { + if (foregroundCallId != null) { + debugLog(TAG, "[notifications] clearAnchor: foreground anchor cleared") + } + foregroundCallId = null + } + + private fun recordPostedLocked( + callId: String, + notification: Notification, + snapshot: NotificationSnapshot? = null + ) { + val current = notificationsState[callId] ?: CallNotificationState() + notificationsState[callId] = current.copy( + postedNotification = notification, + lastSnapshot = snapshot ?: current.lastSnapshot + ) + } + /** * Sets the optimistic state of the call notification. * Optimistic state is used to update the notification text while the app is connecting or declining the call. @@ -133,13 +167,7 @@ class CallNotificationManager( */ fun setOptimisticState(callId: String, state: OptimisticState) = synchronized(lock) { // Be resilient to races where we receive optimistic actions before a notification state entry exists. - val current = - notificationsState[callId] - ?: CallNotificationState().also { - if (foregroundCallId == null) { - foregroundCallId = callId - } - } + val current = notificationsState[callId] ?: CallNotificationState() notificationsState[callId] = if (state != OptimisticState.NONE) { current.copy(optimisticState = state, lastSnapshot = null) } else { @@ -176,6 +204,7 @@ class CallNotificationManager( .setSmallIcon(R.drawable.ic_round_call_24) .setCategory(NotificationCompat.CATEGORY_CALL) .setPriority(NotificationCompat.PRIORITY_MAX) + .setOnlyAlertOnce(true) .setOngoing(optimisticState != OptimisticState.REJECTING) builder.setStyle(callStyle) @@ -235,18 +264,24 @@ class CallNotificationManager( // Record the snapshot only once the post succeeds, so a rejected notification stays // retryable instead of being skipped as "no state change". if (notifySafely(notificationId, notification)) { - notificationsState[callId] = - notificationsState[callId]?.copy(lastSnapshot = newSnapshot) - ?: CallNotificationState(lastSnapshot = newSnapshot) + recordPostedLocked(callId, notification, newSnapshot) debugLog(TAG, "[notifications] updateCallNotification[$callId]: Notification posted (id=$notificationId)") } } fun postNotification(callId: String, notification: Notification) = synchronized(lock) { val notificationId = getOrCreateNotificationId(callId) - notifySafely(notificationId, notification) + if (notifySafely(notificationId, notification)) { + recordPostedLocked(callId, notification) + } } + fun isNotificationPosted(callId: String): Boolean = + synchronized(lock) { notificationsState[callId]?.postedNotification != null } + + fun lastPostedNotification(callId: String): Notification? = + synchronized(lock) { notificationsState[callId]?.postedNotification } + /** * Posts a notification without letting a platform rejection take down the app. * @@ -272,10 +307,39 @@ class CallNotificationManager( } /** - * Returns a new foreground notification ID if the caller needs to call startForeground() - * to re-promote the service, or null if no action is needed. + * Returns the oldest tracked call with a posted notification, skipping [excluding] (the call + * giving up the anchor). Order is by age, not call state: Telecom allows only one ringing call + * and the JS SDK leaves other calls before registering a new one, so a ringing call is never + * picked over an active one. The notification is returned so the caller need not rebuild it. + */ + fun nextAnchorCandidate(excluding: String): PromotionTarget? = synchronized(lock) { + val next = + notificationsState.entries.firstOrNull { + it.key != excluding && it.value.postedNotification != null + } + if (next == null) { + debugLog( + TAG, + "[notifications] nextAnchorCandidate: nothing posted to re-anchor to (excluding $excluding)" + ) + return@synchronized null + } + + val postedNotification = next.value.postedNotification ?: return@synchronized null + val nextNotificationId = getNotificationId(next.key) + debugLog(TAG, "[notifications] nextAnchorCandidate: ${next.key} (id=$nextNotificationId)") + return@synchronized PromotionTarget( + next.key, + nextNotificationId, + postedNotification + ) + } + + /** + * Cancels the notification for [callId] and drops its state. Call this *after* the anchor has + * moved (re-promotion) or been given up (demotion). */ - fun cancelNotification(callId: String): Int? = synchronized(lock) { + fun cancelNotification(callId: String) = synchronized(lock) { debugLog(TAG, "[notifications] cancelNotification[$callId]") val state = notificationsState.remove(callId) val notificationId = getNotificationId(callId) @@ -283,15 +347,10 @@ class CallNotificationManager( if (state != null) { debugLog(TAG, "[notifications] cancelNotification[$callId]: Cancelled (id=$notificationId)") } - if (foregroundCallId == callId) { - foregroundCallId = notificationsState.keys.firstOrNull() - // Return the new foreground notification ID so the service can re-promote - if (foregroundCallId != null) { - return@synchronized getNotificationId(foregroundCallId!!) - } + debugLog(TAG, "[notifications] cancelNotification[$callId]: was the anchor, clearing") + foregroundCallId = null } - return@synchronized null } fun getForegroundCallId(): String? = synchronized(lock) { foregroundCallId } @@ -343,7 +402,7 @@ class CallNotificationManager( notificationsState[callId] = current.copy(optimisticState = OptimisticState.NONE, lastSnapshot = null) } - // when skipIncomingPushInForeground is true, we use the ongoing channel + // when skipIncomingPushInForeground is true, we use the ongoing channel // for the notification to avoid notification overlapping app ui, just for UX purposes private fun shouldShowAsIncoming( call: Call.Registered, diff --git a/packages/react-native-callingx/src/types.ts b/packages/react-native-callingx/src/types.ts index 75e325bde9..a90deef02b 100644 --- a/packages/react-native-callingx/src/types.ts +++ b/packages/react-native-callingx/src/types.ts @@ -203,6 +203,14 @@ export interface ICallingxModule { registerVoipToken(): void; + /** + * Asks the Android call service to stop. Android-only; resolves as a no-op on iOS. + * + * This is a *request*, not a command: the service hosts every call, so it stays alive while any + * call is registered or in the middle of being registered. Use {@link endCallWithReason} to tear + * down an individual call — this method never ends calls, and never dismisses their + * notifications. + */ stopService(): Promise; /** diff --git a/packages/react-native-sdk/__tests__/push/android.test.ts b/packages/react-native-sdk/__tests__/push/android.test.ts new file mode 100644 index 0000000000..5733a2e0af --- /dev/null +++ b/packages/react-native-sdk/__tests__/push/android.test.ts @@ -0,0 +1,107 @@ +/** + * The two `createStreamVideoClient` failure branches of the Android `call.ring` push handler + * abandon the push. `callingx.stopService()` is only a request — it no-ops while another call is + * registered or being registered — so the abandoned call has to be ended explicitly, otherwise its + * notification is stranded whenever a second call is live. + */ + +const CALL_CID = 'default:abandoned'; +const RING_DATA = { + call_cid: CALL_CID, + sender: 'stream.video', + type: 'call.ring', +}; + +/** Loads the handler with a failing client factory. `calls` records the callingx sequence. */ +const setup = (createStreamVideoClient: jest.Mock) => { + const calls: string[] = []; + const callingx = { + log: jest.fn(), + acquireBackgroundTask: jest.fn().mockResolvedValue(undefined), + releaseBackgroundTask: jest.fn(() => { + calls.push('release'); + }), + endCallWithReason: jest.fn(async () => { + calls.push('end'); + }), + stopService: jest.fn(async () => { + calls.push('stop'); + }), + }; + + let handler!: (data: unknown) => Promise; + let subscriptions!: Map; + jest.isolateModules(() => { + jest.doMock('react-native', () => ({ + Platform: { OS: 'android' }, + AppState: { currentState: 'background', addEventListener: jest.fn() }, + })); + // mocked to keep the video-client / react-native-webrtc runtime out of the test + jest.doMock('@stream-io/video-client', () => ({ + CallingState: { IDLE: 'idle', LEFT: 'left' }, + })); + jest.doMock('../../src/utils/push/libs', () => ({ + getCallingxLib: () => callingx, + getCallingxLibIfAvailable: () => callingx, + })); + jest.doMock('../../src/utils/StreamVideoRN', () => ({ + StreamVideoRN: { + getConfig: () => ({ push: { createStreamVideoClient } }), + }, + })); + jest.doMock('../../src/utils/push/internal/utils', () => ({ + canListenToWS: () => true, + shouldCallBeClosed: () => ({ mustEndCall: false }), + })); + handler = + require('../../src/utils/push/internal/android').onRingNotificationReceived; + subscriptions = + require('../../src/utils/push/internal/constants').pushUnsubscriptionCallbacks; + }); + + return { handler, calls, callingx, subscriptions }; +}; + +describe('onRingNotificationReceived — abandoning a push', () => { + afterEach(() => { + jest.resetModules(); + }); + + it.each<[string, () => jest.Mock]>([ + ['returns no client', () => jest.fn().mockResolvedValue(undefined)], + ['throws', () => jest.fn().mockRejectedValue(new Error('boom'))], + ])( + 'ends the call before requesting the stop when the client factory %s', + async (_label, clientFactory) => { + const { handler, calls, callingx, subscriptions } = + setup(clientFactory()); + + await handler(RING_DATA); + + expect(calls).toEqual(['release', 'end', 'stop']); + expect(callingx.endCallWithReason).toHaveBeenCalledWith( + CALL_CID, + 'error', + ); + expect(subscriptions.has(CALL_CID)).toBe(false); + }, + ); + + it.each<['endCallWithReason' | 'stopService', string[]]>([ + ['endCallWithReason', ['release', 'stop']], + ['stopService', ['release', 'end']], + ])('finishes the cleanup when %s rejects', async (failing, expected) => { + const { handler, calls, callingx, subscriptions } = setup( + jest.fn().mockResolvedValue(undefined), + ); + callingx[failing].mockRejectedValue(new Error('boom')); + + await handler(RING_DATA); + + expect(calls).toEqual(expected); + // a retained entry would make every later push for this cid look like a duplicate + expect(subscriptions.has(CALL_CID)).toBe(false); + }); +}); + +export {}; diff --git a/packages/react-native-sdk/src/hooks/push/useCallingExpWithCallingStateEffect.ts b/packages/react-native-sdk/src/hooks/push/useCallingExpWithCallingStateEffect.ts index 461827f6a5..a13a190ece 100644 --- a/packages/react-native-sdk/src/hooks/push/useCallingExpWithCallingStateEffect.ts +++ b/packages/react-native-sdk/src/hooks/push/useCallingExpWithCallingStateEffect.ts @@ -112,12 +112,19 @@ export const useCallingExpWithCallingStateEffect = () => { return; } - callingx.updateDisplay( - activeCallCid, - createdByUserId ?? callDisplayName, - callDisplayName, - isIncoming, - ); + callingx + .updateDisplay( + activeCallCid, + createdByUserId ?? callDisplayName, + callDisplayName, + isIncoming, + ) + .catch((error: unknown) => { + logger.debug( + `useCallingExpWithCallingStateEffect: Error updating display in callingx: ${activeCallCid}`, + error, + ); + }); }, [activeCallCid, createdByUserId, callDisplayName, isIncoming]); // Sync microphone mute state from app → CallKit diff --git a/packages/react-native-sdk/src/utils/push/internal/android.ts b/packages/react-native-sdk/src/utils/push/internal/android.ts index b6356ae080..33b6de0c7b 100644 --- a/packages/react-native-sdk/src/utils/push/internal/android.ts +++ b/packages/react-native-sdk/src/utils/push/internal/android.ts @@ -79,6 +79,35 @@ export const onRingNotificationReceived = async ( callingx.releaseBackgroundTask(backgroundTaskOwner); }; + /** + * Gives up on this push: dismisses the call the native push path already displayed, then asks + * the call service to stop. `stopService` is only a request — it no-ops while any other call is + * registered or being registered — so this call must be ended explicitly rather than relying on + * the service teardown to wipe it. + */ + const abandonPush = async () => { + if (asForegroundService) { + finishBackgroundTask(); + } + // Each step is contained on its own: failing to dismiss the call must not skip the stop + // request, and neither may skip the unsubscription cleanup — a stale entry makes every later + // push for this cid look like a duplicate and get discarded. + try { + await callingx.endCallWithReason(call_cid, 'error'); + } catch (error) { + nativeLog(`Failed to end call ${call_cid}: ${error}`, 'error'); + } + try { + await callingx.stopService(); + } catch (error) { + nativeLog( + `Failed to stop the call service for ${call_cid}: ${error}`, + 'error', + ); + } + pushUnsubscriptionCallbacks.delete(call_cid); + }; + if (asForegroundService) { // initialize the callback array immediately to avoid race condition pushUnsubscriptionCallbacks.set(call_cid, []); @@ -98,21 +127,13 @@ export const onRingNotificationReceived = async ( client = await pushConfig.createStreamVideoClient(); if (!client) { nativeLog(`video client not found, skipping the call.ring notification`); - if (asForegroundService) { - finishBackgroundTask(); - } - await callingx.stopService(); - pushUnsubscriptionCallbacks.delete(call_cid); + await abandonPush(); return; } } catch (error) { //we need to release the background task and stop the service to avoid stale owner nativeLog(`Failed to create video client: ${error}`, 'error'); - if (asForegroundService) { - finishBackgroundTask(); - } - await callingx.stopService(); - pushUnsubscriptionCallbacks.delete(call_cid); + await abandonPush(); return; }