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 f9867123..1de8fc58 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -80,6 +80,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) ?: reactApplicationContext.getString(R.string.notification_action_delete) val replyLabel = options.takeIf { it.hasKey("replyLabel") }?.getString("replyLabel") ?: reactApplicationContext.getString(R.string.notification_action_reply) + val copyCodeLabel = options.takeIf { it.hasKey("copyCodeLabel") }?.getString("copyCodeLabel") + ?: reactApplicationContext.getString(R.string.notification_action_copy_code) + val codeCopiedLabel = options.takeIf { it.hasKey("codeCopiedLabel") }?.getString("codeCopiedLabel") + ?: reactApplicationContext.getString(R.string.notification_code_copied) // 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. @@ -95,7 +99,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) postNotification( reactApplicationContext, notificationId, title, body, preview, largeIcon, bgColorHex, emailId, threadId, subject, accountId, jmapAccountId, groupKey, - groupTitle, markReadLabel, deleteLabel, replyLabel, deleteNeedsUnlock, + groupTitle, markReadLabel, deleteLabel, replyLabel, copyCodeLabel, codeCopiedLabel, + deleteNeedsUnlock, ) if (groupKey != null) { postGroupSummary(reactApplicationContext, groupKey, groupTitle, bgColorHex, accountId, withoutContent) @@ -153,6 +158,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) markReadLabel: String, deleteLabel: String, replyLabel: String, + copyCodeLabel: String, + codeCopiedLabel: String, deleteNeedsUnlock: Boolean, ) { val intent = Intent(ctx, MainActivity::class.java).apply { @@ -204,6 +211,33 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) } if (emailId != null && accountId != null) { + // A sign-in code in the mail: Copy comes first and takes Reply's + // place, as the tray shows three actions and nobody answers a + // code mail. The label shows the code, so a wrong pick is seen + // before it is pasted. A fault in the search must not cost the + // notification itself. + val code = runCatching { OtpCode.find(subject, preview) }.getOrNull() + if (code != null) { + val copyIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { + action = ACTION_COPY_CODE + data = Uri.parse("bulwark-action-copy-code://$notificationId") + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_ACTION, "copyCode") + putExtra(EXTRA_CODE, code) + putExtra(EXTRA_CODE_COPIED_LABEL, codeCopiedLabel) + putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId) + } + val copyPending = PendingIntent.getBroadcast( + ctx, + (notificationId + ":copyCode").hashCode(), + copyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + builder.addAction( + NotificationCompat.Action.Builder(0, copyCodeLabel.replace("{code}", code), copyPending).build() + ) + } + // Action 1: Mark as read val markReadIntent = Intent(ctx, BulwarkNotificationActionReceiver::class.java).apply { action = ACTION_MARK_READ @@ -247,26 +281,28 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) .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") + // Action 3: Reply, unless Copy took its place + if (code == null) { + 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 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 @@ -418,6 +454,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) 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" + const val ACTION_COPY_CODE = "com.anonymous.bulwarkmobile.ACTION_COPY_CODE" + const val EXTRA_CODE = "bulwark.notification.code" + const val EXTRA_CODE_COPIED_LABEL = "bulwark.notification.codeCopiedLabel" fun emit(eventName: String, params: WritableMap?) { val module = currentInstance ?: return @@ -445,6 +484,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) markReadLabel: String = context.getString(R.string.notification_action_mark_read), deleteLabel: String = context.getString(R.string.notification_action_delete), replyLabel: String = context.getString(R.string.notification_action_reply), + copyCodeLabel: String = context.getString(R.string.notification_action_copy_code), + codeCopiedLabel: String = context.getString(R.string.notification_code_copied), deleteNeedsUnlock: Boolean = true, ) { val largeIcon = iconUrl?.let { fetchBitmap(it) } @@ -452,7 +493,8 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) postNotification( context, notificationId, title, body, preview, largeIcon, bgColorHex, emailId, threadId, subject, accountId, jmapAccountId, groupKey, - groupTitle, markReadLabel, deleteLabel, replyLabel, deleteNeedsUnlock, + groupTitle, markReadLabel, deleteLabel, replyLabel, copyCodeLabel, codeCopiedLabel, + deleteNeedsUnlock, ) if (groupKey != null) { postGroupSummary(context, groupKey, groupTitle ?: "Bulwark Mail", bgColorHex, accountId) diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNativePushFetcher.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNativePushFetcher.kt index 2eccff17..60cf2658 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNativePushFetcher.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNativePushFetcher.kt @@ -151,6 +151,8 @@ object BulwarkNativePushFetcher { val markRead: String, val delete: String, val reply: String, + val copyCode: String, + val codeCopied: String, ) sealed class JmapFetchResult { @@ -1047,6 +1049,10 @@ object BulwarkNativePushFetcher { ?: context.getString(R.string.notification_action_delete), reply = stringsObj?.optString("reply")?.takeIf { it.isNotBlank() } ?: context.getString(R.string.notification_action_reply), + copyCode = stringsObj?.optString("copyCode")?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.notification_action_copy_code), + codeCopied = stringsObj?.optString("codeCopied")?.takeIf { it.isNotBlank() } + ?: context.getString(R.string.notification_code_copied), ) } @@ -1187,6 +1193,8 @@ object BulwarkNativePushFetcher { markReadLabel = strings.markRead, deleteLabel = strings.delete, replyLabel = strings.reply, + copyCodeLabel = strings.copyCode, + codeCopiedLabel = strings.codeCopied, deleteNeedsUnlock = !result.hasTrash, ) } 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 71c6e228..17e8d9f6 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkNotificationActionReceiver.kt @@ -3,12 +3,17 @@ package com.anonymous.bulwarkmobile import android.app.Application import android.app.NotificationManager import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.PersistableBundle import android.util.Log +import android.widget.Toast import com.facebook.react.bridge.Arguments import com.facebook.react.jstasks.HeadlessJsTaskConfig import com.facebook.react.jstasks.HeadlessJsTaskContext @@ -33,6 +38,11 @@ class BulwarkNotificationActionReceiver : BroadcastReceiver() { BulwarkFcmModule.refreshGroupSummaryAfterCancel(context, accountId) } + if (action == "copyCode") { + copyCode(context, intent) + return + } + if (emailId.isNullOrBlank() || action.isNullOrBlank() || accountId.isNullOrBlank()) { Log.w(TAG, "Missing parameters for notification action: action=$action, emailId=$emailId, accountId=$accountId") return @@ -150,6 +160,29 @@ class BulwarkNotificationActionReceiver : BroadcastReceiver() { } } + // Copies the sign-in code the notification offered (OtpCode), marked + // sensitive so the clipboard preview of Android 13+ hides it. Older + // Android confirms nothing itself: a toast says it was copied. + private fun copyCode(context: Context, intent: Intent) { + val code = intent.getStringExtra(BulwarkFcmModule.EXTRA_CODE) + if (code.isNullOrBlank()) return + // The label is the app's name: a clipboard viewer may show the label + // even where it hides the sensitive text. + val clip = ClipData.newPlainText(context.getString(R.string.app_name), code).apply { + description.extras = PersistableBundle().apply { + // ClipDescription.EXTRA_IS_SENSITIVE, a constant of API 33. + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + val label = intent.getStringExtra(BulwarkFcmModule.EXTRA_CODE_COPIED_LABEL) + ?: context.getString(R.string.notification_code_copied) + Toast.makeText(context, label, Toast.LENGTH_SHORT).show() + } + } + companion object { private const val TAG = "BulwarkNotifAction" } diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/OtpCode.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/OtpCode.kt new file mode 100644 index 00000000..6796d790 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/OtpCode.kt @@ -0,0 +1,157 @@ +package com.anonymous.bulwarkmobile + +import java.text.Normalizer + +/** + * Finds a sign-in code (two-factor, verification, one-time password) in a + * new mail's subject and preview, for the notification's Copy action. A + * number only counts close to a word that names a code, so order numbers, + * prices, dates and phone numbers in other mail offer nothing. Offering a + * wrong number is worse than offering none: when in doubt, it offers none. + */ +object OtpCode { + // Words that name a code, in the app's languages. The short Latin ones + // need word edges ("pin" is in "shipping"); "code" and "kod" may end a + // longer word ("Bestätigungscode", "engangskode", "verifieringskod"). + private val KEYWORD = Regex( + "\\b(?:otp|pin|tan|2fa|mfa)\\b|code|passcode|verification|one[- ]time|two[- ]factor|" + + "código|codigo|codice|\\bcod(?:ul)?\\b|kod(?:e|en|s|u|unuz)?\\b|kód|koodi|код|κωδικ|" + + "コード|認証|验证码|驗證碼|인증|코드|รหัส|رمز|קוד", + RegexOption.IGNORE_CASE, + ) + + // A bare English "code" is also prose ("a code formatter"). Text whose + // only code word is that must say what the code is for somewhere. + private val SIGN_IN = Regex( + "v[eé]rif|one[- ]time|\\b(?:otp|2fa|mfa|pin)\\b|two[- ]factor|passcode|sign[- ]?in|log[- ]?in|inlog|" + + "anmeld|connexion|confirm|authenticat|password|reset|security code|access code|your code|this code|" + + "code is|code:", + RegexOption.IGNORE_CASE, + ) + + // A code word that names something else when one of these comes right + // before it: "promo code", "gift card code", "postal code", "error + // code", or glued on: "barcode", "Unicode", "decode", "Aktionscode". + private val NOT_A_SIGN_IN_CODE = Regex( + "(?:\\b(?:promo|promotional|discount|coupon|voucher|gift|card|referral|invite|invitation|zip|postal|" + + "post|area|country|error|exit|decline|status|source|tracking|order|booking|qr|bar|dress|" + + "rabatt|gutschein|aktions)[\\s-]*|" + + "(?= 2 && code.any { it.isLetter() } && !WORD_AND_NUMBER.matches(code) + } + + private fun isBareCode(text: String, keyword: MatchResult): Boolean = + keyword.value.equals("code", ignoreCase = true) && + (keyword.range.first == 0 || !text[keyword.range.first - 1].isLetter()) + + private fun isYear(code: String): Boolean = code.length == 4 && code.all { it.isDigit() } && code.toInt() in 1900..2099 + + private fun yearFollows(text: String, keyword: IntRange, code: IntRange): Boolean = + keyword.last < code.first && YEAR_AFTER_WORD.matches(text.substring(keyword.last + 1, code.first)) + + // The characters between the word and the code, plus the penalty for a + // code before the word. Past MAX_GAP, or before the word anywhere but + // at the start of a line, it does not count at all. + private fun score(keyword: IntRange, code: IntRange, leadsLine: Boolean): Int = when { + keyword.last < code.first -> (code.first - keyword.last - 1).takeIf { it <= MAX_GAP } + code.last < keyword.first -> + if (leadsLine) (keyword.first - code.last - 1).takeIf { it <= MAX_GAP }?.plus(BEFORE_PENALTY) else null + else -> 0 + } ?: Int.MAX_VALUE +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 878881c8..b2d5cadc 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -7,6 +7,9 @@ Mark as read Delete Reply + + Copy {code} + Copied to clipboard (No Subject) New email \ No newline at end of file diff --git a/android/app/src/test/java/com/anonymous/bulwarkmobile/OtpCodeTest.kt b/android/app/src/test/java/com/anonymous/bulwarkmobile/OtpCodeTest.kt new file mode 100644 index 00000000..72649681 --- /dev/null +++ b/android/app/src/test/java/com/anonymous/bulwarkmobile/OtpCodeTest.kt @@ -0,0 +1,172 @@ +package com.anonymous.bulwarkmobile + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +// The code a new mail's notification offers to copy (OtpCode). The texts are +// shaped like real sign-in mail: the code is in the subject, in the preview, +// or both, and other numbers stand nearby. +class OtpCodeTest { + @Test + fun `finds the code in the subject`() { + assertEquals("482913", OtpCode.find("482913 is your verification code", null)) + assertEquals("482913", OtpCode.find("Your sign-in code: 482913", "Enter it on the page.")) + } + + @Test + fun `finds the code in the preview`() { + assertEquals( + "731045", + OtpCode.find( + "Your Example account security code", + "Please use the following security code for the Example account wy***@example.com. Security code: 731045", + ), + ) + assertEquals("9055", OtpCode.find("Sign in to Example", "Your one-time PIN is 9055. It expires in 10 minutes.")) + } + + @Test + fun `takes a code split in two groups whole`() { + assertEquals("123456", OtpCode.find("Verification code", "Your code is 123 456")) + assertEquals("12345678", OtpCode.find("Verification code", "Your code is 1234-5678.")) + } + + @Test + fun `finds a code of capitals and digits`() { + assertEquals("K7Q2X9", OtpCode.find("Confirm your email", "Your confirmation code is K7Q2X9")) + assertEquals("F4K2N", OtpCode.find("Your Steam account: Access from new computer", "Login Code F4K2N")) + assertEquals("Q7K2M9", OtpCode.find("Slack confirmation code: Q7K-2M9", null)) + } + + @Test + fun `finds a code right after a colon`() { + assertEquals("731045", OtpCode.find("Sign in", "Your code:731045")) + assertEquals("308451", OtpCode.find("验证码:308451", null)) + } + + @Test + fun `takes a year-like code only right after the code word`() { + assertEquals("2048", OtpCode.find("Your PIN 2048", null)) + assertEquals("1987", OtpCode.find("Your Example code is 1987", null)) + assertEquals("4821", OtpCode.find("Your temporary PIN number 4821", null)) + } + + @Test + fun `takes full-width digits as plain ones`() { + assertEquals("482913", OtpCode.find("確認コード:482913", null)) + } + + @Test + fun `finds the code in other languages`() { + assertEquals("583920", OtpCode.find("Ihr Bestätigungscode", "Ihr Code lautet 583920.")) + assertEquals("482913", OtpCode.find("Votre code de vérification", "Votre code de vérification est : 482913.")) + assertEquals("204816", OtpCode.find("Tu código de verificación es 204816", null)) + assertEquals("661204", OtpCode.find("Код подтверждения: 661204", null)) + assertEquals("308451", OtpCode.find("您的验证码是308451", null)) + assertEquals("520731", OtpCode.find("인증 코드: 520731", null)) + assertEquals("447120", OtpCode.find("รหัส OTP ของคุณคือ 447120", null)) + assertEquals("482913", OtpCode.find("Din engangskode er 482913", null)) + assertEquals("482913", OtpCode.find("Din verifieringskod: 482913", null)) + assertEquals("482913", OtpCode.find("Doğrulama kodunuz: 482913", null)) + assertEquals("482913", OtpCode.find("Codul tău de verificare este 482913", null)) + } + + @Test + fun `takes the code after the word over the number before it`() { + assertEquals("482913", OtpCode.find("Account 40021987 verification code: 482913", null)) + assertEquals("482913", OtpCode.find("Ref 77812 code: 482913", null)) + } + + @Test + fun `offers nothing for a meeting invite`() { + assertNull(OtpCode.find("Zoom meeting invitation", "Meeting ID: 812 3456 7890 Passcode: 448812")) + assertNull(OtpCode.find("Weekly sync", "Join by phone: PIN: 482 913 117#, dial-in +1 555 0100")) + } + + @Test + fun `takes a code before its word only where it leads a line`() { + assertNull(OtpCode.find("Issue 48213 updated: Login code no longer sent", null)) + assertNull(OtpCode.find("Your shipment 7712 3456 9012 is on its way", "Delivery code needed at the door")) + } + + @Test + fun `offers nothing for a phone number near a code word`() { + assertNull(OtpCode.find("Reset your password", "Use the link below. No code? Call 1 888 555 0199")) + } + + @Test + fun `takes the number nearest the code word`() { + assertEquals( + "904417", + OtpCode.find( + "Order 55512345 shipped", + "Someone tried to sign in. If it was you, your verification code is 904417", + ), + ) + } + + @Test + fun `offers nothing without a word that names a code`() { + assertNull(OtpCode.find("Your order 482913 has shipped", "Track it at the link below.")) + assertNull(OtpCode.find("Invoice 2026-0042", "Amount due: 1234")) + assertNull(OtpCode.find("Lunch at 1230?", "Call me at 5551234")) + } + + @Test + fun `offers nothing for codes that are not for signing in`() { + assertNull(OtpCode.find("20% off this week", "Use promo code SAVE2026 at checkout")) + assertNull(OtpCode.find("Delivery update", "We could not find postal code 90210")) + assertNull(OtpCode.find("Build failed", "The server answered with error code 5003")) + assertNull(OtpCode.find("Fall sale", "Use code FALL25 for 25% off")) + assertNull(OtpCode.find("A gift for you", "Your gift card code: 4829 1739")) + assertNull(OtpCode.find("Your trip to Lisbon", "Your flight confirmation code is HX4K92")) + assertNull(OtpCode.find("Scan to pay", "Show this barcode 48291739 at the till")) + assertNull(OtpCode.find("Black Friday early access", "Your early access code: BF2026")) + assertNull(OtpCode.find("Herbstaktion", "Dein Aktionscode: HERBST25")) + assertNull(OtpCode.find("Payment failed", "The bank answered with decline code 2051")) + assertNull(OtpCode.find("Porter booking", "Confirmation code: 4X7K2P. Flight PD 2241 departs at 09:10")) + } + + @Test + fun `finds the code of a password reset`() { + assertEquals("482913", OtpCode.find("Reset your password", "Use this code to reset your password: 482913")) + } + + @Test + fun `offers nothing for code in prose`() { + assertNull(OtpCode.find("Kotlin weekly", "A new code formatter, 4500 commits from 300 people")) + } + + @Test + fun `offers nothing for the number a code word is about`() { + assertNull(OtpCode.find("Action needed", "Your card ending in 4821 needs to be verified")) + assertNull(OtpCode.find("Verification needed", "We need to verify account 12345678")) + } + + @Test + fun `offers nothing for prices, times, dates, years and decimals near a code word`() { + assertNull(OtpCode.find("Your code", "Your code expires at 12:30 on 2026/11/14")) + assertNull(OtpCode.find("Gift card code", "Balance \$1234.56")) + assertNull(OtpCode.find("Verification", "© 2026 Example Inc.")) + assertNull(OtpCode.find("Verification code", "Ticket #123456 was updated")) + } + + @Test + fun `offers nothing for a number too far from the code word`() { + val filler = "x".repeat(80) + assertNull(OtpCode.find("Verification", "$filler 482913")) + } + + @Test + fun `takes a code that ends the sentence`() { + assertEquals("482913", OtpCode.find(null, "Your verification code is 482913.")) + assertEquals("482913", OtpCode.find(null, "Your verification code is 482913, valid for 5 minutes")) + } + + @Test + fun `offers nothing for an empty mail`() { + assertNull(OtpCode.find(null, null)) + assertNull(OtpCode.find("", " ")) + } +} diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 77ff076c..e6617824 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -1710,7 +1710,8 @@ "wrong_passphrase": "عبارة المرور هذه غير صحيحة." }, "notifications": { - "vacation_filters_off": "تم حفظ إعدادات الغياب، لكن المرشحات لديك معطّلة. احفظ مجددًا لإعادة تفعيلها." + "vacation_filters_off": "تم حفظ إعدادات الغياب، لكن المرشحات لديك معطّلة. احفظ مجددًا لإعادة تفعيلها.", + "copy_code": "نسخ {code}" }, "outbox": { "discard": "تجاهل", diff --git a/locales/rn/ca.json b/locales/rn/ca.json index 83821769..1253e6d2 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -1715,7 +1715,8 @@ "wrong_passphrase": "La contrasenya no és correcta." }, "notifications": { - "vacation_filters_off": "S'ha desat la configuració d'absència, però els filtres estan desactivats. Torneu a desar per activar-los de nou." + "vacation_filters_off": "S'ha desat la configuració d'absència, però els filtres estan desactivats. Torneu a desar per activar-los de nou.", + "copy_code": "Copia {code}" }, "outbox": { "discard": "Descarta", diff --git a/locales/rn/cs.json b/locales/rn/cs.json index 8438860a..0229f598 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -1715,7 +1715,8 @@ "wrong_passphrase": "Toto heslo není správné." }, "notifications": { - "vacation_filters_off": "Nastavení nepřítomnosti bylo uloženo, ale vaše filtry jsou vypnuté. Uložte znovu, aby se zase zapnuly." + "vacation_filters_off": "Nastavení nepřítomnosti bylo uloženo, ale vaše filtry jsou vypnuté. Uložte znovu, aby se zase zapnuly.", + "copy_code": "Kopírovat {code}" }, "outbox": { "discard": "Zahodit", diff --git a/locales/rn/da.json b/locales/rn/da.json index 32aab641..ca58bf50 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -1712,7 +1712,8 @@ "wrong_passphrase": "Adgangssætningen er forkert." }, "notifications": { - "vacation_filters_off": "Fraværsindstillinger gemt, men dine filtre er slået fra. Gem igen for at slå dem til igen." + "vacation_filters_off": "Fraværsindstillinger gemt, men dine filtre er slået fra. Gem igen for at slå dem til igen.", + "copy_code": "Kopiér {code}" }, "outbox": { "discard": "Kassér", diff --git a/locales/rn/de.json b/locales/rn/de.json index a8cf809f..c197c6a1 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -783,7 +783,8 @@ } }, "notifications": { - "vacation_filters_off": "Abwesenheits-Einstellungen gespeichert, aber Ihre Filter sind ausgeschaltet. Speichern Sie noch einmal, um sie wieder einzuschalten." + "vacation_filters_off": "Abwesenheits-Einstellungen gespeichert, aber Ihre Filter sind ausgeschaltet. Speichern Sie noch einmal, um sie wieder einzuschalten.", + "copy_code": "{code} kopieren" }, "offline": { "banner": "Sie sind offline", diff --git a/locales/rn/en.json b/locales/rn/en.json index 41171eb3..ee683452 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -993,7 +993,8 @@ "emails_marked_read": "{count, plural, one {# email marked as read} other {# emails marked as read}}", "emails_marked_unread": "{count, plural, one {# email marked as unread} other {# emails marked as unread}}", "tag_removed_count": "{count, plural, one {Tag removed from # email} other {Tag removed from # emails}}", - "vacation_filters_off": "Out of office settings saved, but your filters are switched off. Save again to turn them back on." + "vacation_filters_off": "Out of office settings saved, but your filters are switched off. Save again to turn them back on.", + "copy_code": "Copy {code}" }, "offline": { "banner": "You are offline", diff --git a/locales/rn/es.json b/locales/rn/es.json index 99a48255..2bca0571 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -1721,7 +1721,8 @@ "wrong_passphrase": "Esa contraseña no es correcta." }, "notifications": { - "vacation_filters_off": "Configuración de ausencia guardada, pero tus filtros están desactivados. Vuelve a guardar para activarlos de nuevo." + "vacation_filters_off": "Configuración de ausencia guardada, pero tus filtros están desactivados. Vuelve a guardar para activarlos de nuevo.", + "copy_code": "Copiar {code}" }, "outbox": { "discard": "Descartar", diff --git a/locales/rn/et.json b/locales/rn/et.json index ff57ca09..98f8cbef 100644 --- a/locales/rn/et.json +++ b/locales/rn/et.json @@ -1531,7 +1531,8 @@ } }, "notifications": { - "vacation_filters_off": "Puhkusevastaja seaded salvestati, kuid sinu filtrid on välja lülitatud. Nende uuesti sisselülitamiseks salvesta veel kord." + "vacation_filters_off": "Puhkusevastaja seaded salvestati, kuid sinu filtrid on välja lülitatud. Nende uuesti sisselülitamiseks salvesta veel kord.", + "copy_code": "Kopeeri {code}" }, "offline": { "banner": "Võrguühendus puudub", diff --git a/locales/rn/fa.json b/locales/rn/fa.json index bdb94450..edcaae2c 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -1709,7 +1709,8 @@ "wrong_passphrase": "این عبارت عبور درست نیست." }, "notifications": { - "vacation_filters_off": "تنظیمات غیبت ذخیره شد، اما فیلترهای شما خاموش هستند. برای روشن کردن دوبارهٔ آن‌ها، دوباره ذخیره کنید." + "vacation_filters_off": "تنظیمات غیبت ذخیره شد، اما فیلترهای شما خاموش هستند. برای روشن کردن دوبارهٔ آن‌ها، دوباره ذخیره کنید.", + "copy_code": "کپی {code}" }, "outbox": { "discard": "دور انداختن", diff --git a/locales/rn/fr.json b/locales/rn/fr.json index b56e1d87..e7f222ca 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -1719,7 +1719,8 @@ "wrong_passphrase": "Cette phrase secrète est incorrecte." }, "notifications": { - "vacation_filters_off": "Paramètres d'absence enregistrés, mais vos filtres sont désactivés. Enregistrez à nouveau pour les réactiver." + "vacation_filters_off": "Paramètres d'absence enregistrés, mais vos filtres sont désactivés. Enregistrez à nouveau pour les réactiver.", + "copy_code": "Copier {code}" }, "outbox": { "discard": "Supprimer", diff --git a/locales/rn/he.json b/locales/rn/he.json index 0a78c29e..4d286214 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -1713,7 +1713,8 @@ "wrong_passphrase": "ביטוי הסיסמה שגוי." }, "notifications": { - "vacation_filters_off": "הגדרות ההיעדרות נשמרו, אבל המסננים שלך כבויים. שמור שוב כדי להפעיל אותם מחדש." + "vacation_filters_off": "הגדרות ההיעדרות נשמרו, אבל המסננים שלך כבויים. שמור שוב כדי להפעיל אותם מחדש.", + "copy_code": "העתקת {code}" }, "outbox": { "discard": "מחק", diff --git a/locales/rn/hu.json b/locales/rn/hu.json index 85ba2eab..469c9ba6 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -1711,7 +1711,8 @@ "wrong_passphrase": "Ez a jelszó nem megfelelő." }, "notifications": { - "vacation_filters_off": "Távolléti beállítások elmentve, de a szűrőid ki vannak kapcsolva. Mentsd újra, hogy visszakapcsold őket." + "vacation_filters_off": "Távolléti beállítások elmentve, de a szűrőid ki vannak kapcsolva. Mentsd újra, hogy visszakapcsold őket.", + "copy_code": "{code} másolása" }, "outbox": { "discard": "Elvetés", diff --git a/locales/rn/it.json b/locales/rn/it.json index ced0f08e..45b946b5 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -1717,7 +1717,8 @@ "wrong_passphrase": "La passphrase non è corretta." }, "notifications": { - "vacation_filters_off": "Impostazioni di assenza salvate, ma i tuoi filtri sono disattivati. Salva di nuovo per riattivarli." + "vacation_filters_off": "Impostazioni di assenza salvate, ma i tuoi filtri sono disattivati. Salva di nuovo per riattivarli.", + "copy_code": "Copia {code}" }, "outbox": { "discard": "Scarta", diff --git a/locales/rn/ja.json b/locales/rn/ja.json index c707429f..57fbeb5c 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -1715,7 +1715,8 @@ "wrong_passphrase": "パスフレーズが正しくありません。" }, "notifications": { - "vacation_filters_off": "不在の設定を保存しましたが、フィルターがオフになっています。オンに戻すには、もう一度保存してください。" + "vacation_filters_off": "不在の設定を保存しましたが、フィルターがオフになっています。オンに戻すには、もう一度保存してください。", + "copy_code": "{code} をコピー" }, "outbox": { "discard": "破棄", diff --git a/locales/rn/ko.json b/locales/rn/ko.json index e339356a..093e19d0 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -1713,7 +1713,8 @@ "wrong_passphrase": "비밀번호가 올바르지 않아요." }, "notifications": { - "vacation_filters_off": "부재중 설정은 저장되었지만 필터가 꺼져 있어요. 다시 켜려면 한 번 더 저장해 주세요." + "vacation_filters_off": "부재중 설정은 저장되었지만 필터가 꺼져 있어요. 다시 켜려면 한 번 더 저장해 주세요.", + "copy_code": "{code} 복사" }, "outbox": { "discard": "삭제", diff --git a/locales/rn/lv.json b/locales/rn/lv.json index bfbab989..57946aec 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -1714,7 +1714,8 @@ "wrong_passphrase": "Parole nav pareiza." }, "notifications": { - "vacation_filters_off": "Prombūtnes iestatījumi saglabāti, taču jūsu filtri ir izslēgti. Saglabājiet vēlreiz, lai tos atkal ieslēgtu." + "vacation_filters_off": "Prombūtnes iestatījumi saglabāti, taču jūsu filtri ir izslēgti. Saglabājiet vēlreiz, lai tos atkal ieslēgtu.", + "copy_code": "Kopēt {code}" }, "outbox": { "discard": "Atmest", diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 545763ec..f92b2e63 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -1710,7 +1710,8 @@ "wrong_passphrase": "Нууц үг буруу байна." }, "notifications": { - "vacation_filters_off": "Эзгүй байх тохиргоог хадгалсан боловч таны шүүлтүүрүүд унтраалттай байна. Тэдгээрийг дахин асаахын тулд дахин хадгална уу." + "vacation_filters_off": "Эзгүй байх тохиргоог хадгалсан боловч таны шүүлтүүрүүд унтраалттай байна. Тэдгээрийг дахин асаахын тулд дахин хадгална уу.", + "copy_code": "{code} хуулах" }, "outbox": { "discard": "Хаях", diff --git a/locales/rn/nb.json b/locales/rn/nb.json index f577863e..52c728c9 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -1712,7 +1712,8 @@ "wrong_passphrase": "Passordfrasen er feil." }, "notifications": { - "vacation_filters_off": "Fraværsinnstillingene er lagret, men filtrene dine er slått av. Lagre på nytt for å slå dem på igjen." + "vacation_filters_off": "Fraværsinnstillingene er lagret, men filtrene dine er slått av. Lagre på nytt for å slå dem på igjen.", + "copy_code": "Kopier {code}" }, "outbox": { "discard": "Forkast", diff --git a/locales/rn/nl.json b/locales/rn/nl.json index 0305eeb2..eb08f892 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -1715,7 +1715,8 @@ "wrong_passphrase": "Die wachtwoordzin klopt niet." }, "notifications": { - "vacation_filters_off": "Afwezigheidsinstellingen opgeslagen, maar je filters staan uit. Sla opnieuw op om ze weer in te schakelen." + "vacation_filters_off": "Afwezigheidsinstellingen opgeslagen, maar je filters staan uit. Sla opnieuw op om ze weer in te schakelen.", + "copy_code": "{code} kopiëren" }, "outbox": { "discard": "Verwijderen", diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 16974c1b..40c1dd44 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -1715,7 +1715,8 @@ "wrong_passphrase": "To hasło jest nieprawidłowe." }, "notifications": { - "vacation_filters_off": "Ustawienia nieobecności zapisane, ale Twoje filtry są wyłączone. Zapisz ponownie, aby je włączyć." + "vacation_filters_off": "Ustawienia nieobecności zapisane, ale Twoje filtry są wyłączone. Zapisz ponownie, aby je włączyć.", + "copy_code": "Kopiuj {code}" }, "outbox": { "discard": "Odrzuć", diff --git a/locales/rn/pt.json b/locales/rn/pt.json index a8ddcbfe..024e2328 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -1718,7 +1718,8 @@ "wrong_passphrase": "A frase secreta não está correta." }, "notifications": { - "vacation_filters_off": "Configurações de ausência salvas, mas seus filtros estão desativados. Salve de novo para reativá-los." + "vacation_filters_off": "Configurações de ausência salvas, mas seus filtros estão desativados. Salve de novo para reativá-los.", + "copy_code": "Copiar {code}" }, "outbox": { "discard": "Descartar", diff --git a/locales/rn/ro.json b/locales/rn/ro.json index b950ba29..64fc3d33 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -1715,7 +1715,8 @@ "wrong_passphrase": "Fraza de acces nu este corectă." }, "notifications": { - "vacation_filters_off": "Setările de absență au fost salvate, dar filtrele dvs. sunt dezactivate. Salvați din nou pentru a le reactiva." + "vacation_filters_off": "Setările de absență au fost salvate, dar filtrele dvs. sunt dezactivate. Salvați din nou pentru a le reactiva.", + "copy_code": "Copiază {code}" }, "outbox": { "discard": "Renunțați", diff --git a/locales/rn/ru.json b/locales/rn/ru.json index ef07729b..1fa6d22d 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -1718,7 +1718,8 @@ "wrong_passphrase": "Неверная парольная фраза." }, "notifications": { - "vacation_filters_off": "Настройки отсутствия сохранены, но ваши фильтры выключены. Сохраните ещё раз, чтобы снова их включить." + "vacation_filters_off": "Настройки отсутствия сохранены, но ваши фильтры выключены. Сохраните ещё раз, чтобы снова их включить.", + "copy_code": "Копировать {code}" }, "outbox": { "discard": "Удалить", diff --git a/locales/rn/sk.json b/locales/rn/sk.json index 0a28af94..d4e2b615 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -1714,7 +1714,8 @@ "wrong_passphrase": "Toto heslo nie je správne." }, "notifications": { - "vacation_filters_off": "Nastavenia neprítomnosti boli uložené, ale vaše filtre sú vypnuté. Ak ich chcete znova zapnúť, uložte ešte raz." + "vacation_filters_off": "Nastavenia neprítomnosti boli uložené, ale vaše filtre sú vypnuté. Ak ich chcete znova zapnúť, uložte ešte raz.", + "copy_code": "Kopírovať {code}" }, "outbox": { "discard": "Zahodiť", diff --git a/locales/rn/th.json b/locales/rn/th.json index 30edd6db..0701547b 100644 --- a/locales/rn/th.json +++ b/locales/rn/th.json @@ -795,7 +795,8 @@ } }, "notifications": { - "vacation_filters_off": "บันทึกการตั้งค่าตอบกลับอัตโนมัติแล้ว แต่ตัวกรองของคุณถูกปิดอยู่ บันทึกอีกครั้งเพื่อเปิดใช้งานตัวกรอง" + "vacation_filters_off": "บันทึกการตั้งค่าตอบกลับอัตโนมัติแล้ว แต่ตัวกรองของคุณถูกปิดอยู่ บันทึกอีกครั้งเพื่อเปิดใช้งานตัวกรอง", + "copy_code": "คัดลอก {code}" }, "offline": { "banner": "คุณออฟไลน์อยู่", diff --git a/locales/rn/tr.json b/locales/rn/tr.json index 9430954a..0fd453d9 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -1716,7 +1716,8 @@ "wrong_passphrase": "Bu parola doğru değil." }, "notifications": { - "vacation_filters_off": "Ofis dışında ayarları kaydedildi, ancak filtreleriniz kapalı. Yeniden açmak için tekrar kaydedin." + "vacation_filters_off": "Ofis dışında ayarları kaydedildi, ancak filtreleriniz kapalı. Yeniden açmak için tekrar kaydedin.", + "copy_code": "{code} kopyala" }, "outbox": { "discard": "Vazgeç", diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 215ec54b..d7a3b0bf 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -1717,7 +1717,8 @@ "wrong_passphrase": "Неправильна парольна фраза." }, "notifications": { - "vacation_filters_off": "Налаштування відсутності збережено, але ваші фільтри вимкнено. Збережіть ще раз, щоб знову їх увімкнути." + "vacation_filters_off": "Налаштування відсутності збережено, але ваші фільтри вимкнено. Збережіть ще раз, щоб знову їх увімкнути.", + "copy_code": "Копіювати {code}" }, "outbox": { "discard": "Відкинути", diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index cb2cee9f..e68f04b8 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -1714,7 +1714,8 @@ "wrong_passphrase": "密碼片語不正確。" }, "notifications": { - "vacation_filters_off": "外出設定已儲存,但您的篩選規則已關閉。再次儲存即可重新開啟。" + "vacation_filters_off": "外出設定已儲存,但您的篩選規則已關閉。再次儲存即可重新開啟。", + "copy_code": "複製 {code}" }, "outbox": { "discard": "捨棄", diff --git a/locales/rn/zh.json b/locales/rn/zh.json index cfa66f45..72bbc00b 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -1714,7 +1714,8 @@ "wrong_passphrase": "密码不正确。" }, "notifications": { - "vacation_filters_off": "外出设置已保存,但您的过滤器已关闭。再次保存即可重新开启。" + "vacation_filters_off": "外出设置已保存,但您的过滤器已关闭。再次保存即可重新开启。", + "copy_code": "复制 {code}" }, "outbox": { "discard": "丢弃", diff --git a/src/lib/__tests__/android-native-push.test.ts b/src/lib/__tests__/android-native-push.test.ts index 671dcf91..4292a6ea 100644 --- a/src/lib/__tests__/android-native-push.test.ts +++ b/src/lib/__tests__/android-native-push.test.ts @@ -44,6 +44,8 @@ describe('shareAccountWithExtension on Android', () => { markRead: expect.any(String), delete: expect.any(String), reply: expect.any(String), + copyCode: 'Copy {code}', + codeCopied: expect.any(String), }, }); // Ensure passwords and OAuth tokens are never duplicated into SharedPreferences diff --git a/src/lib/__tests__/push-background-task.test.ts b/src/lib/__tests__/push-background-task.test.ts index 6ed3c19d..837efe65 100644 --- a/src/lib/__tests__/push-background-task.test.ts +++ b/src/lib/__tests__/push-background-task.test.ts @@ -380,6 +380,9 @@ describe('pushBackgroundTask notifications', () => { markReadLabel: expect.any(String), deleteLabel: expect.any(String), replyLabel: expect.any(String), + // Left with its placeholder: Android puts in the code it finds. + copyCodeLabel: 'Copy {code}', + codeCopiedLabel: expect.any(String), })); }); diff --git a/src/lib/__tests__/push-without-content.test.ts b/src/lib/__tests__/push-without-content.test.ts index 4f00a42e..569f95c6 100644 --- a/src/lib/__tests__/push-without-content.test.ts +++ b/src/lib/__tests__/push-without-content.test.ts @@ -229,6 +229,7 @@ describe('the rule for groups, several messages, older notifications and the cac expect(shown).toMatchObject({ emailId: '', threadId: '', groupKey: `bulwark-mail:${LOCAL}` }); expect(shown).not.toHaveProperty('markReadLabel'); expect(shown).not.toHaveProperty('replyLabel'); + expect(shown).not.toHaveProperty('copyCodeLabel'); // They count as notified: the next push for them shows nothing more. await push(['m1', 'm2', 'm3']); expect(showNotification).toHaveBeenCalledTimes(1); diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index 14c6bc02..fff0fc3f 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -140,6 +140,11 @@ interface ShowNotificationOptions { // one, or when that is not known, the unlock stays. deleteNeedsUnlock?: boolean; replyLabel?: string; + // The Copy action Android offers for a sign-in code it finds in the mail + // (OtpCode.kt). "{code}" in the label is replaced by the code; the second + // is the toast below Android 13, which confirms no copy itself. + copyCodeLabel?: string; + codeCopiedLabel?: string; // The login's organisation wants notifications without content: the // account's group summary takes no lines from the other notifications in // the group, which may be message notifications just taken out. @@ -687,6 +692,8 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa 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'); + const copyCodeLabel = translate(locale, 'notifications.copy_code', 'Copy {code}'); + const codeCopiedLabel = translate(locale, 'notifications.copied_to_clipboard', 'Copied to clipboard'); await native.showNotification({ notificationId: `mail:${email.id}`, @@ -707,6 +714,8 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa deleteLabel, deleteNeedsUnlock: !hasTrash, replyLabel, + copyCodeLabel, + codeCopiedLabel, }); } diff --git a/src/lib/push-notifications.ts b/src/lib/push-notifications.ts index 39babb75..9b7cb793 100644 --- a/src/lib/push-notifications.ts +++ b/src/lib/push-notifications.ts @@ -1189,6 +1189,8 @@ export async function shareAccountWithExtension( markRead: t('email_viewer.mark_read', 'Mark as read'), delete: t('email_viewer.delete', 'Delete'), reply: t('email_viewer.reply', 'Reply'), + copyCode: t('notifications.copy_code', 'Copy {code}'), + codeCopied: t('notifications.copied_to_clipboard', 'Copied to clipboard'), }, }), );