From 5ddc2df64b52db37ffba656f01cb3ba002f2c202 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:05:28 +0200 Subject: [PATCH] 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 --- .../bulwarkmobile/BulwarkFcmPackage.kt | 15 +++++--- .../BulwarkHeadlessJsTaskSupportModule.kt | 35 +++++++++++++++++++ .../bulwarkmobile/BulwarkMessagingService.kt | 25 +++++++++++-- .../bulwarkmobile/BulwarkPushTaskService.kt | 6 ++++ 4 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkHeadlessJsTaskSupportModule.kt diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt index 77854735..f384dd56 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmPackage.kt @@ -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 = - 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> = emptyList() diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkHeadlessJsTaskSupportModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkHeadlessJsTaskSupportModule.kt new file mode 100644 index 00000000..5058e4a0 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkHeadlessJsTaskSupportModule.kt @@ -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)) + } +} 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 4d26a8b0..41c553db 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt @@ -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 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 63d98199..4af9f11a 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushTaskService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkPushTaskService.kt @@ -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)