diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index f5ca4234..a1318459 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -59,6 +59,7 @@
+
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
index 5a9f876b..4d26a8b0 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt
@@ -8,6 +8,7 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
+import android.util.Log
import androidx.core.app.NotificationCompat
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
@@ -44,6 +45,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
companion object {
const val CHANNEL_ID = "bulwark_mail"
+ private const val TAG = "BulwarkPush"
// Shared with BulwarkUnifiedPushService - a push arriving over either
// transport is dispatched the same way.
@@ -60,8 +62,19 @@ class BulwarkMessagingService : FirebaseMessagingService() {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
- context.startService(intent)
- HeadlessJsTaskService.acquireWakeLockNow(context)
+ try {
+ context.startService(intent)
+ HeadlessJsTaskService.acquireWakeLockNow(context)
+ } catch (e: IllegalStateException) {
+ // Android 8+ refuses to start a service from the background
+ // unless the app is temporarily allowlisted. A high-priority
+ // FCM message does that; a downgraded one or a UnifiedPush
+ // message may not (BackgroundServiceStartNotAllowedException
+ // on 12+), and uncaught this crashed the app on every such
+ // push. Run the same task from a job instead.
+ Log.w(TAG, "Push service start refused in the background, using a job: ${e.message}")
+ BulwarkPushJobService.schedule(context, data)
+ }
}
fun ensureChannel(context: Context) {
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt
new file mode 100644
index 00000000..cde4fd5f
--- /dev/null
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt
@@ -0,0 +1,180 @@
+package com.anonymous.bulwarkmobile
+
+import android.app.job.JobInfo
+import android.app.job.JobParameters
+import android.app.job.JobScheduler
+import android.app.job.JobService
+import android.content.ComponentName
+import android.content.Context
+import android.os.Build
+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.
+ *
+ * A push normally starts BulwarkPushTaskService, but Android 8+ only lets a
+ * background app start a service while it is temporarily allowlisted, which a
+ * high-priority FCM message does. FCM delivers some messages at normal
+ * priority anyway (it downgrades apps whose high-priority pushes rarely end in
+ * a notification), and a UnifiedPush distributor may not raise the app at all.
+ * The service start then throws, so the push is handed to this job instead:
+ * expedited on Android 12+, where it runs right away while the app has
+ * expedited-job quota and as a regular job after that, and a regular job on
+ * Android 8-11. It needs no new dependency and ends in the same notification
+ * as the service path.
+ */
+class BulwarkPushJobService : JobService(), HeadlessJsTaskEventListener {
+ private class RunningTask(val params: JobParameters, val startedAt: Long)
+
+ private val running = ConcurrentHashMap()
+ private var taskContext: HeadlessJsTaskContext? = null
+
+ override fun onStartJob(params: JobParameters): Boolean {
+ val config = BulwarkPushTaskService.taskConfig(Bundle(params.extras))
+ withReadyReactContext { startTask(it, config, params) }
+ return true
+ }
+
+ // The JS task has its own 30 s timeout and finishes on its own; nothing to
+ // reschedule if the system stops the job first.
+ override fun onStopJob(params: JobParameters): Boolean {
+ running.values.removeIf { it.params.jobId == params.jobId }
+ return false
+ }
+
+ override fun onDestroy() {
+ taskContext?.removeTaskEventListener(this)
+ super.onDestroy()
+ }
+
+ override fun onHeadlessJsTaskStart(taskId: Int) = Unit
+
+ override fun onHeadlessJsTaskFinish(taskId: Int) {
+ val task = running.remove(taskId) ?: return
+ val ms = SystemClock.elapsedRealtime() - task.startedAt
+ Log.i(TAG, "Push task $taskId from job ${task.params.jobId} finished after $ms ms")
+ jobFinished(task.params, false)
+ }
+
+ private fun startTask(context: ReactContext, config: HeadlessJsTaskConfig, params: JobParameters) {
+ val tasks = HeadlessJsTaskContext.getInstance(context)
+ if (taskContext !== tasks) {
+ taskContext?.removeTaskEventListener(this)
+ tasks.addTaskEventListener(this)
+ taskContext = tasks
+ }
+ UiThreadUtil.runOnUiThread {
+ try {
+ val taskId = tasks.startTask(config)
+ running[taskId] = RunningTask(params, SystemClock.elapsedRealtime())
+ } catch (e: IllegalStateException) {
+ // 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 {
+ private const val TAG = "BulwarkPush"
+
+ // A block of job ids for push jobs, one per push that is waiting or
+ // running. A pending job with the same id would be replaced (and a
+ // running one stopped), so each push takes a free id.
+ private const val JOB_ID_BASE = 0x42554c00
+ private const val JOB_ID_COUNT = 32
+
+ /** Schedules the push task as a job. Returns false if no job could be scheduled. */
+ fun schedule(context: Context, data: Map): Boolean = try {
+ val scheduler = context.getSystemService(JobScheduler::class.java)
+ val busy = scheduler.allPendingJobs.mapTo(HashSet()) { it.id }
+ val jobId = (JOB_ID_BASE until JOB_ID_BASE + JOB_ID_COUNT).firstOrNull { it !in busy }
+ if (jobId == null) {
+ // Every slot holds a push that has not finished yet; any of
+ // them fetches the same new mail.
+ Log.w(TAG, "Push not scheduled: $JOB_ID_COUNT push jobs already pending")
+ false
+ } else {
+ val extras = PersistableBundle().apply {
+ for ((key, value) in data) putString(key, value)
+ }
+ val job = JobInfo.Builder(jobId, ComponentName(context, BulwarkPushJobService::class.java))
+ .setExtras(extras)
+ .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) setExpedited(true) }
+ .build()
+ val scheduled = scheduler.schedule(job) == JobScheduler.RESULT_SUCCESS
+ Log.i(TAG, "Push job $jobId scheduled: $scheduled")
+ scheduled
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Push job could not be scheduled", e)
+ false
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushTaskService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushTaskService.kt
index c4b118e9..63d98199 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushTaskService.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushTaskService.kt
@@ -1,6 +1,7 @@
package com.anonymous.bulwarkmobile
import android.content.Intent
+import android.os.Bundle
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.HeadlessJsTaskService
@@ -8,11 +9,18 @@ import com.facebook.react.HeadlessJsTaskService
class BulwarkPushTaskService : HeadlessJsTaskService() {
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val extras = intent?.extras ?: return null
- return HeadlessJsTaskConfig(
- "BulwarkPushTask",
- Arguments.fromBundle(extras),
- 30000L,
- false,
- )
+ return taskConfig(extras)
+ }
+
+ companion object {
+ // Shared with BulwarkPushJobService, which runs the same task when
+ // this service may not be started.
+ fun taskConfig(extras: Bundle): HeadlessJsTaskConfig =
+ HeadlessJsTaskConfig(
+ "BulwarkPushTask",
+ Arguments.fromBundle(extras),
+ 30000L,
+ false,
+ )
}
}