From 200404483c4cb0e0320ec589d27b00134cd92c6b Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:01:28 +0200 Subject: [PATCH 1/4] fix(notifications): ensure notification tap always opens target email --- App.tsx | 1 + .../java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt | 9 ++++++++- src/lib/__tests__/viewer-pages.test.ts | 8 ++++++++ src/screens/EmailThreadScreen.tsx | 7 ++++++- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/App.tsx b/App.tsx index 5e152ebf..4622f730 100644 --- a/App.tsx +++ b/App.tsx @@ -119,6 +119,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi navigationRef.navigate('EmailThread', { emailId: payload.emailId, threadId: payload.threadId, + emailIds: [payload.emailId], subject: payload.subject, // A group mailbox's message lives under another JMAP account (#839). jmapAccountId: notificationTapJmapAccountId(payload), diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 6922adbe..c69d042c 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -4,6 +4,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.net.Uri import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas @@ -114,6 +115,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-notification://$notificationId") if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) @@ -157,7 +159,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) it.groupKey?.endsWith(groupKey) == true && (it.notification.flags and android.app.Notification.FLAG_GROUP_SUMMARY) == 0 } - val count = maxOf(children.size, 1) + if (children.size < 2) { + manager.cancel(groupKey, groupKey.hashCode()) + return + } + val count = children.size val inbox = NotificationCompat.InboxStyle() children.sortedByDescending { it.postTime }.take(5).forEach { sbn -> val extras = sbn.notification.extras @@ -172,6 +178,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val intent = Intent(ctx, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-group-summary://$groupKey") if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) } val pending = PendingIntent.getActivity( diff --git a/src/lib/__tests__/viewer-pages.test.ts b/src/lib/__tests__/viewer-pages.test.ts index bad52442..1120ad41 100644 --- a/src/lib/__tests__/viewer-pages.test.ts +++ b/src/lib/__tests__/viewer-pages.test.ts @@ -41,6 +41,14 @@ describe('viewerPages', () => { expect(pages).toEqual([{ id: 'u1', threadId: 't-u1' }, LIST[3]]); }); + it('is a single page when opened with a single emailId from notification tap', () => { + const pages = viewerPages({ + emailId: 'q2', threadId: 'tq', emailIds: ['q2'], + list: LIST, threading: true, + }); + expect(pages).toEqual([LIST[1]]); + }); + it('is a single page for a message the list does not hold', () => { const pages = viewerPages({ emailId: 'm9', threadId: 't9', list: LIST, threading: true, diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index aad8f3f9..851d965b 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -735,12 +735,17 @@ function EmailViewer({ route, navigation }: Props) { initialScrollIndex={initialIndexRef.current} getItemLayout={(_, index) => ({ length: windowWidth, offset: windowWidth * index, index })} windowSize={3} - initialNumToRender={1} + initialNumToRender={Math.max(initialIndexRef.current + 1, 1)} maxToRenderPerBatch={2} removeClippedSubviews scrollEnabled={!pagerLocked} onScrollBeginDrag={releaseNeighbours} onMomentumScrollEnd={onMomentumEnd} + onScrollToIndexFailed={(info) => { + setTimeout(() => { + listRef.current?.scrollToOffset({ offset: info.offset, animated: false }); + }, 50); + }} renderItem={({ item, index }) => ( Date: Fri, 9 Oct 2026 09:05:27 +0200 Subject: [PATCH 2/4] fix(viewer): use info.index * windowWidth for onScrollToIndexFailed offset --- src/screens/EmailThreadScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 851d965b..03aa7996 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -743,7 +743,7 @@ function EmailViewer({ route, navigation }: Props) { onMomentumScrollEnd={onMomentumEnd} onScrollToIndexFailed={(info) => { setTimeout(() => { - listRef.current?.scrollToOffset({ offset: info.offset, animated: false }); + listRef.current?.scrollToOffset({ offset: info.index * windowWidth, animated: false }); }, 50); }} renderItem={({ item, index }) => ( From 90fe43882a6ecd73912f90d5fd49e0ca97b63952 Mon Sep 17 00:00:00 2001 From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:16:53 +0200 Subject: [PATCH 3/4] feat(notifications): add email preview snippet and quick actions (mark read, delete, reply) --- App.tsx | 1 + android/app/src/main/AndroidManifest.xml | 2 + .../bulwarkmobile/BulwarkFcmModule.kt | 98 ++++++++++++++++++- .../BulwarkNotificationActionReceiver.kt | 34 +++++++ .../BulwarkNotificationActionService.kt | 18 ++++ .../bulwarkmobile/NotificationTapStore.kt | 7 +- index.ts | 6 ++ .../__tests__/push-background-task.test.ts | 67 +++++++++++++ src/lib/push-background-task.ts | 84 ++++++++++++++++ src/lib/push-notifications.ts | 2 + 10 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt diff --git a/App.tsx b/App.tsx index 4622f730..a275df46 100644 --- a/App.tsx +++ b/App.tsx @@ -123,6 +123,7 @@ async function navigateToNotificationTap(payload: NotificationTapPayload): Promi subject: payload.subject, // A group mailbox's message lives under another JMAP account (#839). jmapAccountId: notificationTapJmapAccountId(payload), + ...(payload.action === 'reply' ? { action: 'reply' as const } : {}), }); } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8106099a..68ab6fab 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -76,6 +76,8 @@ + + diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index c69d042c..105c4442 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -73,14 +73,20 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) val groupTitle = options.takeIf { it.hasKey("groupTitle") }?.getString("groupTitle") ?: accountId ?: "Bulwark Mail" + val preview = options.takeIf { it.hasKey("preview") }?.getString("preview") + val markReadLabel = options.takeIf { it.hasKey("markReadLabel") }?.getString("markReadLabel") ?: "Mark as read" + val deleteLabel = options.takeIf { it.hasKey("deleteLabel") }?.getString("deleteLabel") ?: "Delete" + val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: "Reply" + // Bitmap fetch + draw off the bridge thread so the caller doesn't // block waiting for the favicon request. thread(name = "bulwark-notification") { val largeIcon = iconUrl?.let { fetchBitmap(it) } ?: makeLetterAvatar(initials, bgColorHex) postNotification( - notificationId, title, body, largeIcon, bgColorHex, + notificationId, title, body, preview, largeIcon, bgColorHex, emailId, threadId, subject, accountId, jmapAccountId, groupKey, + groupTitle, markReadLabel, deleteLabel, replyLabel, ) if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId) promise.resolve(null) @@ -103,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) notificationId: String, title: String, body: String, + preview: String?, largeIcon: Bitmap, colorHex: String, emailId: String?, @@ -111,6 +118,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) accountId: String?, jmapAccountId: String?, groupKey: String?, + groupTitle: String?, + markReadLabel: String, + deleteLabel: String, + replyLabel: String, ) { val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { @@ -129,12 +140,25 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + val bigText = buildString { + if (!subject.isNullOrBlank()) append(subject) + if (!preview.isNullOrBlank()) { + if (isNotEmpty()) append("\n") + append(preview) + } + } + val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setLargeIcon(largeIcon) .setContentTitle(title) - .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentText(subject ?: preview ?: body) + .setStyle( + NotificationCompat.BigTextStyle() + .bigText(if (bigText.isNotBlank()) bigText else body) + .setBigContentTitle(title) + .setSummaryText(groupTitle) + ) .setColor(parseColor(colorHex, fallback = Color.parseColor("#2563eb"))) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -144,6 +168,69 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) } + if (emailId != null && accountId != null) { + // Action 1: Mark as read + val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { + action = ACTION_MARK_READ + data = Uri.parse("bulwark-action-read://$notificationId") + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_ACTION, "markRead") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + val markReadPending = PendingIntent.getBroadcast( + ctx, + (notificationId + ":read").hashCode(), + markReadIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, markReadLabel, markReadPending).build() + ) + + // Action 2: Delete + val deleteIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { + action = ACTION_DELETE + data = Uri.parse("bulwark-action-delete://$notificationId") + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_ACTION, "delete") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + } + val deletePending = PendingIntent.getBroadcast( + ctx, + (notificationId + ":delete").hashCode(), + deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, deleteLabel, deletePending).build() + ) + + // Action 3: Reply + val replyIntent = Intent(ctx, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + data = Uri.parse("bulwark-reply://$notificationId") + putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId) + if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId) + if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + if (jmapAccountId != null) putExtra(NotificationTapStore.EXTRA_JMAP_ACCOUNT_ID, jmapAccountId) + putExtra(EXTRA_ACTION, "reply") + } + val replyPending = PendingIntent.getActivity( + ctx, + (notificationId + ":reply").hashCode(), + replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, replyLabel, replyPending).build() + ) + } + val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(notificationId, notificationId.hashCode(), builder.build()) } @@ -280,6 +367,11 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) @Volatile private var currentInstance: BulwarkFcmModule? = null + const val EXTRA_ACTION = "bulwark.notification.action" + const val EXTRA_NOTIFICATION_ID = "bulwark.notification.notificationId" + const val ACTION_MARK_READ = "com.anonymous.bulwarkmobile.ACTION_MARK_READ" + const val ACTION_DELETE = "com.anonymous.bulwarkmobile.ACTION_DELETE" + fun emit(eventName: String, params: WritableMap?) { val module = currentInstance ?: return val ctx = module.reactApplicationContext diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt new file mode 100644 index 00000000..94bdca1c --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt @@ -0,0 +1,34 @@ +package com.anonymous.bulwarkmobile + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +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) + + // Dismiss the notification immediately from the tray + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (notificationId != null) { + 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) } + } + + try { + context.startService(serviceIntent) + } catch (e: Exception) { + Log.w("BulwarkNotifAction", "Failed to start action service: ${e.message}") + } + } +} diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt new file mode 100644 index 00000000..644e9a98 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionService.kt @@ -0,0 +1,18 @@ +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/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt index baed3c73..1ebebf61 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt @@ -43,7 +43,8 @@ object NotificationTapStore { // The JMAP account a group/shared mailbox's message lives in - a JMAP // id like the email id. Optional: older notifications don't carry it. val jmapAccountId = extras.getString(EXTRA_JMAP_ACCOUNT_ID)?.takeIf { ID_PATTERN.matches(it) } - val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId) + val action = extras.getString(EXTRA_ACTION)?.takeIf { it == "reply" } + val payload = TapPayload(emailId, threadId, subject, accountId, jmapAccountId, action) pending = payload // Clear so a subsequent activity lifecycle event doesn't replay this. extras.remove(EXTRA_EMAIL_ID) @@ -51,6 +52,7 @@ object NotificationTapStore { extras.remove(EXTRA_SUBJECT) extras.remove(EXTRA_ACCOUNT_ID) extras.remove(EXTRA_JMAP_ACCOUNT_ID) + extras.remove(EXTRA_ACTION) return payload } @@ -60,6 +62,7 @@ object NotificationTapStore { val subject: String?, val accountId: String?, val jmapAccountId: String?, + val action: String? = null, ) { fun toMap(): WritableMap = Arguments.createMap().apply { putString("emailId", emailId) @@ -67,6 +70,7 @@ object NotificationTapStore { if (subject != null) putString("subject", subject) if (accountId != null) putString("accountId", accountId) if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId) + if (action != null) putString("action", action) } } @@ -75,4 +79,5 @@ object NotificationTapStore { const val EXTRA_SUBJECT = "bulwark.notification.subject" const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId" const val EXTRA_JMAP_ACCOUNT_ID = "bulwark.notification.jmapAccountId" + const val EXTRA_ACTION = "bulwark.notification.action" } diff --git a/index.ts b/index.ts index 7db96696..965b95a4 100644 --- a/index.ts +++ b/index.ts @@ -16,6 +16,12 @@ AppRegistry.registerHeadlessTask('BulwarkPushTask', () => async (data: Parameter await Promise.allSettled([pushBackgroundTask(data), refreshWidgetsInBackground()]); }); +// Notification quick actions (Mark as read, Delete): run without launching the app UI. +AppRegistry.registerHeadlessTask('BulwarkNotificationAction', () => async (data: unknown) => { + const { handleNotificationAction } = require('./src/lib/push-background-task') as typeof import('./src/lib/push-background-task'); + await Promise.allSettled([handleNotificationAction(data), refreshWidgetsInBackground()]); +}); + // Device sync (Android, #34): the contacts and calendar sync adapters run the // sync engine through this task, headless or next to the UI. The engine is // required on first use so an app start does not load it. diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index b8298866..c8137914 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -16,6 +16,7 @@ import { jmapClient } from '../../api/jmap-client'; import { secureFetch } from '../client-cert'; import { carriesNoMail, + handleNotificationAction, matchAccountsForPush, parseRelayPushData, pushBackgroundTask, @@ -185,6 +186,72 @@ describe('pushBackgroundTask notifications', () => { expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ emailId: 'm1', body: '(Kein Betreff)' })); }); + + it('includes preview and action labels on notification', async () => { + (secureFetch as ReturnType).mockImplementation(async (url: string) => ({ + 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: [[ + 'Email/get', + { list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Hello', preview: 'World snippet', from: [{ name: 'Bob', email: 'bob@example.com' }] }] }, + '0', + ]], + }), + })); + + await pushBackgroundTask({ + kind: 'jmap-email-push', + accountLabel: 'alice', + accountId: 'jmap-primary', + emailIds: JSON.stringify(['m1']), + }); + + expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({ + emailId: 'm1', + title: 'Bob', + body: 'Hello', + preview: 'World snippet', + markReadLabel: expect.any(String), + deleteLabel: expect.any(String), + replyLabel: expect.any(String), + })); + }); + + it('handles markRead action via detached JMAP', 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: [['Email/set', { updated: { m1: {} } }, '0']] }), + }; + }); + + await handleNotificationAction({ + action: 'markRead', + emailId: 'm1', + accountId: LOCAL, + }); + + expect(postCalls.length).toBe(1); + expect(postCalls[0].methodCalls).toEqual([ + ['Email/set', { accountId: 'jmap-primary', update: { m1: { 'keywords/$seen': 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 1f513bc9..537b5058 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -12,6 +12,7 @@ import { getFaviconDomain, getFaviconUrl, } from './avatar-utils'; +import { singleLine } from './single-line'; import { lastNotifiedKey, migrateLegacyPushKeys, @@ -87,6 +88,7 @@ interface ShowNotificationOptions { notificationId: string; title: string; body: string; + preview?: string; initials: string; bgColorHex: string; iconUrl?: string; @@ -101,6 +103,9 @@ interface ShowNotificationOptions { // several deliveries under a "+N more" summary instead of stacking them. groupKey: string; groupTitle: string; + markReadLabel?: string; + deleteLabel?: string; + replyLabel?: string; } interface BulwarkFcmNative { @@ -505,10 +510,16 @@ async function processAccountForPush(accountId: string, payload: RelayPushData): const faviconDomain = favicons ? getFaviconDomain(address) : null; const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; + const preview = email.preview ? singleLine(email.preview) : undefined; + const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read'); + const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete'); + const replyLabel = translate(locale, 'email_viewer.reply', 'Reply'); + await native.showNotification({ notificationId: `mail:${email.id}`, title, body, + preview, initials, bgColorHex, iconUrl, @@ -519,6 +530,9 @@ async function processAccountForPush(accountId: string, payload: RelayPushData): jmapAccountId: emailAccountId, groupKey, groupTitle, + markReadLabel, + deleteLabel, + replyLabel, }); } @@ -539,3 +553,73 @@ function hslToHex(hsl: string): string { }; return `#${component(0)}${component(8)}${component(4)}`; } + +export interface NotificationActionPayload { + action: 'markRead' | 'delete'; + emailId: string; + accountId: string; + jmapAccountId?: string; +} + +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; + + try { + const session = await openDetachedSession(accountId); + if (!session) return; + const emailAccountId = p.jmapAccountId ?? session.jmapAccountId; + + if (action === 'markRead') { + await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { 'keywords/$seen': true }, + }, + }, + '0', + ], + ]); + } else if (action === 'delete') { + const mbRes = await jmapPost(session, [ + ['Mailbox/get', { accountId: emailAccountId, properties: ['id', 'role'] }, '0'], + ]); + const [, mbBody] = mbRes[0] ?? []; + const mailboxes = ((mbBody?.list as Mailbox[]) ?? []); + const trash = mailboxes.find((m) => m.role === 'trash'); + + if (trash) { + await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + update: { + [emailId]: { mailboxIds: { [trash.id]: true } }, + }, + }, + '0', + ], + ]); + } else { + await jmapPost(session, [ + [ + 'Email/set', + { + accountId: emailAccountId, + destroy: [emailId], + }, + '0', + ], + ]); + } + } + } catch (err) { + console.warn('[push] notification action failed', action, emailId, err); + } +} diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 97c48971..5ea47bd0 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1443,6 +1443,8 @@ export interface NotificationTapPayload { // The JMAP account the message lives in: a group or shared mailbox's account // when it isn't the user's own. Absent on older notifications. jmapAccountId?: string; + // Notification action tapped (e.g. 'reply' to jump straight to Compose) + action?: 'reply'; } /** 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 4/4] fix(notifications): execute background actions directly via HeadlessJs without service --- android/app/src/main/AndroidManifest.xml | 1 - .../BulwarkNotificationActionReceiver.kt | 102 ++++++++++++++++-- .../BulwarkNotificationActionService.kt | 18 ---- .../__tests__/push-background-task.test.ts | 35 ++++++ src/lib/push-background-task.ts | 32 ++++-- 5 files changed, 157 insertions(+), 31 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 + 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()) - // Dispatch background headless task to perform JMAP operations - val serviceIntent = Intent(context, BulwarkNotificationActionService::class.java).apply { - intent.extras?.let { putExtras(it) } + // 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, [