fix: stop holding a wake lock for 30 seconds after every push

Every background push kept the app awake for the full 30 s timeout of
the BulwarkPushTask headless task, however quickly the task was done.
AppRegistry reports a finished task through the native
HeadlessJsTaskSupport module, and React Native 0.81 registers that
module only for the old bridge (CoreModulesPackage); the bridgeless
runtime's CoreReactPackage leaves it out. JS could never say it was
done, so the service, or the job, and its wake lock waited for the
timeout. BulwarkHeadlessJsTaskSupportModule provides the module under
the same name when the app runs bridgeless.

The push path also leaked a wake lock now and then. startHeadlessTask
called HeadlessJsTaskService.acquireWakeLockNow on the FCM worker
thread while the service took the same static lock on the main thread.
The method is not thread-safe, so both could create a lock, and the one
that was overwritten stayed held. The FCM thread now takes its own
wake lock, timed at 10 s, and the service drops it as soon as it holds
React Native's.

Measured on the API 35 emulator with the R8 release build:

- push handled by the job: 30,000 ms before, 75-155 ms now
- push handled by the service: the service ran 31 s before, it is
  gone within a second now
- `com.facebook.react.i` wake lock still held 5 s after a push: 2 of
  4 pushes before, 0 of 8 now
This commit is contained in:
Linus Rath
2026-09-24 22:14:35 +02:00
parent fef23e4c33
commit 5ddc2df64b
4 changed files with 74 additions and 7 deletions
@@ -3,15 +3,20 @@ package com.anonymous.bulwarkmobile
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags
import com.facebook.react.uimanager.ViewManager
class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(
BulwarkFcmModule(reactContext),
BulwarkUnifiedPushModule(reactContext),
BulwarkClientCertModule(reactContext),
)
buildList {
add(BulwarkFcmModule(reactContext))
add(BulwarkUnifiedPushModule(reactContext))
add(BulwarkClientCertModule(reactContext))
// The bridge registers React Native's own module under this name.
if (ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture()) {
add(BulwarkHeadlessJsTaskSupportModule(reactContext))
}
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
@@ -0,0 +1,35 @@
package com.anonymous.bulwarkmobile
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.jstasks.HeadlessJsTaskContext
/**
* React Native's HeadlessJsTaskSupport module, which AppRegistry calls when a
* headless JS task's promise settles. RN 0.81 registers it only for the old
* bridge (CoreModulesPackage); the bridgeless runtime's CoreReactPackage
* leaves it out, so in this app JS could never report a finished task and
* every BulwarkPushTask ran until its 30 s timeout, keeping the service or job
* and its wake lock alive that long. Same logic as the RN module.
*/
class BulwarkHeadlessJsTaskSupportModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "HeadlessJsTaskSupport"
@ReactMethod
fun notifyTaskFinished(taskId: Double) {
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
val id = taskId.toInt()
if (tasks.isTaskRunning(id)) tasks.finishTask(id)
}
@ReactMethod
fun notifyTaskRetry(taskId: Double, promise: Promise) {
val tasks = HeadlessJsTaskContext.getInstance(reactApplicationContext)
val id = taskId.toInt()
promise.resolve(tasks.isTaskRunning(id) && tasks.retryTask(id))
}
}
@@ -8,9 +8,9 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
@@ -46,6 +46,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
companion object {
const val CHANNEL_ID = "bulwark_mail"
private const val TAG = "BulwarkPush"
private const val START_WAKE_LOCK_MS = 10_000L
// Shared with BulwarkUnifiedPushService - a push arriving over either
// transport is dispatched the same way.
@@ -62,9 +63,20 @@ class BulwarkMessagingService : FirebaseMessagingService() {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
// Keep the device awake until the service holds React Native's
// wake lock; BulwarkPushTaskService releases this one then. Not
// HeadlessJsTaskService.acquireWakeLockNow: it is not
// thread-safe, and FCM calls this on a worker thread while the
// service takes the same static lock on the main thread, so both
// could create a lock and the overwritten one stayed held.
startWakeLock = context.getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "bulwark:push-start")
.apply {
setReferenceCounted(false)
acquire(START_WAKE_LOCK_MS)
}
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
@@ -72,11 +84,20 @@ class BulwarkMessagingService : FirebaseMessagingService() {
// message may not (BackgroundServiceStartNotAllowedException
// on 12+), and uncaught this crashed the app on every such
// push. Run the same task from a job instead.
releaseStartWakeLock()
Log.w(TAG, "Push service start refused in the background, using a job: ${e.message}")
BulwarkPushJobService.schedule(context, data)
}
}
@Volatile private var startWakeLock: PowerManager.WakeLock? = null
/** Drops the wake lock taken in [startHeadlessTask]; it also times out on its own. */
fun releaseStartWakeLock() {
startWakeLock?.release()
startWakeLock = null
}
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -7,6 +7,12 @@ import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.HeadlessJsTaskService
class BulwarkPushTaskService : HeadlessJsTaskService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
super.onStartCommand(intent, flags, startId).also {
// startTask has taken React Native's wake lock by now.
BulwarkMessagingService.releaseStartWakeLock()
}
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val extras = intent?.extras ?: return null
return taskConfig(extras)