Merge de8439572f8f8fcbfeb823a4fa8bb332b3f7f029 into bb9cdbdbe9059b1eb9ea8581e2abd24e7ab1ca20

This commit is contained in:
Ben Zhe 2026-05-03 16:53:29 +08:00 committed by GitHub
commit 89add1542e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 342 additions and 7 deletions

3
.gitignore vendored
View File

@ -102,3 +102,6 @@ lint/tmp/
### Kotlin ###
.kotlin/
# VSCode
.vscode/

View File

@ -23,6 +23,7 @@ import org.fcitx.fcitx5.android.core.reloadPinyinDict
import org.fcitx.fcitx5.android.core.reloadQuickPhrase
import org.fcitx.fcitx5.android.daemon.FcitxDaemon
import org.fcitx.fcitx5.android.data.clipboard.ClipboardManager
import org.fcitx.fcitx5.android.data.otp.OtpManager
import org.fcitx.fcitx5.android.utils.Const
import org.fcitx.fcitx5.android.utils.desc
import org.fcitx.fcitx5.android.utils.descEquals
@ -93,8 +94,8 @@ class FcitxRemoteService : Service() {
Timber.d("unregisterClipboardEntryTransformer: ${transformer.desc}")
scope.launch {
clipboardTransformers.remove(transformer)
|| clipboardTransformers.removeAll { it.descEquals(transformer) }
|| return@launch
|| clipboardTransformers.removeAll { it.descEquals(transformer) }
|| return@launch
updateClipboardManager()
}
}
@ -106,6 +107,13 @@ class FcitxRemoteService : Service() {
override fun reloadQuickPhrase() {
FcitxDaemon.getFirstConnectionOrNull()?.runIfReady { reloadQuickPhrase() }
}
override fun updateOtp(otp: String?) {
Timber.d("updateOtp called: $otp")
otp?.let {
OtpManager.updateOtp(it)
}
}
}
override fun onCreate() {

View File

@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.data.otp
import org.fcitx.fcitx5.android.utils.WeakHashSet
object OtpManager {
fun interface OnOtpReceivedListener {
fun onOtpReceived(otp: String)
}
private val listeners = WeakHashSet<OnOtpReceivedListener>()
fun addOnOtpReceivedListener(listener: OnOtpReceivedListener) {
listeners.add(listener)
}
fun removeOnOtpReceivedListener(listener: OnOtpReceivedListener) {
listeners.remove(listener)
}
fun updateOtp(otp: String) {
listeners.forEach { it.onOtpReceived(otp) }
}
}

View File

@ -82,12 +82,15 @@ import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.min
import org.fcitx.fcitx5.android.data.otp.OtpManager
import kotlin.math.PI
import kotlin.math.sin
class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(),
InputBroadcastReceiver {
private var otpCode: String? = null
private val context by manager.context()
private val theme by manager.theme()
private val service by manager.inputMethodService()
@ -108,12 +111,14 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
private val preferredVoiceInput by prefs.keyboard.preferredVoiceInput
private var clipboardTimeoutJob: Job? = null
private var otpCodeTimeoutJob: Job? = null
private var isClipboardFresh: Boolean = false
private var isInlineSuggestionPresent: Boolean = false
private var isCapabilityFlagsPassword: Boolean = false
private var isKeyboardLayoutNumber: Boolean = false
private var isToolbarManuallyToggled: Boolean = false
private var isOtpCodeFresh: Boolean = false
private enum class NumberRowState { Auto, ForceShow, ForceHide }
@ -174,8 +179,33 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
}
}
private val otpListener = OtpManager.OnOtpReceivedListener { otp ->
if (idleUi.otpUi.text.text == null) return@OnOtpReceivedListener
service.lifecycleScope.launch {
idleUi.otpUi.text.text = otp
otpCode = otp
isOtpCodeFresh = true
launchOtpCodeTimeoutJob()
evalIdleUiState()
}
}
private fun launchOtpCodeTimeoutJob() {
otpCodeTimeoutJob?.cancel()
val timeout = clipboardItemTimeout.getValue() * 1000L
// never transition to ClipboardTimedOut state when timeout < 0
if (timeout < 0L) return
otpCodeTimeoutJob = service.lifecycleScope.launch {
delay(timeout)
otpCode = null
isOtpCodeFresh = false
otpCodeTimeoutJob = null
}
}
private fun evalIdleUiState(fromUser: Boolean = false) {
val newState = when {
isOtpCodeFresh -> IdleUi.State.Otp
numberRowState == NumberRowState.ForceShow -> IdleUi.State.NumberRow
isClipboardFresh -> IdleUi.State.Clipboard
isInlineSuggestionPresent -> IdleUi.State.InlineSuggestion
@ -334,6 +364,11 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
true
}
}
otpUi.suggestionView.setOnClickListener {
otpCode?.let(service::commitText)
otpCode = null
isOtpCodeFresh = false
evalIdleUiState()
numberRow.apply {
onCollapseListener = {
numberRowState = NumberRowState.ForceHide
@ -439,6 +474,7 @@ class KawaiiBarComponent : UniqueViewComponent<KawaiiBarComponent, FrameLayout>(
ClipboardManager.addOnUpdateListener(onClipboardUpdateListener)
clipboardSuggestion.registerOnChangeListener(onClipboardSuggestionUpdateListener)
clipboardItemTimeout.registerOnChangeListener(onClipboardTimeoutUpdateListener)
OtpManager.addOnOtpReceivedListener(otpListener)
}
override fun onStartInput(info: EditorInfo, capFlags: CapabilityFlags) {

View File

@ -50,7 +50,7 @@ class IdleUi(
) : Ui {
enum class State {
Empty, Toolbar, Clipboard, NumberRow, InlineSuggestion
Empty, Toolbar, Clipboard, NumberRow, InlineSuggestion, Otp
}
var currentState = State.Empty
@ -81,7 +81,7 @@ class IdleUi(
val buttonsUi = ButtonsBarUi(ctx, theme)
val clipboardUi = ClipboardSuggestionUi(ctx, theme)
val clipboardUi = ClipboardSuggestionUi(ctx, theme, false)
val numberRow = NumberRow(ctx, theme).apply {
visibility = View.GONE
@ -89,11 +89,14 @@ class IdleUi(
val inlineSuggestionsBar = InlineSuggestionsUi(ctx)
val otpUi = ClipboardSuggestionUi(ctx, theme, true)
private val animator = ViewAnimator(ctx).apply {
add(emptyBar, lParams(matchParent, matchParent))
add(buttonsUi.root, lParams(matchParent, matchParent))
add(clipboardUi.root, lParams(matchParent, matchParent))
add(inlineSuggestionsBar.root, lParams(matchParent, matchParent))
add(otpUi.root, lParams(matchParent, matchParent))
}
private val inAnimation by lazy {
@ -222,6 +225,7 @@ class IdleUi(
State.Clipboard -> animator.displayedChild = 2
State.NumberRow -> {}
State.InlineSuggestion -> animator.displayedChild = 3
State.Otp -> animator.displayedChild = 4
}
if (state == State.NumberRow) {
numberRow.keyActionListener = commonKeyActionListener.listener

View File

@ -31,11 +31,17 @@ import splitties.views.dsl.core.verticalMargin
import splitties.views.dsl.core.wrapContent
import splitties.views.imageDrawable
class ClipboardSuggestionUi(override val ctx: Context, private val theme: Theme) : Ui {
class ClipboardSuggestionUi(override val ctx: Context, private val theme: Theme, private val isOtp: Boolean) : Ui {
private val icon = imageView {
imageDrawable = drawable(R.drawable.ic_clipboard)!!.apply {
setTint(theme.altKeyTextColor)
imageDrawable = if (isOtp) {
drawable(R.drawable.ic_baseline_library_books_24)!!.apply {
setTint(theme.altKeyTextColor)
}
} else {
drawable(R.drawable.ic_clipboard)!!.apply {
setTint(theme.altKeyTextColor)
}
}
}

View File

@ -22,4 +22,6 @@ interface IFcitxRemoteService {
void reloadPinyinDict();
/** Reload fcitx quick phrase */
void reloadQuickPhrase();
/** Update OTP from plugin */
void updateOtp(in String Otp);
}

View File

@ -0,0 +1,39 @@
import com.android.build.gradle.tasks.MergeSourceSetFolders
import kotlinx.serialization.json.Json
plugins {
id("org.fcitx.fcitx5.android.app-convention")
id("org.fcitx.fcitx5.android.plugin-app-convention")
id("org.fcitx.fcitx5.android.build-metadata")
id("org.fcitx.fcitx5.android.data-descriptor")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "org.fcitx.fcitx5.android.plugin.smsotp"
defaultConfig {
applicationId = "org.fcitx.fcitx5.android.plugin.smsotp"
}
buildTypes {
release {
resValue("string", "app_name", "@string/app_name_release")
}
debug {
resValue("string", "app_name", "@string/app_name_debug")
}
}
sourceSets {
getByName("test") {
java.srcDir("src/main/java")
}
}
}
dependencies {
implementation(project(":lib:plugin-base"))
implementation(libs.kotlinx.serialization.json)
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:2.1.20")
}

View File

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="${mainApplicationId}.permission.IPC" />
<application
android:icon="@mipmap/ic_launcher_plugin_generic"
android:label="@string/app_name">
<service
android:name=".MainService"
android:directBootAware="true"
android:exported="true"
android:permission="${mainApplicationId}.permission.PLUGIN"
tools:targetApi="26">
<intent-filter>
<action android:name="${mainApplicationId}.plugin.SERVICE" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -0,0 +1,106 @@
package org.fcitx.fcitx5.android.plugin.smsotp
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import java.util.regex.Pattern
import org.fcitx.fcitx5.android.common.FcitxPluginService
import org.fcitx.fcitx5.android.common.ipc.FcitxRemoteConnection
import org.fcitx.fcitx5.android.common.ipc.bindFcitxRemoteService
fun extractOtp(message: String?): String? {
if (message == null) return null
val pattern =
Pattern.compile("""(?<!(\d|\d.|\d\s{1,4}|/))(\d{4,8}|\d{3}-\d{3})(?!(\d|\.\d|\s+\d|/|-))""")
val matcher = pattern.matcher(message)
val results = mutableListOf<String>()
val codePositions = mutableListOf<Pair<String, Int>>()
while (matcher.find()) {
val code = matcher.group(2).replace("-", "")
results.add(code)
codePositions.add(Pair(code, matcher.start(2)))
}
if (results.size > 1) {
val keywords = listOf("code", "otp", "", "", "コード", "코드", "код", "kodo")
val keywordPositions = keywords.flatMap { keyword ->
Regex(Regex.escape(keyword), RegexOption.IGNORE_CASE)
.findAll(message)
.map { it.range.first }
.toList()
}
if (keywordPositions.isNotEmpty()) {
// find the nearest one
val nearest = codePositions.minByOrNull { (code, pos) ->
keywordPositions.minOf { kp -> kotlin.math.abs(pos - kp) }
}
return nearest?.first
} else {
return results.firstOrNull()
}
}
else {
return results.firstOrNull()
}
}
class MainService : FcitxPluginService() {
private lateinit var connection: FcitxRemoteConnection
override fun start() {
registerSmsReceiver(this)
connection =
bindFcitxRemoteService(BuildConfig.MAIN_APPLICATION_ID) { log("Bind to fcitx remote") }
}
override fun stop() {
unregisterSmsReceiver(this)
unbindService(connection)
log("Unbind from fcitx remote")
}
private fun log(msg: String) {
Log.d("SmsOtpService", msg)
}
private var receiver: BroadcastReceiver? = null
private fun registerSmsReceiver(context: Context) {
if (receiver != null) return
receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messages = android.provider.Telephony.Sms.Intents.getMessagesFromIntent(intent)
val fullMessage = messages.joinToString("") { it.messageBody }
val code = extractOtp(fullMessage)
if (code != null) {
log("OTP Detected: $code")
tryInputOtp(context, code)
}
}
}
val filter = IntentFilter("android.provider.Telephony.SMS_RECEIVED")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(receiver, filter)
}
}
private fun unregisterSmsReceiver(context: Context) {
receiver?.let {
context.unregisterReceiver(it)
receiver = null
}
}
private fun tryInputOtp(context: Context, code: String) {
try {
connection.remoteService?.updateOtp(code)
} catch (e: Exception) {
log("updateOtp failed")
}
}
}

View File

@ -0,0 +1 @@
Read OTP from SMS message

View File

@ -0,0 +1 @@
Fcitx5 (SMS OTP Plugin)

View File

@ -0,0 +1 @@
自动获取短信验证码

View File

@ -0,0 +1 @@
小企鹅输入法 (短信验证码插件)

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name_debug">小企鹅输入法 (短信验证码插件 | 调试)</string>
<string name="app_name_release">小企鹅输入法 (短信验证码插件)</string>
<string name="description">自动获取短信验证码</string>
</resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Fcitx5 (SMS OTP Plugin)</string>
<string name="app_name_debug">Fcitx5 (SMS OTP Plugin | Debug)</string>
<string name="app_name_release">Fcitx5 (SMS OTP Plugin)</string>
<string name="description">Read OTP from SMS message</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<plugin xmlns="../../../../../pluginSchema.xsd">
<apiVersion>0.1</apiVersion>
<description>@string/description</description>
<hasService>true</hasService>
</plugin>

View File

@ -0,0 +1,55 @@
package org.fcitx.fcitx5.android.plugin.smsotp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SmSFormatTest {
@Test
fun testExtractOtp_basic() {
assertEquals("123456", extractOtp("Your OTP is 123456."))
assertEquals("987654", extractOtp("验证码987654"))
assertEquals("1234", extractOtp("Code: 1234"))
assertEquals("12345678", extractOtp("OTP: 12345678"))
assertEquals("123456", extractOtp("123-456"))
}
@Test
fun testExtractOtp_withNoise() {
assertEquals("123456", extractOtp("abc 123456 def"))
assertNull(extractOtp("abc.1234567/def"))
assertNull(extractOtp("2004/10/12, 12:00:00"))
}
@Test
fun testExtractOtp_invalid() {
assertNull(extractOtp(null))
assertNull(extractOtp("No code here"))
assertNull(extractOtp("123")) // too short
assertNull(extractOtp("123456789")) // too long
}
@Test
fun testExtractOtp_multipleCodes() {
assertEquals("123456", extractOtp("First: 123456, Second: 654321"))
}
@Test
fun testExtractOtp_realSamples() {
assertEquals("401572", extractOtp("WhatsApp code 401-572"))
assertEquals("28843", extractOtp("28843 e o codigo de confirmacao do Facebook de Pedro Davi #fb"))
assertEquals("932033", extractOtp("Your DENT code is: 932033"))
assertEquals("050475", extractOtp("Ваш код перевірки Poe: 050475. Не повідомляйте цей код іншим."))
assertEquals("429309", extractOtp("[抖音] 429309 is your verification code, valid for 5 minutes."))
assertEquals("8650", extractOtp("<#> 8650 is your Venmo phone verification code."))
assertEquals("060973", extractOtp("12306用户注册或既有用户手机核验专用验证码060973。如非本人直接访问12306请停止操作切勿将验证码提供给第三方。【铁路客服】"))
assertEquals("505513", extractOtp("您在付款为防诈骗千万不要告诉他人验证码505513商户为汇付天下金额80元。如有疑问请停止操作。短信编号245747【工商银行】"))
assertEquals("370000", extractOtp("任何向你索要验证码的都是骗子千万别给您正在向www尾号4832转账验证码370000100元。"))
assertEquals("927711", extractOtp("TAN to confirm mandate for Segpay UK Test Please enter TAN 927711 to set up your mandate 10751532bd3573241262f56b4e76a003 securely."))
assertEquals("121093", extractOtp("Twój kod do Tindera to 121093 Nie udostępniaj @tinder.com #121093"))
assertNull(extractOtp("OKXUSDTsuccessfully withdrawn: 132,619.89 【okxcash.com】Account: DTm888 Key: Swksf367"))
assertNull(extractOtp("You can also tap on this link to verify your phone: v.whatsapp.com/696948"))
assertNull(extractOtp("https://www.photosfromyourevent.com/4669/wauem8/"))
}
}

View File

@ -35,3 +35,4 @@ include(":plugin:chewing")
include(":plugin:sayura")
include(":plugin:jyutping")
include(":plugin:thai")
include(":plugin:sms-otp")