From b6f31b349daf6d4671e19b8d47bae535cd3025ad Mon Sep 17 00:00:00 2001
From: Rohan Lodhi <42321434+RohanLodhi@users.noreply.github.com>
Date: Sat, 6 Jun 2026 16:11:35 +0530
Subject: [PATCH 1/2] added auto detect feature
---
app/src/main/AndroidManifest.xml | 2 +
.../helium314/keyboard/latin/LatinIME.java | 25 ++-
.../keyboard/latin/OtpSuggestionManager.kt | 167 ++++++++++++++++++
.../keyboard/latin/settings/Defaults.kt | 1 +
.../keyboard/latin/settings/Settings.java | 1 +
.../latin/settings/SettingsValues.java | 3 +
.../settings/screens/TextCorrectionScreen.kt | 19 ++
app/src/main/res/layout/otp_suggestion.xml | 36 ++++
app/src/main/res/values/strings.xml | 6 +
9 files changed, 258 insertions(+), 2 deletions(-)
create mode 100644 app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt
create mode 100644 app/src/main/res/layout/otp_suggestion.xml
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2f7bf9bf..341501da 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,6 +15,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
+
+
diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
index 3095457b..b0121f98 100644
--- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java
+++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
@@ -182,6 +182,7 @@ public void onReceive(Context context, Intent intent) {
private GestureConsumer mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER;
private final ClipboardHistoryManager mClipboardHistoryManager = new ClipboardHistoryManager(this);
+ private final OtpSuggestionManager mOtpSuggestionManager = new OtpSuggestionManager(this);
private FloatingKeyboardManager mFloatingKeyboardManager;
@@ -711,6 +712,7 @@ public void onDestroy() {
mFloatingKeyboardManager.destroy();
}
mClipboardHistoryManager.onDestroy();
+ mOtpSuggestionManager.stop();
mDictionaryFacilitator.closeDictionaries();
mSettings.onDestroy();
unregisterReceiver(mRingerModeChangeReceiver);
@@ -1093,6 +1095,10 @@ void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restart
if (TRACE)
Debug.startMethodTracing("/data/trace/latinime");
+
+ // Listen for incoming SMS OTPs only while the keyboard is shown, and only if the
+ // user has opted in and granted the permission (handled inside the manager).
+ mOtpSuggestionManager.start();
}
@Override
@@ -1130,6 +1136,7 @@ void onFinishInputInternal() {
void onFinishInputViewInternal(final boolean finishingInput) {
super.onFinishInputView(finishingInput);
Log.i(TAG, "onFinishInputView");
+ mOtpSuggestionManager.stop();
cleanupInternalStateForFinishInput();
}
@@ -1718,6 +1725,20 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) {
* in suggestion strip.
* returns whether a clipboard suggestion has been set.
*/
+ /**
+ * Checks if a recent SMS OTP suggestion is available. If so, it is set in the suggestion strip.
+ * Returns whether an OTP suggestion has been set.
+ */
+ public boolean tryShowOtpSuggestion() {
+ if (!hasSuggestionStripView()) return false;
+ final View otpView = mOtpSuggestionManager.getOtpSuggestionView(mSuggestionStripView);
+ if (otpView != null) {
+ mSuggestionStripView.setExternalSuggestionView(otpView, true);
+ return true;
+ }
+ return false;
+ }
+
public boolean tryShowClipboardSuggestion() {
final View clipboardView = mClipboardHistoryManager.getClipboardSuggestionView(getCurrentInputEditorInfo(),
mSuggestionStripView);
@@ -1743,8 +1764,8 @@ public boolean tryShowClipboardSuggestion() {
@Override
public void setNeutralSuggestionStrip() {
final SettingsValues currentSettings = mSettings.getCurrent();
- if (tryShowClipboardSuggestion()) {
- // clipboard suggestion has been set
+ if (tryShowOtpSuggestion() || tryShowClipboardSuggestion()) {
+ // an external (OTP or clipboard) suggestion has been set
if (hasSuggestionStripView() && currentSettings.mAutoHideToolbar)
mSuggestionStripView.setToolbarVisibility(false);
return;
diff --git a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt
new file mode 100644
index 00000000..b800462d
--- /dev/null
+++ b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: GPL-3.0-only
+
+package helium314.keyboard.latin
+
+import android.Manifest
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Handler
+import android.os.Looper
+import android.provider.Telephony
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.core.view.isGone
+import helium314.keyboard.event.HapticEvent
+import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
+import helium314.keyboard.latin.common.ColorType
+import helium314.keyboard.latin.databinding.OtpSuggestionBinding
+import helium314.keyboard.latin.permissions.PermissionsUtil
+import helium314.keyboard.latin.utils.Log
+import helium314.keyboard.latin.utils.ToolbarKey
+
+/**
+ * Optional, opt-in helper that surfaces one-time passcodes (OTPs) from incoming SMS as a
+ * suggestion-strip chip the user can tap to insert (similar to the clipboard/screenshot
+ * suggestions, see [ClipboardHistoryManager.getClipboardSuggestionView]).
+ *
+ * Privacy: this never reads the existing SMS inbox. A [BroadcastReceiver] is registered only
+ * while the keyboard input view is shown and only when the feature is enabled and the
+ * RECEIVE_SMS permission has been granted, so the keyboard only ever sees messages that arrive
+ * while the user is actively typing.
+ */
+class OtpSuggestionManager(private val latinIME: LatinIME) {
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private var otpSuggestionView: View? = null
+ private var dontShowCurrentSuggestion = false
+
+ private var latestOtp: String? = null
+ private var latestOtpTimestamp = 0L
+
+ private var isRegistered = false
+ private val smsReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
+ val body = try {
+ Telephony.Sms.Intents.getMessagesFromIntent(intent)
+ ?.joinToString(separator = "") { it.messageBody ?: it.displayMessageBody ?: "" }
+ ?: return
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to read incoming SMS", e)
+ return
+ }
+ val otp = extractOtp(body) ?: return
+ latestOtp = otp
+ latestOtpTimestamp = System.currentTimeMillis()
+ dontShowCurrentSuggestion = false
+ // Refresh the strip on the main thread so the chip appears immediately,
+ // mirroring the screenshot-observer path in ClipboardHistoryManager.
+ mainHandler.post {
+ if (latinIME.isInputViewShown) latinIME.setNeutralSuggestionStrip()
+ }
+ }
+ }
+
+ /** Register the SMS receiver if the feature is enabled and the permission is granted. Idempotent. */
+ fun start() {
+ if (isRegistered) return
+ if (!latinIME.mSettings.current.mAutoReadOtp) return
+ if (!PermissionsUtil.checkAllPermissionsGranted(latinIME, Manifest.permission.RECEIVE_SMS)) return
+ try {
+ ContextCompat.registerReceiver(
+ latinIME,
+ smsReceiver,
+ IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION),
+ ContextCompat.RECEIVER_NOT_EXPORTED // SMS_RECEIVED is a protected system broadcast
+ )
+ isRegistered = true
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not register SMS receiver", e)
+ }
+ }
+
+ /** Unregister the receiver. Idempotent. Called when the input view is hidden or the IME is destroyed. */
+ fun stop() {
+ if (!isRegistered) return
+ try {
+ latinIME.unregisterReceiver(smsReceiver)
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not unregister SMS receiver", e)
+ }
+ isRegistered = false
+ }
+
+ /**
+ * Build the OTP suggestion chip if a recent code is available, else null.
+ * Called from [LatinIME.tryShowOtpSuggestion].
+ */
+ fun getOtpSuggestionView(parent: ViewGroup?): View? {
+ otpSuggestionView = null
+ if (parent == null) return null
+ if (!latinIME.mSettings.current.mAutoReadOtp) return null
+ if (dontShowCurrentSuggestion) return null
+ val otp = latestOtp ?: return null
+ if (System.currentTimeMillis() - latestOtpTimestamp > RECENT_OTP_MILLIS) return null
+
+ val binding = OtpSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false)
+ val textView = binding.otpSuggestionText
+ latinIME.mSettings.getCustomTypeface()?.let { textView.typeface = it }
+ textView.text = otp
+ val icon = latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.NUMPAD.name.lowercase())
+ textView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null)
+ textView.setOnClickListener {
+ dontShowCurrentSuggestion = true
+ latinIME.onTextInput(otp)
+ AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS)
+ binding.root.isGone = true
+ }
+ val closeButton = binding.otpSuggestionClose
+ closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase()))
+ closeButton.setOnClickListener { removeOtpSuggestion() }
+
+ val colors = latinIME.mSettings.current.mColors
+ textView.setTextColor(colors.get(ColorType.KEY_TEXT))
+ icon?.let { colors.setColor(it, ColorType.KEY_ICON) }
+ colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON)
+ colors.setBackground(binding.root, ColorType.CLIPBOARD_SUGGESTION_BACKGROUND)
+
+ otpSuggestionView = binding.root
+ return otpSuggestionView
+ }
+
+ private fun removeOtpSuggestion() {
+ dontShowCurrentSuggestion = true
+ val view = otpSuggestionView ?: return
+ if (view.parent != null && !view.isGone) {
+ latinIME.setNeutralSuggestionStrip()
+ latinIME.mHandler.postResumeSuggestions(false)
+ }
+ view.isGone = true
+ }
+
+ /**
+ * Extract an OTP from an SMS body. Keyword-gated to limit false positives: a 4-8 digit group is
+ * only treated as a code when the message mentions a code-like keyword, or when it is the single
+ * such group in the message.
+ */
+ private fun extractOtp(body: String): String? {
+ if (body.isBlank()) return null
+ val groups = codeRegex.findAll(body).map { it.value }.toList()
+ if (groups.isEmpty()) return null
+ return if (otpKeywordRegex.containsMatchIn(body) || groups.size == 1) groups.first() else null
+ }
+
+ companion object {
+ private const val TAG = "OtpSuggestionManager"
+ private const val RECENT_OTP_MILLIS = 60 * 1000L // OTP chip is offered for 60s after arrival
+ private val codeRegex = Regex("\\b\\d{4,8}\\b")
+ private val otpKeywordRegex = Regex(
+ "otp|code|passcode|password|pin|verification|verify|one[- ]?time|2fa|auth",
+ RegexOption.IGNORE_CASE
+ )
+ }
+}
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
index 5bbb017d..fafe652d 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
@@ -112,6 +112,7 @@ object Defaults {
const val PREF_SUGGEST_CLIPBOARD_CONTENT = true
const val PREF_SUGGEST_SCREENSHOTS = false
const val PREF_COMPRESS_SCREENSHOTS = true
+ const val PREF_AUTO_READ_OTP = false
const val PREF_GESTURE_INPUT = true
const val PREF_VIBRATION_DURATION_SETTINGS = -1
const val PREF_VIBRATION_AMPLITUDE_SETTINGS = -1
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
index f5e93229..058f1d82 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
@@ -167,6 +167,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history";
public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots";
public static final String PREF_COMPRESS_SCREENSHOTS = "compress_screenshots";
+ public static final String PREF_AUTO_READ_OTP = "auto_read_otp";
public static final String PREF_CLIPBOARD_HISTORY_RETENTION_TIME = "clipboard_history_retention_time";
public static final String PREF_CLIPBOARD_HISTORY_PINNED_FIRST = "clipboard_history_pinned_first";
public static final String PREF_CLIPBOARD_FOLD_PINNED = "clipboard_fold_pinned";
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
index 1bc0629d..7f398c4c 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
@@ -167,6 +167,7 @@ public class SettingsValues {
private final boolean mOverrideShowingSuggestions;
public final boolean mSuggestClipboardContent;
public final boolean mSuggestScreenshots;
+ public final boolean mAutoReadOtp;
public final boolean mCompressScreenshots;
public final SettingsValuesForSuggestion mSettingsValuesForSuggestion;
public final boolean mIncognitoModeEnabled;
@@ -269,6 +270,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
Defaults.PREF_SUGGEST_CLIPBOARD_CONTENT);
mSuggestScreenshots = prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS,
Defaults.PREF_SUGGEST_SCREENSHOTS);
+ mAutoReadOtp = prefs.getBoolean(Settings.PREF_AUTO_READ_OTP,
+ Defaults.PREF_AUTO_READ_OTP);
mCompressScreenshots = prefs.getBoolean(Settings.PREF_COMPRESS_SCREENSHOTS,
Defaults.PREF_COMPRESS_SCREENSHOTS);
mDoubleSpacePeriodTimeout = 1100; // ms
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
index 32d629df..4be56769 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
@@ -250,6 +250,25 @@ fun createCorrectionSettings(context: Context) = listOf(
) {
SwitchPreference(it, Defaults.PREF_COMPRESS_SCREENSHOTS)
},
+ Setting(context, Settings.PREF_AUTO_READ_OTP,
+ R.string.auto_read_otp, R.string.auto_read_otp_summary
+ ) { setting ->
+ val activity = LocalContext.current.getActivity() ?: return@Setting
+ var granted by remember { mutableStateOf(PermissionsUtil.checkAllPermissionsGranted(activity, Manifest.permission.RECEIVE_SMS)) }
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ granted = it
+ if (granted)
+ activity.prefs().edit { putBoolean(setting.key, true) }
+ }
+ SwitchPreference(setting, Defaults.PREF_AUTO_READ_OTP,
+ allowCheckedChange = {
+ if (it && !granted) {
+ launcher.launch(Manifest.permission.RECEIVE_SMS)
+ false
+ } else true
+ }
+ )
+ },
Setting(context, Settings.PREF_USE_CONTACTS,
R.string.use_contacts_dict, R.string.use_contacts_dict_summary
) { setting ->
diff --git a/app/src/main/res/layout/otp_suggestion.xml b/app/src/main/res/layout/otp_suggestion.xml
new file mode 100644
index 00000000..c8276c63
--- /dev/null
+++ b/app/src/main/res/layout/otp_suggestion.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cb0c379d..69456e5a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -180,6 +180,12 @@
Suggest recent screenshots
Show recently taken screenshots as a suggestion
+
+ Auto-read OTP from SMS
+
+ Show the code from an incoming SMS as a suggestion you can tap to insert
+
+ Dismiss OTP suggestion
Compress screenshot suggestions
From 7131d6253aac5462806ae45b4a71a1ec961059ce Mon Sep 17 00:00:00 2001
From: Rohan Lodhi <42321434+RohanLodhi@users.noreply.github.com>
Date: Sat, 6 Jun 2026 17:03:51 +0530
Subject: [PATCH 2/2] changed registration flag
---
app/src/main/java/helium314/keyboard/latin/LatinIME.java | 3 ++-
.../java/helium314/keyboard/latin/OtpSuggestionManager.kt | 5 ++++-
.../keyboard/settings/screens/TextCorrectionScreen.kt | 1 +
3 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
index b0121f98..d3abb54f 100644
--- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java
+++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java
@@ -1733,7 +1733,8 @@ public boolean tryShowOtpSuggestion() {
if (!hasSuggestionStripView()) return false;
final View otpView = mOtpSuggestionManager.getOtpSuggestionView(mSuggestionStripView);
if (otpView != null) {
- mSuggestionStripView.setExternalSuggestionView(otpView, true);
+ // false: the OTP chip layout already has its own close button (wired in the manager)
+ mSuggestionStripView.setExternalSuggestionView(otpView, false);
return true;
}
return false;
diff --git a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt
index b800462d..669c3648 100644
--- a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt
+++ b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt
@@ -76,7 +76,10 @@ class OtpSuggestionManager(private val latinIME: LatinIME) {
latinIME,
smsReceiver,
IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION),
- ContextCompat.RECEIVER_NOT_EXPORTED // SMS_RECEIVED is a protected system broadcast
+ // EXPORTED is required: SMS_RECEIVED is delivered by the system/telephony process
+ // (an external sender), so a NOT_EXPORTED receiver never receives it. This is safe
+ // because SMS_RECEIVED is a protected broadcast that only the system can send.
+ ContextCompat.RECEIVER_EXPORTED
)
isRegistered = true
} catch (e: Exception) {
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
index 4be56769..a233d286 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt
@@ -91,6 +91,7 @@ fun TextCorrectionScreen(
Settings.PREF_SUGGEST_SCREENSHOTS,
if (prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS))
Settings.PREF_COMPRESS_SCREENSHOTS else null,
+ Settings.PREF_AUTO_READ_OTP,
Settings.PREF_USE_CONTACTS,
Settings.PREF_USE_APPS
)