Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,11 @@ 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),
...(payload.action === 'reply' ? { action: 'reply' as const } : {}),
});
}

Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
<service android:name=".BulwarkPushTaskService" android:exported="false"/>
<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=".BulwarkUnifiedPushService" android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,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)
Expand All @@ -102,6 +109,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
notificationId: String,
title: String,
body: String,
preview: String?,
largeIcon: Bitmap,
colorHex: String,
emailId: String?,
Expand All @@ -110,10 +118,15 @@ 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 {
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)
Expand All @@ -127,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)
Expand All @@ -142,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())
}
Expand All @@ -157,7 +246,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
Expand All @@ -172,6 +265,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(
Expand Down Expand Up @@ -273,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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
if (notificationId != null) {
manager.cancel(notificationId, notificationId.hashCode())
}

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 {
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.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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,16 @@ 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)
extras.remove(EXTRA_THREAD_ID)
extras.remove(EXTRA_SUBJECT)
extras.remove(EXTRA_ACCOUNT_ID)
extras.remove(EXTRA_JMAP_ACCOUNT_ID)
extras.remove(EXTRA_ACTION)
return payload
}

Expand All @@ -60,13 +62,15 @@ object NotificationTapStore {
val subject: String?,
val accountId: String?,
val jmapAccountId: String?,
val action: String? = null,
) {
fun toMap(): WritableMap = Arguments.createMap().apply {
putString("emailId", emailId)
putString("threadId", threadId)
if (subject != null) putString("subject", subject)
if (accountId != null) putString("accountId", accountId)
if (jmapAccountId != null) putString("jmapAccountId", jmapAccountId)
if (action != null) putString("action", action)
}
}

Expand All @@ -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"
}
6 changes: 6 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading