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 f62fa5a7..80842df0 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,23 @@ class OtpDetector { "transaction code", "confirm code", "confirmation code", - "code" + "code", + "验证码", + "登录码", + "安全码", + "校验码", + "密码", + "动态码", + "一次性密码", + "授权码", + "访问码", + "重置码", + "交易码", + "确认码", + "认证码", + "OTP", + "2FA", + "MFA" ).map { it.lowercase() } private val safetyKeywords = listOf( @@ -46,12 +62,25 @@ class OtpDetector { "valid for", "expires in", "expires within", - "expires after" + "expires after", + "请勿分享", + "不要分享", + "切勿分享", + "请勿透露", + "请勿转发", + "保密", + "有效时间", + "有效期为", + "将在", + "内过期", + "后过期" ).map { it.lowercase() } private val moneyIndicators = listOf( "rs", "inr", "usd", "eur", "gbp", "₹", "$", "€", "£", "balance", - "amount", "debited", "credited", "txn", "transaction id", "order id" + "amount", "debited", "credited", "txn", "transaction id", "order id", + "人民币", "元", "¥", "金额", "余额", "转入", "转出", "交易", "订单", "交易号", "订单号", + "已扣除", "已入账", "支付", "收款" ).map { it.lowercase() } fun detect(rawMessage: String): OtpDetectionResult { @@ -127,54 +156,79 @@ class OtpDetector { input.replace(Regex("\\s+"), " ").trim() private fun extractCandidates(message: String): List { - val candidates = mutableListOf() - - // 1) Pure numeric chunks 3–10 digits - val numericRegex = Regex("\\b\\d{3,10}\\b") - numericRegex.findAll(message).forEach { match -> - val code = match.value - candidates += Candidate( - code = code, - startIndex = match.range.first, - endIndex = match.range.last, - isNumeric = true + val candidates = mutableMapOf() + + val patterns = listOf( + PatternInfo( + Regex("(\\p{IsHan}+)(\\d{3,10})"), + 2, + true, + 1.5 + ), + PatternInfo( + Regex("(^|\\s|\\p{P}|\\p{IsHan})(\\d{3,10})(\\p{P}|\\s|$|\\p{IsHan})"), + 2, + true, + 1.0 + ), + PatternInfo( + Regex("(^|\\s|\\p{P}|\\p{IsHan})(\\d{2,4}[\\s-]\\d{2,4})([\\s-]\\d{2,4})*(\\p{P}|\\s|$|\\p{IsHan})"), + 2, + true, + 2.0 + ), + PatternInfo( + Regex("(^|\\s|\\p{P}|\\p{IsHan})([0-9A-Za-z]{4,10})(\\p{P}|\\s|$|\\p{IsHan})"), + 2, + false, + 0.8 ) - } - - // 2) Numeric with a single space or dash (e.g., "123 456", "12-34-56") - val spacedRegex = Regex("\\b\\d{2,4}([\\s-]\\d{2,4})+\\b") - spacedRegex.findAll(message).forEach { match -> - val raw = match.value - val normalizedCode = raw.replace("[\\s-]".toRegex(), "") - // Avoid duplicating codes we already saw as a plain numeric chunk - if (normalizedCode.length in 4..8 && candidates.none { it.code == normalizedCode }) { - candidates += Candidate( - code = normalizedCode, - startIndex = match.range.first, - endIndex = match.range.last, - isNumeric = true - ) - } - } + ) - // 3) Alphanumeric tokens 4–10 chars, at least 2 digits - val alnumRegex = Regex("\\b[0-9A-Za-z]{4,10}\\b") - alnumRegex.findAll(message).forEach { match -> - val token = match.value - if (token.any { it.isDigit() } && token.count { it.isDigit() } >= 2) { - // Skip if it's purely numeric; we already captured those - if (!token.all { it.isDigit() }) { - candidates += Candidate( - code = token, - startIndex = match.range.first, - endIndex = match.range.last, - isNumeric = false - ) + for (patternInfo in patterns) { + patternInfo.regex.findAll(message).forEach { match -> + val code = match.groupValues[patternInfo.groupIndex] + val normalizedCode = code.replace("[\\s-]".toRegex(), "") + + if (isValidCandidate(normalizedCode, patternInfo.isNumeric)) { + val startIndex = match.range.first + match.groupValues[1].length + val endIndex = startIndex + code.length - 1 + + val existing = candidates[normalizedCode] + if (existing == null || patternInfo.priority > existing.score) { + candidates[normalizedCode] = Candidate( + code = normalizedCode, + startIndex = startIndex, + endIndex = endIndex, + isNumeric = patternInfo.isNumeric, + score = patternInfo.priority + ) + } } } } - return candidates + return candidates.values.toList() + } + + private data class PatternInfo( + val regex: Regex, + val groupIndex: Int, + val isNumeric: Boolean, + val priority: Double + ) + + private fun isValidCandidate(code: String, isNumeric: Boolean): Boolean { + val length = code.length + + if (isNumeric) { + return length in 3..10 + } else { + return length in 4..10 && + code.any { it.isDigit() } && + code.count { it.isDigit() } >= 2 && + !code.all { it.isDigit() } + } } private fun scoreCandidate( @@ -206,9 +260,8 @@ class OtpDetector { score -= 1.5 } - // Local context: the line containing the candidate + // Local context: line containing the candidate val lineInfo = extractLineContext(original, candidate.startIndex, candidate.endIndex) - val lineLower = lineInfo.line.lowercase() // If the line is mostly just the code -> strong hint val trimmedLine = lineInfo.line.trim() @@ -216,16 +269,17 @@ class OtpDetector { score += 2.5 } - // Typical OTP line patterns + // Typical OTP line patterns - support both English and Chinese if (Regex( - "(otp|code|password|passcode)", + "(otp|code|password|passcode|验证码|登录码|安全码|校验码|动态码|密码|一次性密码|授权码|认证码)", RegexOption.IGNORE_CASE ).containsMatchIn(lineInfo.line) ) { score += 2.0 } - if (Regex("(:|is|=)\\s*${Regex.escape(candidate.code)}").containsMatchIn(lineInfo.line)) { + // Support both English and Chinese separators + if (Regex("(:|is|=|是|为|:)\\s*${Regex.escape(candidate.code)}").containsMatchIn(lineInfo.line)) { score += 1.5 } @@ -348,3 +402,4 @@ class OtpDetector { return confidence.coerceIn(0.0, 1.0) } } + 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 00000000..f2cdca6e --- /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 07b16c13..664d946f 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,7 @@ 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.common.util.extensions.resolveThemeColor import org.prauga.messages.common.util.extensions.setTint import org.prauga.messages.databinding.ConversationListItemBinding @@ -133,6 +134,32 @@ 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) { + // Choose appropriate tag text based on language + val locale = context.resources.configuration.locales[0] + val otpText = if (locale.language == "zh") { + "验证码" // Show "验证码" for Chinese locale + } else { + "OTP" // Show "OTP" for other locales + } + 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) + } } override fun getItemId(position: Int): Long { @@ -160,3 +187,4 @@ class ConversationsAdapter @Inject constructor( } } + 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 00000000..e7ab3e35 --- /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 6a805424..38719413 100644 --- a/presentation/src/main/res/layout/conversation_list_item.xml +++ b/presentation/src/main/res/layout/conversation_list_item.xml @@ -71,11 +71,34 @@ 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" /> + + + +