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'),
},
}),
);