From ce2cf858bb3ba9c162d305c862ce055293622da8 Mon Sep 17 00:00:00 2001
From: wmcparla <183443443+wmcparla@users.noreply.github.com>
Date: Sat, 14 Nov 2026 21:02:31 -0500
Subject: [PATCH] feat: offer to copy a sign-in code from the new-mail
notification on Android #147
When a new mail's subject or preview carries a sign-in code (two-factor,
verification, one-time password), its notification shows "Copy 482913"
as the first action, in place of Reply: the tray shows three actions, and
nobody answers a code mail. Tapping it copies the code, marked sensitive
so the clipboard preview of Android 13+ hides it, and dismisses the
notification; below Android 13 a toast says it was copied.
A number only counts near a word that names a code (in the app's
languages), after it, or before it only where it leads a line. It does
not count where that word names something else (promo, gift card, postal,
error or decline code, barcode), where the number is what the code is for
(card ending in, account, issue, shipment, flight), for a phone or dial-in
number, or in mail that sells something or invites to a meeting. Offering
a wrong number is worse than offering none.
Both the native lookup and the JS task end in postNotification, so both
get it; a login whose organisation hides notification content shows no
content and so no code. The label is new in every locale (machine
translated); the toast reuses notifications.copied_to_clipboard.
---
.../bulwarkmobile/BulwarkFcmModule.kt | 84 ++++++---
.../bulwarkmobile/BulwarkNativePushFetcher.kt | 8 +
.../BulwarkNotificationActionReceiver.kt | 33 ++++
.../com/anonymous/bulwarkmobile/OtpCode.kt | 157 ++++++++++++++++
android/app/src/main/res/values/strings.xml | 3 +
.../anonymous/bulwarkmobile/OtpCodeTest.kt | 172 ++++++++++++++++++
locales/rn/ar.json | 3 +-
locales/rn/ca.json | 3 +-
locales/rn/cs.json | 3 +-
locales/rn/da.json | 3 +-
locales/rn/de.json | 3 +-
locales/rn/en.json | 3 +-
locales/rn/es.json | 3 +-
locales/rn/et.json | 3 +-
locales/rn/fa.json | 3 +-
locales/rn/fr.json | 3 +-
locales/rn/he.json | 3 +-
locales/rn/hu.json | 3 +-
locales/rn/it.json | 3 +-
locales/rn/ja.json | 3 +-
locales/rn/ko.json | 3 +-
locales/rn/lv.json | 3 +-
locales/rn/mn.json | 3 +-
locales/rn/nb.json | 3 +-
locales/rn/nl.json | 3 +-
locales/rn/pl.json | 3 +-
locales/rn/pt.json | 3 +-
locales/rn/ro.json | 3 +-
locales/rn/ru.json | 3 +-
locales/rn/sk.json | 3 +-
locales/rn/th.json | 3 +-
locales/rn/tr.json | 3 +-
locales/rn/uk.json | 3 +-
locales/rn/zh-TW.json | 3 +-
locales/rn/zh.json | 3 +-
src/lib/__tests__/android-native-push.test.ts | 2 +
.../__tests__/push-background-task.test.ts | 3 +
.../__tests__/push-without-content.test.ts | 1 +
src/lib/push-background-task.ts | 9 +
src/lib/push-notifications.ts | 2 +
40 files changed, 511 insertions(+), 50 deletions(-)
create mode 100644 android/app/src/main/java/com/anonymous/bulwarkmobile/OtpCode.kt
create mode 100644 android/app/src/test/java/com/anonymous/bulwarkmobile/OtpCodeTest.kt
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'),
},
}),
);