mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
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`.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<Void>? {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user