fix: stop a background push from crashing the app on android 12 and later

A push that arrived while the app was in the background started
BulwarkPushTaskService with `startService`. Android 8+ allows that only
while the app is temporarily allowlisted, which a high-priority FCM
message grants. The relay does send FCM data messages at HIGH priority,
but FCM delivers some at normal priority anyway (it downgrades apps
whose high-priority messages rarely end in a notification), and a
UnifiedPush distributor may not raise the app at all. The call then
threw `BackgroundServiceStartNotAllowedException` and the app crashed,
from the FCM thread or, for UnifiedPush, on the main thread.

The service start is kept as the first choice, so a high-priority push
works as before. When Android refuses it, the same headless task now
runs from a JobScheduler job (BulwarkPushJobService): expedited on
Android 12+, which runs at once while the app has expedited-job quota
and as a regular job after that, and a regular job on Android 8-11.
JobScheduler is a platform API, so nothing is added to the build;
WorkManager is not in the dependency tree. A generic "new mail"
notification built natively was the other option, but the relay's
payload cannot tell new mail from other changes and the native side
knows neither the notification settings nor what was already shown.

Checked on the API 35 emulator with the R8 release build, the process
killed and the push delivered past the allowlist:

- FCM data message: before, FATAL EXCEPTION on
  Firebase-Messaging-Intent-Handle; now the job is scheduled 18 ms
  after the refusal and runs the task
- UnifiedPush message (connector 3.3.5, sent the way a distributor
  does): before, FATAL EXCEPTION on main; now the same job path
- allowlisted FCM message: still starts BulwarkPushTaskService
This commit is contained in:
Linus Rath
2026-09-24 22:14:35 +02:00
parent 4a3bce154c
commit fef23e4c33
4 changed files with 210 additions and 8 deletions
+1
View File
@@ -59,6 +59,7 @@
</intent-filter>
</service>
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
@@ -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) {
@@ -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<Int, RunningTask>()
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<String, String>): 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
}
}
}
@@ -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,
)
}
}