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 )