From 9669e07e95cfbbf4817381be6388a4bd02bca6e6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:25:21 +0100 Subject: [PATCH] fix: let a notification's delete skip the unlock when the mail goes to trash #85 --- .../bulwarkmobile/BulwarkFcmModule.kt | 11 +++-- .../notification-delete-unlock.test.ts | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/notification-delete-unlock.test.ts 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 2bcc8ff7..15eabd80 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -77,6 +77,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) 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" + // Delete moves the mail to Trash where there is one: no unlock needed + // for that (#85). Without a Trash it destroys the mail, so it asks. + // Unlock stays the default for a caller that doesn't say. + val deleteNeedsUnlock = !options.hasKey("deleteNeedsUnlock") || options.getBoolean("deleteNeedsUnlock") // Bitmap fetch + draw off the bridge thread so the caller doesn't // block waiting for the favicon request. @@ -86,7 +90,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) postNotification( notificationId, title, body, preview, largeIcon, bgColorHex, emailId, threadId, subject, accountId, jmapAccountId, groupKey, - groupTitle, markReadLabel, deleteLabel, replyLabel, + groupTitle, markReadLabel, deleteLabel, replyLabel, deleteNeedsUnlock, ) if (groupKey != null) postGroupSummary(groupKey, groupTitle, bgColorHex, accountId) promise.resolve(null) @@ -122,6 +126,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) markReadLabel: String, deleteLabel: String, replyLabel: String, + deleteNeedsUnlock: Boolean, ) { val ctx = reactApplicationContext val intent = Intent(ctx, MainActivity::class.java).apply { @@ -209,8 +214,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) ) builder.addAction( NotificationCompat.Action.Builder(0, deleteLabel, deletePending) - // Android 12+: unlock first, so a locked phone can't delete mail. - .setAuthenticationRequired(true) + // Android 12+: unlock first, so a locked phone can't destroy mail. + .setAuthenticationRequired(deleteNeedsUnlock) .build() ) diff --git a/src/lib/__tests__/notification-delete-unlock.test.ts b/src/lib/__tests__/notification-delete-unlock.test.ts new file mode 100644 index 00000000..23a4ce44 --- /dev/null +++ b/src/lib/__tests__/notification-delete-unlock.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// #85: a notification's Delete asked to unlock the phone even where it only +// moves the mail to Trash. The push task tells the native module +// (deleteNeedsUnlock: !hasTrash); the module has to use it on the action. + +const MODULE = path.resolve( + __dirname, + '../../../android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt', +); +const source = fs.readFileSync(MODULE, 'utf8'); + +function postNotificationCalls(src: string): string[] { + const out: string[] = []; + const re = /(? { + it('reads deleteNeedsUnlock and keeps unlock as the default', () => { + expect(source).toMatch( + /val deleteNeedsUnlock = !options\.hasKey\("deleteNeedsUnlock"\) \|\| options\.getBoolean\("deleteNeedsUnlock"\)/, + ); + }); + + it('asks to unlock only when told to', () => { + expect(source).toMatch(/\.setAuthenticationRequired\(deleteNeedsUnlock\)/); + expect(source).not.toMatch(/\.setAuthenticationRequired\(true\)/); + expect(source).toMatch(/private fun postNotification\([^)]*deleteNeedsUnlock: Boolean/); + }); + + it('passes it on every postNotification call', () => { + const calls = postNotificationCalls(source); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) expect(call).toMatch(/\bdeleteNeedsUnlock\b/); + }); +});