Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_USER_DICTIONARY" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<!-- Optional: only requested at runtime when the user enables the "Auto-read OTP" feature. -->
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
Expand Down
26 changes: 24 additions & 2 deletions app/src/main/java/helium314/keyboard/latin/LatinIME.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -711,6 +712,7 @@ public void onDestroy() {
mFloatingKeyboardManager.destroy();
}
mClipboardHistoryManager.onDestroy();
mOtpSuggestionManager.stop();
mDictionaryFacilitator.closeDictionaries();
mSettings.onDestroy();
unregisterReceiver(mRingerModeChangeReceiver);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1130,6 +1136,7 @@ void onFinishInputInternal() {
void onFinishInputViewInternal(final boolean finishingInput) {
super.onFinishInputView(finishingInput);
Log.i(TAG, "onFinishInputView");
mOtpSuggestionManager.stop();
cleanupInternalStateForFinishInput();
}

Expand Down Expand Up @@ -1718,6 +1725,21 @@ 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) {
// false: the OTP chip layout already has its own close button (wired in the manager)
mSuggestionStripView.setExternalSuggestionView(otpView, false);
return true;
}
return false;
}

public boolean tryShowClipboardSuggestion() {
final View clipboardView = mClipboardHistoryManager.getClipboardSuggestionView(getCurrentInputEditorInfo(),
mSuggestionStripView);
Expand All @@ -1743,8 +1765,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;
Expand Down
170 changes: 170 additions & 0 deletions app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// 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),
// 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) {
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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -250,6 +251,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 ->
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/res/layout/otp_suggestion.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-License-Identifier: GPL-3.0-only
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:background="@drawable/clipboard_suggestion_background"
android:gravity="center"
android:paddingEnd="12dp"
android:layout_width="wrap_content"
android:layout_height="match_parent"
tools:ignore="RtlSymmetry">
<TextView
android:id="@+id/otp_suggestion_text"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="match_parent"
android:drawablePadding="3dp"
android:paddingHorizontal="12dp"
android:hapticFeedbackEnabled="false"
android:soundEffectsEnabled="false"
android:singleLine="true"
android:gravity="center"
android:ellipsize="end"
android:textStyle="bold"
style="?android:attr/textAppearanceSmall" />
<ImageView
android:id="@+id/otp_suggestion_close"
android:contentDescription="@string/spoken_otp_suggestion"
android:src="@drawable/ic_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center" />

</LinearLayout>
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@
<string name="suggest_screenshots">Suggest recent screenshots</string>
<!-- Description for the "suggest recent screenshots" option. This makes recent screenshots visible in the suggestion strip view. -->
<string name="suggest_screenshots_summary">Show recently taken screenshots as a suggestion</string>
<!-- Option to enable showing one-time passcodes (OTPs) from incoming SMS as a suggestion. -->
<string name="auto_read_otp">Auto-read OTP from SMS</string>
<!-- Description for the "auto_read_otp" option. Requires SMS permission; only reads messages that arrive while the keyboard is open. -->
<string name="auto_read_otp_summary">Show the code from an incoming SMS as a suggestion you can tap to insert</string>
<!-- Spoken description for the button that dismisses the OTP suggestion. -->
<string name="spoken_otp_suggestion">Dismiss OTP suggestion</string>
<!-- Option to compress screenshot images when suggesting them. -->
<string name="compress_screenshots">Compress screenshot suggestions</string>
<!-- Description for the "compress screenshots" option. -->
Expand Down