diff --git a/app/src/main/java/org/prauga/messages/app/utils/OtpDetector.kt b/app/src/main/java/org/prauga/messages/app/utils/OtpDetector.kt index f62fa5a72..a0c0943a6 100644 --- a/app/src/main/java/org/prauga/messages/app/utils/OtpDetector.kt +++ b/app/src/main/java/org/prauga/messages/app/utils/OtpDetector.kt @@ -33,7 +33,14 @@ class OtpDetector { "transaction code", "confirm code", "confirmation code", - "code" + "code", + "验证码", + "登录码", + "安全码", + "动态码", + "一次性密码", + "二次验证码", + "两步验证" ).map { it.lowercase() } private val safetyKeywords = listOf( @@ -46,7 +53,14 @@ class OtpDetector { "valid for", "expires in", "expires within", - "expires after" + "expires after", + "请勿分享", + "切勿分享", + "不要分享", + "保密", + "有效期", + "将在", + "分钟内过期" ).map { it.lowercase() } private val moneyIndicators = listOf( @@ -65,11 +79,14 @@ class OtpDetector { val hasOtpKeyword = otpKeywords.any { lower.contains(it) } val hasSafetyKeyword = safetyKeywords.any { lower.contains(it) } + + // Check if it contains characters related to Chinese CAPTCHAs + val hasChineseOtpChars = lower.contains("验证码") || lower.contains("登录") || lower.contains("码") val candidates = extractCandidates(normalized) if (candidates.isEmpty()) { - val reason = if (hasOtpKeyword) { + val reason = if (hasOtpKeyword || hasChineseOtpChars) { "Contains OTP-like keywords but no numeric/alphanumeric candidate code found" } else { "No OTP-like keywords and no candidate code found" @@ -86,7 +103,7 @@ class OtpDetector { val best = scored.maxByOrNull { it.score }!! - val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword) + val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars) val isOtp = globalConfidence >= 0.6 @@ -129,8 +146,8 @@ class OtpDetector { private fun extractCandidates(message: String): List { val candidates = mutableListOf() - // 1) Pure numeric chunks 3–10 digits - val numericRegex = Regex("\\b\\d{3,10}\\b") + // 1) Pure numeric chunks 3–10 digits (with word boundary support for Chinese) + val numericRegex = Regex("(?:\\b|^|(?<=[^0-9]))\\d{3,10}(?:\\b|$|(?=[^0-9]))") numericRegex.findAll(message).forEach { match -> val code = match.value candidates += Candidate( @@ -335,14 +352,15 @@ class OtpDetector { private fun computeGlobalConfidence( best: Candidate, hasOtpKeyword: Boolean, - hasSafetyKeyword: Boolean + hasSafetyKeyword: Boolean, + hasChineseOtpChars: Boolean ): Double { var confidence = 0.0 // Base on score; tuned experimentally confidence += (best.score / 8.0).coerceIn(0.0, 1.0) - if (hasOtpKeyword) confidence += 0.15 + if (hasOtpKeyword || hasChineseOtpChars) confidence += 0.15 if (hasSafetyKeyword) confidence += 0.15 return confidence.coerceIn(0.0, 1.0) diff --git a/app/src/main/java/org/prauga/messages/app/utils/ParcelDetector.kt b/app/src/main/java/org/prauga/messages/app/utils/ParcelDetector.kt new file mode 100644 index 000000000..7db4acfec --- /dev/null +++ b/app/src/main/java/org/prauga/messages/app/utils/ParcelDetector.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 Saalim Quadri + */ +package org.prauga.messages.app.utils + +import java.util.regex.Matcher +import java.util.regex.Pattern + +data class ParcelDetectionResult( + val address: String, + val code: String, + val success: Boolean, + val reason: String +) + +class ParcelDetector { + // 使用正则表达式来匹配地址和取件码(1个或多个取件码)优先匹配快递柜 + private val lockerPattern: Pattern = + Pattern.compile("""(?i)([0-9]+)号(?:柜|快递柜|丰巢柜|蜂巢柜|熊猫柜|兔喜快递柜)""") + private val addressPattern: Pattern = + Pattern.compile("""(?i)(地址|收货地址|送货地址|位于|放至|已到达|到达|已到|送达|到|已放入|已存放至|已存放|放入)[\s\S]*?([\w\s-]+?(?:门牌|驿站|,|,|。|$)\d*)""") + private val codePattern: Pattern = Pattern.compile( + """(?i)(取件码为|提货号为|取货码为|提货码为|取件码(|提货号(|取货码(|提货码(|取件码『|提货号『|取货码『|提货码『|取件码【|提货号【|取货码【|提货码【|取件码\(|提货号\(|取货码\(|提货码\(|取件码\[|提货号\[|取货码\[|提货码\[|取件码|提货号|取货码|提货码|凭|快递|京东|天猫|中通|顺丰|韵达|德邦|菜鸟|拼多多|EMS|闪送|美团|饿了么|盒马|叮咚买菜|UU跑腿|签收码|签收编号|操作码|提货编码|收货编码|签收编码|取件編號|提貨號碼|運單碼|快遞碼|快件碼|包裹碼|貨品碼)\s*[A-Za-z0-9\s-]{2,}(?:[,,、][A-Za-z0-9\s-]{2,})*""" + ) + + + + fun detectParcel(sms: String): ParcelDetectionResult { + var foundAddress = "" + var foundCode = "" + + // 优先匹配柜号地址,其次默认规则 + val lockerMatcher: Matcher = lockerPattern.matcher(sms) + foundAddress = if (lockerMatcher.find()) lockerMatcher.group().toString() ?: "" else "" + + if (foundAddress.isEmpty()) { + val addressMatcher: Matcher = addressPattern.matcher(sms) + var longestAddress = "" + while (addressMatcher.find()) { + val currentAddress = addressMatcher.group(2)?.toString() ?: "" + if (currentAddress.length > longestAddress.length) { + longestAddress = currentAddress + } + } + foundAddress = longestAddress + } + + val codeMatcher: Matcher = codePattern.matcher(sms) + while (codeMatcher.find()) { + val match = codeMatcher.group(0) + // 进一步将匹配到的内容按分隔符拆分成单个取件码 + val codes = match?.split(Regex("[,,、]")) + foundCode = codes?.joinToString(", ") { it.trim() } ?: "" + foundCode = foundCode.replace(Regex("[^A-Za-z0-9-, ]"), "") + } + foundAddress = foundAddress.replace(Regex("[,,。]"), "") // 移除所有标点和符号 + foundAddress = foundAddress.replace("取件", "") // 移除"取件" + + val success = foundAddress.isNotEmpty() && foundCode.isNotEmpty() + val reason = if (success) { + "Successfully detected parcel: address='$foundAddress', code='$foundCode'" + } else { + when { + foundAddress.isEmpty() && foundCode.isEmpty() -> "No address and no code found" + foundAddress.isEmpty() -> "No address found" + else -> "No code found" + } + } + + return ParcelDetectionResult(foundAddress, foundCode, success, reason) + } + + +} diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 000000000..f2cdca6e8 --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,5 @@ + + + 复制验证码 %s + OTP %s 已复制到剪贴板 + diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt index 07b16c13f..f38c46b4a 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt @@ -31,6 +31,8 @@ import org.prauga.messages.common.base.QkRealmAdapter import org.prauga.messages.common.base.QkViewHolder import org.prauga.messages.common.util.Colors import org.prauga.messages.common.util.DateFormatter +import org.prauga.messages.common.util.OtpDetector +import org.prauga.messages.app.utils.ParcelDetector import org.prauga.messages.common.util.extensions.resolveThemeColor import org.prauga.messages.common.util.extensions.setTint import org.prauga.messages.databinding.ConversationListItemBinding @@ -76,14 +78,14 @@ class ConversationsAdapter @Inject constructor( return QkViewHolder(binding.root).apply { binding.root.setOnClickListener { - val conversation = getItem(adapterPosition) ?: return@setOnClickListener + val conversation = getItem(bindingAdapterPosition) ?: return@setOnClickListener when (toggleSelection(conversation.id, false)) { true -> binding.root.isActivated = isSelected(conversation.id) false -> navigator.showConversation(conversation.id) } } binding.root.setOnLongClickListener { - val conversation = getItem(adapterPosition) ?: return@setOnLongClickListener true + val conversation = getItem(bindingAdapterPosition) ?: return@setOnLongClickListener true toggleSelection(conversation.id) binding.root.isActivated = isSelected(conversation.id) true @@ -133,6 +135,46 @@ class ConversationsAdapter @Inject constructor( disposables.add(disposable) binding.pinned.isVisible = conversation.pinned + + // Check if the conversation contains OTP (One Time Password) + // 1. Initialize OTP detector + val otpDetector = OtpDetector() + // 2. Get message snippet, handle possible null value + val snippet = conversation.snippet ?: "" + // 3. Perform OTP detection + val otpResult = otpDetector.detect(snippet) + // 4. Show or hide OTP tag based on detection result + binding.otpTag.isVisible = otpResult.isOtp + + if (otpResult.isOtp) { + // Get OTP tag text from string resources (Android will automatically use the appropriate translation) + val otpText = context.getString(R.string.otp_tag) + binding.otpTag.text = otpText + + // Set OTP tag background and text color to match theme + val theme = colors.theme(recipient).theme + binding.otpTag.background.setTint(theme) + binding.otpTag.setTextColor(colors.theme(recipient).textPrimary) + } + + // Check if the conversation contains parcel pickup code + // 1. Initialize Parcel detector + val parcelDetector = ParcelDetector() + // 2. Perform parcel code detection + val parcelResult = parcelDetector.detectParcel(snippet) + // 3. Show or hide parcel tag based on detection result + binding.parcelTag.isVisible = parcelResult.success + + if (parcelResult.success) { + // Get parcel tag text from string resources (Android will automatically use the appropriate translation) + val parcelText = context.getString(R.string.parcel_tag) + binding.parcelTag.text = parcelText + + // Set parcel tag background and text color to match theme + val theme = colors.theme(recipient).theme + binding.parcelTag.background.setTint(theme) + binding.parcelTag.setTextColor(colors.theme(recipient).textPrimary) + } } override fun getItemId(position: Int): Long { diff --git a/presentation/src/main/res/drawable/otp_tag_background.xml b/presentation/src/main/res/drawable/otp_tag_background.xml new file mode 100644 index 000000000..e7ab3e350 --- /dev/null +++ b/presentation/src/main/res/drawable/otp_tag_background.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/conversation_list_item.xml b/presentation/src/main/res/layout/conversation_list_item.xml index 6a805424a..b25285cc4 100644 --- a/presentation/src/main/res/layout/conversation_list_item.xml +++ b/presentation/src/main/res/layout/conversation_list_item.xml @@ -71,11 +71,57 @@ android:maxLines="2" android:minLines="2" app:layout_constraintBottom_toTopOf="@id/divider" - app:layout_constraintEnd_toStartOf="@id/scheduled" + app:layout_constraintEnd_toStartOf="@id/otpTag" app:layout_constraintStart_toStartOf="@id/title" app:layout_constraintTop_toBottomOf="@id/title" tools:text="@tools:sample/lorem/random" /> + + + + + + + + 抱歉,加载语音转文字提供程序时出错。 朗读你的消息 分享失败 + + + 验证码 + 取件码 diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 0eb92a543..ab59f73ab 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -597,4 +597,8 @@ Speak your message Oops! Something went wrong while sharing + + + OTP + Parcel