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, [