Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bc60a2d
chore: call service adjustments
greenfrvr Aug 26, 2026
dc314a9
chore: make foreground promotion unconditional
greenfrvr Aug 27, 2026
e3c6c8f
fix(callingx): don't tear down concurrent calls on stop service
santhoshvai Aug 27, 2026
e6ed423
fix(callingx): don't tear down concurrent calls on stop service
santhoshvai Aug 27, 2026
13083e7
fix(callingx): address review on the stop-service guard
santhoshvai Aug 28, 2026
d31deee
chore(callingx): drop the unused CallService binder surface
santhoshvai Aug 28, 2026
6223a51
fix(callingx): stop the service when its headless task finishes
santhoshvai Aug 28, 2026
bf68734
feat: made fgs promotion rely on posted notificaions
greenfrvr Aug 28, 2026
21cce50
Merge branch 'call-service-tweaks' into stop-service
santhoshvai Aug 28, 2026
c7dc2ba
Merge branch 'main' into stop-service
santhoshvai Aug 28, 2026
0df566d
chore: added notifications cache
greenfrvr Aug 31, 2026
dd28a6b
chore: code cleanup
greenfrvr Aug 31, 2026
bc0af1a
chore: code cleanup
greenfrvr Aug 31, 2026
463cea4
Merge branch 'main' into fgs-repromotion
greenfrvr Aug 31, 2026
d6c4cc9
chore: code cleanup
greenfrvr Aug 31, 2026
b4ea740
Merge branch 'main' into fgs-repromotion
santhoshvai Sep 3, 2026
206302e
rabbit review fixes
santhoshvai Sep 3, 2026
96b9b41
better doc
santhoshvai Sep 3, 2026
f9ee501
Merge branch 'fgs-repromotion' into stop-service
santhoshvai Sep 3, 2026
46b220b
fix(callingx): route every stop through the one idle guard
santhoshvai Sep 3, 2026
d424e3e
docs(callingx): shorten the stopServiceIfIdle kdoc
santhoshvai Sep 3, 2026
2db8ea7
fix(callingx): don't let an abandoned headless task pin the service
santhoshvai Sep 3, 2026
748a4b4
fix(callingx): keep the service teardown from reaching other libraries
santhoshvai Sep 3, 2026
311ef5a
fix(callingx): validate task identity on start, keep native tracking …
santhoshvai Sep 3, 2026
db09b57
fix(callingx): scope the registration veto to the service instance
santhoshvai Sep 3, 2026
9329e4f
fix(callingx): close the remaining service-lifecycle races from review
santhoshvai Sep 3, 2026
f55d4a7
refactor(callingx): clarify task-slot naming and call-state discard
santhoshvai Sep 3, 2026
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
1 change: 1 addition & 0 deletions packages/react-native-callingx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactContext>) : 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
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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).
Expand All @@ -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)
}
Expand All @@ -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()
}

/**
Expand Down
Loading