From 1f7acaf08abc58706b8c7c3e994d874efba56c07 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:19:17 +0200 Subject: [PATCH] refactor: start headless tasks for push and device sync the same way The code that starts React Native for a headless JS task, when the process was woken without any UI, lived inside BulwarkPushJobService. The device sync adapters need the same start, so it moves to a shared HeadlessJs object, with a small startTask helper next to it. Push behaviour does not change: - onStartJob still calls the start code on the main thread, and the listener-then-ready-check order is the same code, moved verbatim - the task still starts from `UiThreadUtil.runOnUiThread`, its id is still recorded in that same UI-thread turn, and a refusal because the app came to the foreground still logs and finishes the job - the start code now returns React Native's start task, so a caller can notice a start that fails (a debug build with no Metro); the push job ignores it The helper documents that it must run on the main thread: the ReactHost is created on first use without a lock, so a background caller racing MainActivity could create a second one. Checked with `gradlew assembleDebug -PreactNativeArchitectures=x86_64`. --- .../bulwarkmobile/BulwarkPushJobService.kt | 73 ++---------- .../com/anonymous/bulwarkmobile/HeadlessJs.kt | 106 ++++++++++++++++++ 2 files changed, 115 insertions(+), 64 deletions(-) create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt index cde4fd5f..60a7cc14 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt @@ -11,16 +11,11 @@ import android.os.Bundle import android.os.PersistableBundle import android.os.SystemClock import android.util.Log -import com.facebook.react.ReactApplication -import com.facebook.react.ReactInstanceEventListener import com.facebook.react.bridge.ReactContext -import com.facebook.react.bridge.UiThreadUtil -import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags import com.facebook.react.jstasks.HeadlessJsTaskConfig import com.facebook.react.jstasks.HeadlessJsTaskContext import com.facebook.react.jstasks.HeadlessJsTaskEventListener import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean /** * Runs the BulwarkPushTask headless JS task from a JobScheduler job. @@ -44,7 +39,8 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener { override fun onStartJob(params: JobParameters): Boolean { val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras)) - withReadyReactContext { startTask(it, config, params) } + // JobService callbacks run on the main thread, where HeadlessJs wants to be called. + HeadlessJs.withReadyReactContext(application) { startTask(it, config, params) } return true } @@ -76,68 +72,17 @@ class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener { tasks.addTaskEventListener(this) taskContext = tasks } - UiThreadUtil.runOnUiThread { - try { - val taskId = tasks.startTask(config) - running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) - } catch (e: IllegalStateException) { + HeadlessJs.startTask( + tasks, + config, + onStarted = { taskId -> running[taskId] = RunningTask(params, SystemClock.elapsedRealtime()) }, + onRefused = { e -> // The app came to the foreground since the push arrived and // syncs on its own there. Log.i(TAG, "Push job ${params.jobId} skipped: ${e.message}") jobFinished(params, false) - } - } - } - - /** - * Calls [onReady] once with a React context whose JS instance is up, - * starting React Native if the process was woken for this job. - * - * A context exists before its instance is ready, and a task started on - * it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance - * not available" and waits for the timeout), which happened when several - * pushes arrived while the app was starting. So the listener goes in - * first and the ready check after it; whichever sees the ready instance - * first runs the task. - */ - private fun withReadyReactContext(onReady: (ReactContext) -> Unit) { - val done = AtomicBoolean(false) - val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) } - val app = application as ReactApplication - if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { - val host = checkNotNull(app.reactHost) { "ReactHost is not initialized in New Architecture" } - val listener = object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - host.removeReactInstanceEventListener(this) - runOnce(context) - } - } - host.addReactInstanceEventListener(listener) - val current = host.currentReactContext - if (current != null && current.hasActiveReactInstance()) { - host.removeReactInstanceEventListener(listener) - runOnce(current) - } else { - host.start() - } - } else { - @Suppress("DEPRECATION") - val manager = app.reactNativeHost.reactInstanceManager - val listener = object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - manager.removeReactInstanceEventListener(this) - runOnce(context) - } - } - manager.addReactInstanceEventListener(listener) - val current = manager.currentReactContext - if (current != null && current.hasActiveReactInstance()) { - manager.removeReactInstanceEventListener(listener) - runOnce(current) - } else if (!manager.hasStartedCreatingInitialContext()) { - manager.createReactContextInBackground() - } - } + }, + ) } companion object { diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt new file mode 100644 index 00000000..e8521c38 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/HeadlessJs.kt @@ -0,0 +1,106 @@ +package com.anonymous.bulwarkmobile + +import android.app.Application +import androidx.annotation.MainThread +import com.facebook.react.ReactApplication +import com.facebook.react.ReactInstanceEventListener +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.interfaces.TaskInterface +import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags +import com.facebook.react.jstasks.HeadlessJsTaskConfig +import com.facebook.react.jstasks.HeadlessJsTaskContext +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Running a headless JS task without HeadlessJsTaskService: starting React + * Native when the process was woken without any UI, and starting the task on + * its context. Shared by the push job (BulwarkPushJobService) and the device + * sync adapters. + */ +object HeadlessJs { + /** + * Calls [onReady] once with a React context whose JS instance is up, + * starting React Native if the process was woken for this work. + * + * A context exists before its instance is ready, and a task started on + * it then never reaches JS (HeadlessJsTaskContext only logs "CatalystInstance + * not available" and waits for the timeout), which happened when several + * pushes arrived while the app was starting. So the listener goes in + * first and the ready check after it; whichever sees the ready instance + * first runs the task. + * + * Call it on the main thread; [onReady] runs there too. MainApplication + * creates the ReactHost on first use without a lock (ExpoReactHostFactory), + * so a background caller racing MainActivity could create a second one, + * with a second JS runtime. + * + * Returns React Native's start task when it had to be started, so a caller + * can notice a start that fails (a debug build with no Metro to load the + * bundle from), or null when the context was already up. + */ + @MainThread + fun withReadyReactContext(app: Application, onReady: (ReactContext) -> Unit): TaskInterface? { + val done = AtomicBoolean(false) + val runOnce = { context: ReactContext -> if (done.compareAndSet(false, true)) onReady(context) } + val reactApp = app as ReactApplication + if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) { + val host = checkNotNull(reactApp.reactHost) { "ReactHost is not initialized in New Architecture" } + val listener = object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + host.removeReactInstanceEventListener(this) + runOnce(context) + } + } + host.addReactInstanceEventListener(listener) + val current = host.currentReactContext + if (current != null && current.hasActiveReactInstance()) { + host.removeReactInstanceEventListener(listener) + runOnce(current) + return null + } + return host.start() + } + @Suppress("DEPRECATION") + val manager = reactApp.reactNativeHost.reactInstanceManager + val listener = object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + manager.removeReactInstanceEventListener(this) + runOnce(context) + } + } + manager.addReactInstanceEventListener(listener) + val current = manager.currentReactContext + if (current != null && current.hasActiveReactInstance()) { + manager.removeReactInstanceEventListener(listener) + runOnce(current) + } else if (!manager.hasStartedCreatingInitialContext()) { + manager.createReactContextInBackground() + } + return null + } + + /** + * Starts [config] on the UI thread, where HeadlessJsTaskContext wants it. + * [onStarted] gets the task id in that same UI-thread turn, so before any + * finish callback (HeadlessJsTaskContext posts those to the UI thread). + * [onRefused] gets the refusal when the context is resumed and [config] + * isn't allowed in the foreground. + */ + fun startTask( + tasks: HeadlessJsTaskContext, + config: HeadlessJsTaskConfig, + onStarted: (taskId: Int) -> Unit, + onRefused: (IllegalStateException) -> Unit, + ) { + UiThreadUtil.runOnUiThread { + val taskId = try { + tasks.startTask(config) + } catch (e: IllegalStateException) { + onRefused(e) + return@runOnUiThread + } + onStarted(taskId) + } + } +}