diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt
index 18be4ac78f..4b17253ef1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt
@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
@@ -59,11 +60,15 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Util
import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.v2.ui.warning.ClipStage
+import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetContent
+import org.thoughtcrime.securesms.backup.v2.ui.warning.RecoveryKeyWarningSheetEvent
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.storage.CredentialManagerError
import org.thoughtcrime.securesms.util.storage.CredentialManagerResult
@@ -120,6 +125,7 @@ fun MessageBackupsKeyRecordScreen(
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
@@ -145,6 +151,39 @@ fun MessageBackupsKeyRecordScreen(
RecordScreenBackHandler()
}
+ var displayRecoveryKeyCopyWarning by remember { mutableStateOf(false) }
+ if (displayRecoveryKeyCopyWarning) {
+ val context = LocalContext.current
+ val url = stringResource(R.string.recovery_key_phishing_support_url)
+ val events: (RecoveryKeyWarningSheetEvent) -> Unit = {
+ when (it) {
+ RecoveryKeyWarningSheetEvent.DoNotShareClick -> error("Not supported")
+ RecoveryKeyWarningSheetEvent.GotItClick -> {
+ onCopyToClipboardClick(backupKeyString)
+ displayRecoveryKeyCopyWarning = false
+ }
+ RecoveryKeyWarningSheetEvent.LearnMoreClick -> {
+ CommunicationActions.openBrowserLink(context, url)
+ displayRecoveryKeyCopyWarning = false
+ }
+
+ RecoveryKeyWarningSheetEvent.PasteKeyClick -> error("Not supported")
+ RecoveryKeyWarningSheetEvent.ShareKeyClick -> error("Not supported")
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = { displayRecoveryKeyCopyWarning = false },
+ dragHandle = { BottomSheets.Handle() },
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ) {
+ RecoveryKeyWarningSheetContent(
+ clipStage = ClipStage.COPY,
+ events = events
+ )
+ }
+ }
+
Scaffolds.Settings(
title = "",
navigationIcon = SignalIcons.ArrowStart.imageVector,
@@ -227,7 +266,13 @@ fun MessageBackupsKeyRecordScreen(
item {
Buttons.Small(
- onClick = { onCopyToClipboardClick(backupKeyString) }
+ onClick = {
+ if (mode is MessageBackupsKeyRecordMode.CreateNewKey) {
+ displayRecoveryKeyCopyWarning = true
+ } else {
+ onCopyToClipboardClick(backupKeyString)
+ }
+ }
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetector.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetector.kt
new file mode 100644
index 0000000000..c112f85440
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetector.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.backup.v2.ui.warning
+
+import org.signal.core.models.AccountEntropyPool
+
+/**
+ * Detects whether a block of text contains the user's own [AccountEntropyPool] (recovery key).
+ *
+ * We scan anywhere within the text and try to match the key in as many forms as possible:
+ * upper/lowercase, with or without grouping spaces, and with or without the display characters
+ * (e.g. '#'/'=') used to disambiguate 'O'/'0'. Matching against the user's actual key (rather than
+ * just the AEP shape) avoids false positives on any 64-character in-alphabet string.
+ */
+object RecoveryKeyDetector {
+
+ /**
+ * @param text the text to scan
+ * @param recoveryKey the user's own recovery key, or null if they don't have one yet
+ * @return true if [text] contains [recoveryKey] in any of its accepted forms. Always false when
+ * [recoveryKey] is null, so callers can bypass the check entirely for users without a key.
+ */
+ fun containsRecoveryKey(text: String?, recoveryKey: AccountEntropyPool?): Boolean {
+ if (recoveryKey == null || text.isNullOrBlank() || text.length < AccountEntropyPool.LENGTH) {
+ return false
+ }
+
+ val normalized = AccountEntropyPool.removeIllegalCharacters(AccountEntropyPool.formatForStorage(text)).lowercase()
+
+ return normalized.contains(recoveryKey.value)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteGuard.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteGuard.kt
new file mode 100644
index 0000000000..f39bcf619f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteGuard.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.backup.v2.ui.warning
+
+import androidx.fragment.app.Fragment
+import org.thoughtcrime.securesms.components.ComposeText
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+
+/**
+ * Wires this [ComposeText] so that pasting the user's own recovery key first shows
+ * [RecoveryKeyPasteWarningFragment], warning against sharing it. The paste only completes if the
+ * user explicitly confirms via that warning.
+ *
+ * Must be called once the [host]'s view has been created, as it registers a fragment result
+ * listener scoped to the host's view lifecycle.
+ *
+ * @param onWarningShown invoked just before the warning is shown. Hosts that auto-dismiss when the
+ * keyboard hides (e.g. [org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment]) can use
+ * this to suppress that behavior while the warning is up.
+ * @param onWarningDismissed invoked when the warning is dismissed by any path, after the paste (if
+ * any) has been applied. Hosts can use this to restore the suppressed state and re-focus the input.
+ */
+fun ComposeText.guardAgainstRecoveryKeyPaste(
+ host: Fragment,
+ onWarningShown: () -> Unit = {},
+ onWarningDismissed: () -> Unit = {}
+) {
+ var pendingPaste: CharSequence? = null
+
+ host.childFragmentManager.setFragmentResultListener(RecoveryKeyPasteWarningFragment.REQUEST_KEY, host.viewLifecycleOwner) { _, bundle ->
+ if (bundle.getBoolean(RecoveryKeyPasteWarningFragment.REQUEST_KEY)) {
+ pendingPaste?.let { insertText(it) }
+ }
+ pendingPaste = null
+ onWarningDismissed()
+ }
+
+ setOnPasteListener { pasteText ->
+ if (RecoveryKeyDetector.containsRecoveryKey(pasteText?.toString(), SignalStore.account.accountEntropyPoolOrNull)) {
+ pendingPaste = pasteText
+ onWarningShown()
+ RecoveryKeyPasteWarningFragment().show(host.childFragmentManager, null)
+ true
+ } else {
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteWarningFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteWarningFragment.kt
new file mode 100644
index 0000000000..164c656b1c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyPasteWarningFragment.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.backup.v2.ui.warning
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.fragment.app.setFragmentResult
+import org.signal.core.ui.compose.BottomSheets
+import org.signal.core.ui.compose.ComposeFullScreenDialogFragment
+
+/**
+ * Displayed via the [org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsFragment] whenever the user
+ * attempts to paste their recovery key into the input field.
+ *
+ * A result is always delivered to [REQUEST_KEY] when this fragment is dismissed, with the boolean
+ * indicating whether the user chose to proceed with the paste. The host can rely on this firing for
+ * every dismissal path (paste, decline, or cancel) to restore its own state.
+ */
+class RecoveryKeyPasteWarningFragment : ComposeFullScreenDialogFragment() {
+
+ companion object {
+ const val REQUEST_KEY = "recovery_key_request"
+ }
+
+ private var shouldPaste = false
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return super.onCreateDialog(savedInstanceState).apply {
+ window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
+ window?.setWindowAnimations(0)
+ }
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ setFragmentResult(
+ REQUEST_KEY,
+ Bundle().apply {
+ putBoolean(REQUEST_KEY, shouldPaste)
+ }
+ )
+
+ super.onDismiss(dialog)
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ override fun DialogContent() {
+ var isDisplayingFinalWarningDialog by remember { mutableStateOf(false) }
+
+ val eventHandler: (RecoveryKeyWarningSheetEvent) -> Unit = {
+ when (it) {
+ RecoveryKeyWarningSheetEvent.DoNotShareClick -> {
+ dismissAllowingStateLoss()
+ }
+
+ RecoveryKeyWarningSheetEvent.GotItClick -> error("Not supported for paste")
+ RecoveryKeyWarningSheetEvent.LearnMoreClick -> error("Not supported for paste")
+ RecoveryKeyWarningSheetEvent.PasteKeyClick -> {
+ shouldPaste = true
+ dismissAllowingStateLoss()
+ }
+
+ RecoveryKeyWarningSheetEvent.ShareKeyClick -> {
+ isDisplayingFinalWarningDialog = true
+ }
+ }
+ }
+
+ if (isDisplayingFinalWarningDialog) {
+ RecoveryKeyWarningDialog(
+ events = eventHandler
+ )
+ } else {
+ ModalBottomSheet(
+ onDismissRequest = { dismissAllowingStateLoss() },
+ dragHandle = { BottomSheets.Handle() },
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ ) {
+ RecoveryKeyWarningSheetContent(
+ clipStage = ClipStage.PASTE,
+ events = eventHandler
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetContent.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetContent.kt
new file mode 100644
index 0000000000..b564a0415b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetContent.kt
@@ -0,0 +1,185 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.backup.v2.ui.warning
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.DayNightPreviews
+import org.signal.core.ui.compose.Dialogs
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.horizontalGutters
+import org.thoughtcrime.securesms.R
+
+@Composable
+fun RecoveryKeyWarningSheetContent(
+ clipStage: ClipStage,
+ events: (RecoveryKeyWarningSheetEvent) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.horizontalGutters(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_warning_40),
+ tint = MaterialTheme.colorScheme.error,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(top = 20.dp, bottom = 16.dp)
+ .size(80.dp)
+ .background(color = MaterialTheme.colorScheme.errorContainer, shape = CircleShape)
+ .padding(20.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_your_recovery_key),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 12.dp)
+ )
+
+ val signalWillNeverMessageYou = stringResource(R.string.RecoveryKeyWarningSheetContent__signal_will_never_message_you)
+ val recoveryKeyWarningBody = stringResource(R.string.RecoveryKeyWarningSheetContent__for_your_recovery_key_never_respond)
+
+ Text(
+ text = buildAnnotatedString {
+ withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(signalWillNeverMessageYou)
+ }
+
+ append(" ")
+ append(recoveryKeyWarningBody)
+ },
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 75.dp)
+ )
+
+ when (clipStage) {
+ ClipStage.COPY -> CopyActionButtons(events = events)
+ ClipStage.PASTE -> PasteActionButtons(events = events)
+ }
+
+ Spacer(modifier = Modifier.size(16.dp))
+ }
+}
+
+@Composable
+fun CopyActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
+ Buttons.LargeTonal(onClick = {
+ events(RecoveryKeyWarningSheetEvent.GotItClick)
+ }) {
+ Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__got_it))
+ }
+
+ TextButton(onClick = {
+ events(RecoveryKeyWarningSheetEvent.LearnMoreClick)
+ }) {
+ Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__learn_more))
+ }
+}
+
+@Composable
+fun PasteActionButtons(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
+ Buttons.LargeTonal(onClick = {
+ events(RecoveryKeyWarningSheetEvent.DoNotShareClick)
+ }) {
+ Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__do_not_share_key))
+ }
+
+ TextButton(onClick = {
+ events(RecoveryKeyWarningSheetEvent.ShareKeyClick)
+ }) {
+ Text(text = stringResource(R.string.RecoveryKeyWarningSheetContent__share_key))
+ }
+}
+
+@Composable
+fun RecoveryKeyWarningDialog(events: (RecoveryKeyWarningSheetEvent) -> Unit) {
+ val bodyIntro = stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_your_recovery_key_with_anyone)
+ val bodyEmphasis = stringResource(R.string.RecoveryKeyWarningDialog__signal_will_never_message_you_for_your_recovery_key)
+ val bodyOutro = stringResource(R.string.RecoveryKeyWarningDialog__never_respond_to_a_chat)
+
+ Dialogs.SimpleAlertDialog(
+ title = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__do_not_share_recovery_key)),
+ body = buildAnnotatedString {
+ append(bodyIntro)
+ append(" ")
+
+ withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
+ append(bodyEmphasis)
+ }
+
+ append(" ")
+ append(bodyOutro)
+ },
+ confirm = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__paste_key)),
+ confirmColor = MaterialTheme.colorScheme.error,
+ dismiss = AnnotatedString(stringResource(R.string.RecoveryKeyWarningDialog__dont_share)),
+ onConfirm = { events(RecoveryKeyWarningSheetEvent.PasteKeyClick) },
+ onDeny = { events(RecoveryKeyWarningSheetEvent.DoNotShareClick) }
+ )
+}
+
+enum class ClipStage {
+ COPY,
+ PASTE
+}
+
+@DayNightPreviews
+@Composable
+private fun RecoveryKeyWarningSheetContentCopyPreview() {
+ Previews.BottomSheetPreview {
+ RecoveryKeyWarningSheetContent(
+ clipStage = ClipStage.COPY,
+ events = {},
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun RecoveryKeyWarningSheetContentPastePreview() {
+ Previews.BottomSheetPreview {
+ RecoveryKeyWarningSheetContent(
+ clipStage = ClipStage.PASTE,
+ events = {},
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun RecoveryKeyWarningDialogPreview() {
+ Previews.Preview {
+ RecoveryKeyWarningDialog(events = {})
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetEvent.kt
new file mode 100644
index 0000000000..7a61317dbb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyWarningSheetEvent.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.backup.v2.ui.warning
+
+sealed interface RecoveryKeyWarningSheetEvent {
+ data object DoNotShareClick : RecoveryKeyWarningSheetEvent
+ data object ShareKeyClick : RecoveryKeyWarningSheetEvent
+ data object PasteKeyClick : RecoveryKeyWarningSheetEvent
+ data object GotItClick : RecoveryKeyWarningSheetEvent
+ data object LearnMoreClick : RecoveryKeyWarningSheetEvent
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
index 5c781cadce..80593a9fd9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components;
+import android.content.ClipData;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
@@ -19,6 +20,7 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputConnectionWrapper;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
@@ -26,6 +28,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
+import org.signal.core.util.ServiceUtil;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
@@ -68,6 +71,7 @@ public class ComposeText extends EmojiEditText {
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
@Nullable private StylingChangedListener stylingChangedListener;
+ @Nullable private OnPasteListener onPasteListener;
public ComposeText(Context context) {
super(context);
@@ -213,6 +217,41 @@ public class ComposeText extends EmojiEditText {
stylingChangedListener = listener;
}
+ public void setOnPasteListener(@Nullable OnPasteListener listener) {
+ onPasteListener = listener;
+ }
+
+ /**
+ * Inserts the given text at the current selection (replacing any selected text), as if pasted.
+ * This goes directly through the underlying {@link Editable}, so it does not pass through the
+ * {@link OnPasteListener}. Used to complete a paste the listener previously intercepted, replaying
+ * the exact text that was intercepted rather than re-reading the clipboard — the intercepted text
+ * may have come from an IME suggestion (e.g. the keyboard's clipboard chip) that is not the
+ * current clipboard contents.
+ */
+ public void insertText(@NonNull CharSequence text) {
+ Editable editable = getText();
+ if (editable == null) {
+ return;
+ }
+
+ int selectionStart = getSelectionStart();
+ int selectionEnd = getSelectionEnd();
+
+ int start;
+ int end;
+ if (selectionStart < 0 || selectionEnd < 0) {
+ start = editable.length();
+ end = editable.length();
+ } else {
+ start = Math.min(selectionStart, selectionEnd);
+ end = Math.max(selectionStart, selectionEnd);
+ }
+
+ editable.replace(start, end, text);
+ setSelection(start + text.length());
+ }
+
private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
@@ -242,7 +281,19 @@ public class ComposeText extends EmojiEditText {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
- return inputConnection;
+ if (inputConnection == null) {
+ return null;
+ }
+
+ return new InputConnectionWrapper(inputConnection, true) {
+ @Override
+ public boolean commitText(CharSequence text, int newCursorPosition) {
+ if (onPasteListener != null && text != null && onPasteListener.onPaste(text)) {
+ return true;
+ }
+ return super.commitText(text, newCursorPosition);
+ }
+ };
}
public boolean hasMentions() {
@@ -479,6 +530,20 @@ public class ComposeText extends EmojiEditText {
return true;
}
+ @Override
+ public boolean onTextContextMenuItem(int id) {
+ if ((id == android.R.id.paste || id == android.R.id.pasteAsPlainText) && onPasteListener != null) {
+ ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip();
+ CharSequence pasteText = clipData != null && clipData.getItemCount() > 0 ? clipData.getItemAt(0).coerceToText(getContext()) : null;
+
+ if (onPasteListener.onPaste(pasteText)) {
+ return true;
+ }
+ }
+
+ return super.onTextContextMenuItem(id);
+ }
+
/**
* Return true if we think the user may be inputting a time.
*/
@@ -576,4 +641,15 @@ public class ComposeText extends EmojiEditText {
public interface StylingChangedListener {
void onStylingChanged();
}
+
+ public interface OnPasteListener {
+ /**
+ * Invoked before a paste is applied to the field, giving an observer the chance to intercept it.
+ *
+ * @param pasteText the text currently on the clipboard, or {@code null} if it could not be read
+ * @return true to consume the paste (the listener will handle it, e.g. by prompting the user),
+ * or false to let the paste proceed normally
+ */
+ boolean onPaste(@Nullable CharSequence pasteText);
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
index 6d3c882540..e188688036 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt
@@ -143,6 +143,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentSaver
import org.thoughtcrime.securesms.audio.AudioRecorder
import org.thoughtcrime.securesms.backup.v2.ui.subscription.BackupUpgradeAvailabilityChecker
+import org.thoughtcrime.securesms.backup.v2.ui.warning.guardAgainstRecoveryKeyPaste
import org.thoughtcrime.securesms.badges.gifts.OpenableGift
import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration
import org.thoughtcrime.securesms.badges.gifts.viewgift.received.ViewReceivedGiftBottomSheet
@@ -1301,6 +1302,7 @@ class ConversationFragment :
addTextChangedListener(composeTextEventsListener)
setStylingChangedListener(composeTextEventsListener)
setOnClickListener(composeTextEventsListener)
+ guardAgainstRecoveryKeyPaste(this@ConversationFragment)
filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt
index e6e2d519e4..0edccc4d6e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt
@@ -146,6 +146,15 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
}
}
+ /**
+ * The locally-stored [AccountEntropyPool], or null if one has not yet been generated or restored.
+ * Unlike [accountEntropyPool], reading this never generates and persists a new AEP as a side effect.
+ */
+ val accountEntropyPoolOrNull: AccountEntropyPool?
+ get() = AEP_LOCK.withLock {
+ getString(KEY_ACCOUNT_ENTROPY_POOL, null)?.let { AccountEntropyPool(it) }
+ }
+
fun rotateAccountEntropyPool(aep: AccountEntropyPool) {
AEP_LOCK.withLock {
Log.i(TAG, "Rotating Account Entropy Pool (AEP)...", Throwable(), true)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt
index ff9ecb0148..bac5bb4b54 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/AddMessageDialogFragment.kt
@@ -20,6 +20,7 @@ import org.signal.core.ui.view.Stub
import org.signal.core.util.ByteLimitInputFilter
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.v2.ui.warning.guardAgainstRecoveryKeyPaste
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
import org.thoughtcrime.securesms.components.KeyboardEntryDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -71,6 +72,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
private lateinit var inlineQueryResultsController: InlineQueryResultsController
private var requestedEmojiDrawer: Boolean = false
+ private var displayingRecoveryKeyWarning: Boolean = false
private var recipient: Recipient? = null
@@ -97,6 +99,14 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
binding.content.addAMessageInput.setText(requireArguments().getCharSequence(ARG_INITIAL_TEXT))
binding.content.addAMessageInput.addTextChangedListener { viewModel.setMessage(it) }
binding.content.addAMessageInput.filters += ByteLimitInputFilter(MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES)
+ binding.content.addAMessageInput.guardAgainstRecoveryKeyPaste(
+ host = this,
+ onWarningShown = { displayingRecoveryKeyWarning = true },
+ onWarningDismissed = {
+ displayingRecoveryKeyWarning = false
+ ViewUtil.focusAndShowKeyboard(binding.content.addAMessageInput)
+ }
+ )
binding.content.emojiToggle.setOnClickListener { onEmojiToggleClicked() }
if (requireArguments().getBoolean(ARG_INITIAL_EMOJI_TOGGLE) && view is KeyboardAwareLinearLayout) {
@@ -161,7 +171,7 @@ class AddMessageDialogFragment : KeyboardEntryDialogFragment(R.layout.v2_media_a
}
override fun onKeyboardHidden() {
- if (!requestedEmojiDrawer) {
+ if (!requestedEmojiDrawer && !displayingRecoveryKeyWarning) {
super.onKeyboardHidden()
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ee6914b797..e53424468c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -25,6 +25,7 @@
https://support.signal.org/hc/articles/360031949872#pending
https://support.signal.org/hc/articles/360031949872#donate
https://support.signal.org/hc/articles/9021007554074
+ https://support.signal.org/hc/articles/9932566320410
https://play.google.com/store/account/subscriptions?sku=%1$s&package=%2$s
@@ -9935,5 +9936,33 @@
Open account settings
+
+ Do not share your recovery key
+
+ Signal will never message you
+
+ for your recovery key. Never respond to a chat pretending to be Signal. Store your recovery key somewhere safe and never share it with anyone.
+
+ Got it
+
+ Learn more
+
+ Do not share key
+
+ Share key
+
+
+ Do not share recovery key
+
+ Do not share your recovery key with anyone. This will let them take over your account.
+
+ Signal will never message you for your recovery key.
+
+ Never respond to a chat pretending to be Signal.
+
+ Paste key
+
+ Don\'t share
+
diff --git a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetectorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetectorTest.kt
new file mode 100644
index 0000000000..e884c48fa3
--- /dev/null
+++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/ui/warning/RecoveryKeyDetectorTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.backup.v2.ui.warning
+
+import assertk.assertThat
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import org.junit.Test
+import org.signal.core.models.AccountEntropyPool
+
+class RecoveryKeyDetectorTest {
+
+ private val recoveryKey = AccountEntropyPool.generate()
+
+ @Test
+ fun `null recovery key is never detected`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value, null)).isFalse()
+ }
+
+ @Test
+ fun `null or blank text is not a recovery key`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(null, recoveryKey)).isFalse()
+ assertThat(RecoveryKeyDetector.containsRecoveryKey("", recoveryKey)).isFalse()
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(" ", recoveryKey)).isFalse()
+ }
+
+ @Test
+ fun `ordinary message is not a recovery key`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey("Hey, are we still on for lunch tomorrow?", recoveryKey)).isFalse()
+ }
+
+ @Test
+ fun `text shorter than a key is not a recovery key`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value.dropLast(1), recoveryKey)).isFalse()
+ }
+
+ @Test
+ fun `exact storage form is detected`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value, recoveryKey)).isTrue()
+ }
+
+ @Test
+ fun `uppercase form is detected`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.value.uppercase(), recoveryKey)).isTrue()
+ }
+
+ @Test
+ fun `display form with substitution characters is detected`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(recoveryKey.displayValue, recoveryKey)).isTrue()
+ }
+
+ @Test
+ fun `grouped with spaces is detected`() {
+ val grouped = recoveryKey.value.chunked(4).joinToString(" ")
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(grouped, recoveryKey)).isTrue()
+ }
+
+ @Test
+ fun `embedded in surrounding text is detected`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey("Hey, here is my recovery key: ${recoveryKey.value} keep it safe!", recoveryKey)).isTrue()
+ }
+
+ @Test
+ fun `embedded grouped display form is detected`() {
+ val grouped = recoveryKey.displayValue.chunked(4).joinToString(" ")
+ assertThat(RecoveryKeyDetector.containsRecoveryKey("my key\n$grouped\nthanks", recoveryKey)).isTrue()
+ }
+
+ @Test
+ fun `a different valid recovery key is not detected`() {
+ val otherKey = AccountEntropyPool.generate()
+ assertThat(RecoveryKeyDetector.containsRecoveryKey(otherKey.value, recoveryKey)).isFalse()
+ }
+
+ // These would be false positives under shape-only matching (charset + length, no checksum). Matching
+ // against the user's actual key rejects them.
+
+ @Test
+ fun `64 in-alphabet characters that are not the key are not detected`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey("g".repeat(64), recoveryKey)).isFalse()
+ }
+
+ @Test
+ fun `64 char sha256 hex digest is not detected`() {
+ assertThat(RecoveryKeyDetector.containsRecoveryKey("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recoveryKey)).isFalse()
+ }
+}