From be15af5d4c6878365cdc57c4e2c8c1cbf206528d Mon Sep 17 00:00:00 2001
From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com>
Date: Fri, 9 Oct 2026 12:41:53 +0200
Subject: [PATCH] fix(notifications): execute background actions directly via
HeadlessJs without service
---
android/app/src/main/AndroidManifest.xml | 1 -
.../BulwarkNotificationActionReceiver.kt | 104 ++++++++++++++++--
.../BulwarkNotificationActionService.kt | 18 ---
.../__tests__/push-background-task.test.ts | 35 ++++++
src/lib/push-background-task.ts | 32 +++++-
5 files changed, 158 insertions(+), 32 deletions(-)
delete mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 68ab6fab..5485a398 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -77,7 +77,6 @@
-
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt
index 94bdca1c..ae210a4e 100644
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt
+++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt
@@ -1,16 +1,26 @@
package com.anonymous.bulwarkmobile
+import android.app.Application
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.util.Log
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.jstasks.HeadlessJsTaskConfig
+import com.facebook.react.jstasks.HeadlessJsTaskContext
+import com.facebook.react.jstasks.HeadlessJsTaskEventListener
class BulwarkNotificationActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val notificationId = intent.getStringExtra(BulwarkFcmModule.EXTRA_NOTIFICATION_ID)
val action = intent.getStringExtra(BulwarkFcmModule.EXTRA_ACTION)
val emailId = intent.getStringExtra(NotificationTapStore.EXTRA_EMAIL_ID)
+ val accountId = intent.getStringExtra(NotificationTapStore.EXTRA_ACCOUNT_ID)
+ val jmapAccountId = intent.getStringExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID)
// Dismiss the notification immediately from the tray
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -18,17 +28,97 @@ class BulwarkNotificationActionReceiver : BroadcastReceiver() {
manager.cancel(notificationId, notificationId.hashCode())
}
- if (emailId.isNullOrBlank() || action.isNullOrBlank()) return
-
- // Dispatch background headless task to perform JMAP operations
- val serviceIntent = Intent(context, BulwarkNotificationActionService::class.java).apply {
- intent.extras?.let { putExtras(it) }
+ if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) {
+ Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId")
+ return
}
+ // On Android 8+, a broadcast receiver in background cannot start a Service
+ // (BackgroundServiceStartNotAllowedException). Instead, we use goAsync() and
+ // HeadlessJs.withReadyReactContext to execute the headless JS task directly.
+ val pendingResult = goAsync()
+ val app = context.applicationContext as Application
+ val mainHandler = Handler(Looper.getMainLooper())
+
+ // Safety timeout to ensure pendingResult.finish() is always called
+ val timeoutRunnable = Runnable {
+ Log.w(TAG, "Notification action task timed out or failed to initialize ($action, $emailId)")
+ try {
+ pendingResult.finish()
+ } catch (_: Exception) {}
+ }
+ mainHandler.postDelayed(timeoutRunnable, 20_000L)
+
try {
- context.startService(serviceIntent)
+ HeadlessJs.withReadyReactContext(app) { reactContext ->
+ if (!reactContext.hasActiveReactInstance()) {
+ Log.w(TAG, "React context does not have active instance")
+ mainHandler.removeCallbacks(timeoutRunnable)
+ try {
+ pendingResult.finish()
+ } catch (_: Exception) {}
+ return@withReadyReactContext
+ }
+
+ val tasks = HeadlessJsTaskContext.getInstance(reactContext)
+ val taskBundle = Bundle().apply {
+ putString("action", action)
+ putString("emailId", emailId)
+ putString("accountId", accountId)
+ if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
+ // Keep raw extras as well
+ putString(BulwarkFcmModule.EXTRA_ACTION, action)
+ putString(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
+ putString(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
+ if (jmapAccountId != null) putString(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId)
+ }
+
+ val config = HeadlessJsTaskConfig(
+ "BulwarkNotificationAction",
+ Arguments.fromBundle(taskBundle),
+ 15_000L,
+ true // allowExecutionInForeground
+ )
+
+ val listener = object : HeadlessJsTaskEventListener {
+ override fun onHeadlessJsTaskStart(taskId: Int) = Unit
+ override fun onHeadlessJsTaskFinish(taskId: Int) {
+ Log.i(TAG, "Notification action task $taskId finished for action $action")
+ tasks.removeTaskEventListener(this)
+ mainHandler.removeCallbacks(timeoutRunnable)
+ try {
+ pendingResult.finish()
+ } catch (_: Exception) {}
+ }
+ }
+ tasks.addTaskEventListener(listener)
+
+ HeadlessJs.startTask(
+ tasks,
+ config,
+ onStarted = { taskId ->
+ Log.i(TAG, "Started notification action task $taskId for action $action ($emailId)")
+ },
+ onRefused = { e ->
+ Log.w(TAG, "Notification action task refused: ${e.message}")
+ tasks.removeTaskEventListener(listener)
+ mainHandler.removeCallbacks(timeoutRunnable)
+ try {
+ pendingResult.finish()
+ } catch (_: Exception) {}
+ }
+ )
+ }
} catch (e: Exception) {
- Log.w("BulwarkNotifAction", "Failed to start action service: ${e.message}")
+ Log.e(TAG, "Failed to dispatch headless task from action receiver", e)
+ mainHandler.removeCallbacks(timeoutRunnable)
+ try {
+ pendingResult.finish()
+ } catch (_: Exception) {}
}
}
+
+ companion object {
+ private const val TAG = "BulwarkNotifAction"
+ }
}
diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt
deleted file mode 100644
index 644e9a98..00000000
--- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package com.anonymous.bulwarkmobile
-
-import android.content.Intent
-import com.facebook.react.HeadlessJsTaskService
-import com.facebook.react.bridge.Arguments
-import com.facebook.react.jstasks.HeadlessJsTaskConfig
-
-class BulwarkNotificationActionService : HeadlessJsTaskService() {
- override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
- val extras = intent?.extras ?: return null
- return HeadlessJsTaskConfig(
- "BulwarkNotificationAction",
- Arguments.fromBundle(extras),
- 15000L,
- true // allowExecutionInForeground
- )
- }
-}
diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts
index c8137914..dadfd7b8 100644
--- a/src/lib/__tests__/push-background-task.test.ts
+++ b/src/lib/__tests__/push-background-task.test.ts
@@ -252,6 +252,41 @@ describe('pushBackgroundTask notifications', () => {
['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': true } } }, '0'],
]);
});
+
+ it('handles delete action by moving email to trash', async () => {
+ const postCalls: any[] = [];
+ (secureFetch as ReturnType).mockImplementation(async (url: string, opts?: any) => {
+ if (opts?.body) {
+ postCalls.push(JSON.parse(opts.body));
+ }
+ return {
+ ok: true,
+ json: async () => (url.endsWith('/.well-known/jmap')
+ ? {
+ apiUrl: 'https://mail.example.com/jmap/',
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
+ accounts: { 'jmap-primary': {} },
+ }
+ : {
+ methodResponses: [
+ ['Mailbox/get', { list: [{ id: 'trash-box', role: 'trash' }] }, '0'],
+ ['Email/set', { updated: { m1: {} } }, '0'],
+ ],
+ }),
+ };
+ });
+
+ await handleNotificationAction({
+ 'bulwark.notification.action': 'delete',
+ 'bulwark.notification.emailId': 'm1',
+ 'bulwark.notification.accountId': LOCAL,
+ });
+
+ expect(postCalls.length).toBe(2);
+ expect(postCalls[1].methodCalls).toEqual([
+ ['Email/set', { accountId: 'jmap-primary', update: { m1: { mailboxIds: { 'trash-box': true } } } }, '0'],
+ ]);
+ });
});
describe('pushes for device sync (#34)', () => {
diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts
index 537b5058..56aa87ec 100644
--- a/src/lib/push-background-task.ts
+++ b/src/lib/push-background-task.ts
@@ -561,16 +561,36 @@ export interface NotificationActionPayload {
jmapAccountId?: string;
}
+export function parseNotificationActionPayload(data: unknown): NotificationActionPayload | null {
+ if (!data || typeof data !== 'object') return null;
+ const d = data as Record;
+ const action = (d.action ?? d['bulwark.notification.action']) as 'markRead' | 'delete' | undefined;
+ const emailId = (d.emailId ?? d['bulwark.notification.emailId']) as string | undefined;
+ const accountId = (d.accountId ?? d['bulwark.notification.accountId']) as string | undefined;
+ const jmapAccountId = (d.jmapAccountId ?? d['bulwark.notification.jmapAccountId']) as string | undefined;
+
+ if ((action === 'markRead' || action === 'delete') && emailId && accountId) {
+ return { action, emailId, accountId, jmapAccountId };
+ }
+ return null;
+}
+
export async function handleNotificationAction(data: unknown): Promise {
- if (!data || typeof data !== 'object') return;
- const p = data as NotificationActionPayload;
- const { action, emailId, accountId } = p;
- if (!action || !emailId || !accountId) return;
+ const payload = parseNotificationActionPayload(data);
+ if (!payload) {
+ console.warn('[push] invalid notification action payload', data);
+ return;
+ }
+ const { action, emailId, accountId, jmapAccountId } = payload;
+ console.log(`[push] executing notification action "${action}" for email ${emailId}`);
try {
const session = await openDetachedSession(accountId);
- if (!session) return;
- const emailAccountId = p.jmapAccountId ?? session.jmapAccountId;
+ if (!session) {
+ console.warn('[push] failed to open detached session for account', accountId);
+ return;
+ }
+ const emailAccountId = jmapAccountId ?? session.jmapAccountId;
if (action === 'markRead') {
await jmapPost(session, [