fix(notifications): execute background actions directly via HeadlessJs without service

This commit is contained in:
arnauda-gh
2026-10-09 12:41:53 +02:00
parent 90fe43882a
commit be15af5d4c
5 changed files with 158 additions and 32 deletions
-1
View File
@@ -77,7 +77,6 @@
<service android:name=".BulwarkWidgetRefreshService" android:exported="false"/>
<service android:name=".BulwarkPushJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="false"/>
<receiver android:name=".BulwarkNotificationActionReceiver" android:exported="false"/>
<service android:name=".BulwarkNotificationActionService" android:exported="false"/>
<service android:name=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
@@ -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"
}
}
@@ -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
)
}
}
@@ -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<typeof vi.fn>).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)', () => {
+26 -6
View File
@@ -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<string, unknown>;
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<void> {
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, [