From fef23e4c33c942cd1802c5a477e7c48b57fe89ec Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:44:18 +0200 Subject: [PATCH] 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 --- android/app/src/main/AndroidManifest.xml | 1 + .../bulwarkmobile/BulwarkMessagingService.kt | 17 +- .../bulwarkmobile/BulwarkPushJobService.kt | 180 ++++++++++++++++++ .../bulwarkmobile/BulwarkPushTaskService.kt | 20 +- 4 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushJobService.kt 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, + ) } }