From 8d1ed7746b8626ce4c80a9dcc239437c14c2dfb9 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:35:30 +0800 Subject: [PATCH 01/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 155 ++++++++++++------ 1 file changed, 105 insertions(+), 50 deletions(-) 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..80842df07 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) } } + From e4799cc802b7fc678b5978236f488d056e769de6 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:35:53 +0800 Subject: [PATCH 02/21] Add files via upload --- app/src/main/res/values-zh/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 app/src/main/res/values-zh/strings.xml 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 已复制到剪贴板 + From ffd55c8517ae65662f62e8189735e4f333db545b Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:37:41 +0800 Subject: [PATCH 03/21] Update conversation_list_item.xml --- .../res/layout/conversation_list_item.xml | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/presentation/src/main/res/layout/conversation_list_item.xml b/presentation/src/main/res/layout/conversation_list_item.xml index 6a805424a..387194135 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" /> + + + + Date: Tue, 27 Jan 2026 21:38:32 +0800 Subject: [PATCH 04/21] Update ConversationsAdapter.kt --- .../conversations/ConversationsAdapter.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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..664d946f0 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( } } + From b84b1096052b1a398a37d321ecc6b1719533fadb Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Tue, 27 Jan 2026 21:39:15 +0800 Subject: [PATCH 05/21] Add files via upload --- presentation/src/main/res/drawable/otp_tag_background.xml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 presentation/src/main/res/drawable/otp_tag_background.xml 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 From 21bb5aa8976aba5ca0397bff7b8e7249399e0eb7 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:54:16 +0800 Subject: [PATCH 06/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 159 +++++++----------- 1 file changed, 61 insertions(+), 98 deletions(-) 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 80842df07..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 @@ -37,19 +37,10 @@ class OtpDetector { "验证码", "登录码", "安全码", - "校验码", - "密码", "动态码", "一次性密码", - "授权码", - "访问码", - "重置码", - "交易码", - "确认码", - "认证码", - "OTP", - "2FA", - "MFA" + "二次验证码", + "两步验证" ).map { it.lowercase() } private val safetyKeywords = listOf( @@ -64,23 +55,17 @@ class OtpDetector { "expires within", "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 { @@ -94,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" @@ -115,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 @@ -156,79 +144,54 @@ class OtpDetector { input.replace(Regex("\\s+"), " ").trim() private fun extractCandidates(message: String): List { - 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 + val candidates = mutableListOf() + + // 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( + code = code, + startIndex = match.range.first, + endIndex = match.range.last, + isNumeric = true ) - ) + } - 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 - ) - } - } + // 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 + ) } } - 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() } + // 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 + ) + } + } } + + return candidates } private fun scoreCandidate( @@ -260,8 +223,9 @@ class OtpDetector { score -= 1.5 } - // Local context: line containing the candidate + // Local context: the 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() @@ -269,17 +233,16 @@ class OtpDetector { score += 2.5 } - // Typical OTP line patterns - support both English and Chinese + // Typical OTP line patterns if (Regex( - "(otp|code|password|passcode|验证码|登录码|安全码|校验码|动态码|密码|一次性密码|授权码|认证码)", + "(otp|code|password|passcode)", RegexOption.IGNORE_CASE ).containsMatchIn(lineInfo.line) ) { score += 2.0 } - // Support both English and Chinese separators - if (Regex("(:|is|=|是|为|:)\\s*${Regex.escape(candidate.code)}").containsMatchIn(lineInfo.line)) { + if (Regex("(:|is|=)\\s*${Regex.escape(candidate.code)}").containsMatchIn(lineInfo.line)) { score += 1.5 } @@ -389,17 +352,17 @@ 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) } } - From 9d2037d2999a4b50ec24cc20d1484a0b02f976fe Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:29:08 +0800 Subject: [PATCH 07/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) 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 a0c0943a6..f62fa5a72 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,14 +33,7 @@ class OtpDetector { "transaction code", "confirm code", "confirmation code", - "code", - "验证码", - "登录码", - "安全码", - "动态码", - "一次性密码", - "二次验证码", - "两步验证" + "code" ).map { it.lowercase() } private val safetyKeywords = listOf( @@ -53,14 +46,7 @@ class OtpDetector { "valid for", "expires in", "expires within", - "expires after", - "请勿分享", - "切勿分享", - "不要分享", - "保密", - "有效期", - "将在", - "分钟内过期" + "expires after" ).map { it.lowercase() } private val moneyIndicators = listOf( @@ -79,14 +65,11 @@ 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 || hasChineseOtpChars) { + val reason = if (hasOtpKeyword) { "Contains OTP-like keywords but no numeric/alphanumeric candidate code found" } else { "No OTP-like keywords and no candidate code found" @@ -103,7 +86,7 @@ class OtpDetector { val best = scored.maxByOrNull { it.score }!! - val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars) + val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword) val isOtp = globalConfidence >= 0.6 @@ -146,8 +129,8 @@ class OtpDetector { private fun extractCandidates(message: String): List { val candidates = mutableListOf() - // 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]))") + // 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( @@ -352,15 +335,14 @@ class OtpDetector { private fun computeGlobalConfidence( best: Candidate, hasOtpKeyword: Boolean, - hasSafetyKeyword: Boolean, - hasChineseOtpChars: Boolean + hasSafetyKeyword: Boolean ): Double { var confidence = 0.0 // Base on score; tuned experimentally confidence += (best.score / 8.0).coerceIn(0.0, 1.0) - if (hasOtpKeyword || hasChineseOtpChars) confidence += 0.15 + if (hasOtpKeyword) confidence += 0.15 if (hasSafetyKeyword) confidence += 0.15 return confidence.coerceIn(0.0, 1.0) From 98fe06df015ddbc3e5fe715c5e4e09c101b15486 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Wed, 28 Jan 2026 00:37:54 +0800 Subject: [PATCH 08/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) 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) From 3b97a52cc7f4539a6f1d73bd70da0fd8f6cebb40 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Wed, 28 Jan 2026 01:29:24 +0800 Subject: [PATCH 09/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) 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 a0c0943a6..777789523 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 @@ -8,7 +8,8 @@ data class OtpDetectionResult( val isOtp: Boolean, val code: String?, val confidence: Double, - val reason: String + val reason: String, + val isParcel: Boolean = false ) class OtpDetector { @@ -42,6 +43,18 @@ class OtpDetector { "二次验证码", "两步验证" ).map { it.lowercase() } + + private val parcelKeywords = listOf( + "collection code", + "pickup code", + "collect code", + "use code", + "code", + "取件码", + "提取码", + "凭码", + "收集码" + ).map { it.lowercase() } private val safetyKeywords = listOf( "do not share", @@ -82,14 +95,18 @@ class OtpDetector { // Check if it contains characters related to Chinese CAPTCHAs val hasChineseOtpChars = lower.contains("验证码") || lower.contains("登录") || lower.contains("码") + + // Check if it contains parcel-related keywords or characters + val hasParcelKeyword = parcelKeywords.any { lower.contains(it) } + val hasChineseParcelChars = lower.contains("取件码") || lower.contains("提取码") || lower.contains("凭码") val candidates = extractCandidates(normalized) if (candidates.isEmpty()) { - 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" + val reason = when { + hasOtpKeyword || hasChineseOtpChars -> "Contains OTP-like keywords but no numeric/alphanumeric candidate code found" + hasParcelKeyword || hasChineseParcelChars -> "Contains parcel-like keywords but no numeric/alphanumeric candidate code found" + else -> "No OTP-like keywords and no candidate code found" } return OtpDetectionResult(false, null, 0.1, reason) } @@ -103,7 +120,8 @@ class OtpDetector { val best = scored.maxByOrNull { it.score }!! - val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars) + val isParcel = hasParcelKeyword || hasChineseParcelChars + val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars, isParcel) val isOtp = globalConfidence >= 0.6 @@ -116,7 +134,7 @@ class OtpDetector { }. " ) append( - "HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, GlobalConfidence=${ + "HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, IsParcel=$isParcel, GlobalConfidence=${ "%.2f".format( globalConfidence ) @@ -126,9 +144,10 @@ class OtpDetector { return OtpDetectionResult( isOtp = isOtp, - code = if (isOtp) best.code else null, + code = if (isOtp || isParcel) best.code else null, confidence = globalConfidence, - reason = reason + reason = reason, + isParcel = isParcel ) } @@ -353,7 +372,8 @@ class OtpDetector { best: Candidate, hasOtpKeyword: Boolean, hasSafetyKeyword: Boolean, - hasChineseOtpChars: Boolean + hasChineseOtpChars: Boolean, + isParcel: Boolean ): Double { var confidence = 0.0 @@ -362,6 +382,7 @@ class OtpDetector { if (hasOtpKeyword || hasChineseOtpChars) confidence += 0.15 if (hasSafetyKeyword) confidence += 0.15 + if (isParcel) confidence += 0.15 return confidence.coerceIn(0.0, 1.0) } From ccf551acecb726d6b2ceef2ada10dd7ce13c7353 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:27:58 +0800 Subject: [PATCH 10/21] Create ParcelDetector.kt --- .../messages/app/utils/ParcelDetector.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 app/src/main/java/org/prauga/messages/app/utils/ParcelDetector.kt 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) + } + + +} From 70d7dd61a31e54b0759d4f95372ae5cc6d84dfa0 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:28:19 +0800 Subject: [PATCH 11/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) 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 777789523..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 @@ -8,8 +8,7 @@ data class OtpDetectionResult( val isOtp: Boolean, val code: String?, val confidence: Double, - val reason: String, - val isParcel: Boolean = false + val reason: String ) class OtpDetector { @@ -43,18 +42,6 @@ class OtpDetector { "二次验证码", "两步验证" ).map { it.lowercase() } - - private val parcelKeywords = listOf( - "collection code", - "pickup code", - "collect code", - "use code", - "code", - "取件码", - "提取码", - "凭码", - "收集码" - ).map { it.lowercase() } private val safetyKeywords = listOf( "do not share", @@ -95,18 +82,14 @@ class OtpDetector { // Check if it contains characters related to Chinese CAPTCHAs val hasChineseOtpChars = lower.contains("验证码") || lower.contains("登录") || lower.contains("码") - - // Check if it contains parcel-related keywords or characters - val hasParcelKeyword = parcelKeywords.any { lower.contains(it) } - val hasChineseParcelChars = lower.contains("取件码") || lower.contains("提取码") || lower.contains("凭码") val candidates = extractCandidates(normalized) if (candidates.isEmpty()) { - val reason = when { - hasOtpKeyword || hasChineseOtpChars -> "Contains OTP-like keywords but no numeric/alphanumeric candidate code found" - hasParcelKeyword || hasChineseParcelChars -> "Contains parcel-like keywords but no numeric/alphanumeric candidate code found" - else -> "No OTP-like keywords and no candidate code found" + 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" } return OtpDetectionResult(false, null, 0.1, reason) } @@ -120,8 +103,7 @@ class OtpDetector { val best = scored.maxByOrNull { it.score }!! - val isParcel = hasParcelKeyword || hasChineseParcelChars - val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars, isParcel) + val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars) val isOtp = globalConfidence >= 0.6 @@ -134,7 +116,7 @@ class OtpDetector { }. " ) append( - "HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, IsParcel=$isParcel, GlobalConfidence=${ + "HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, GlobalConfidence=${ "%.2f".format( globalConfidence ) @@ -144,10 +126,9 @@ class OtpDetector { return OtpDetectionResult( isOtp = isOtp, - code = if (isOtp || isParcel) best.code else null, + code = if (isOtp) best.code else null, confidence = globalConfidence, - reason = reason, - isParcel = isParcel + reason = reason ) } @@ -372,8 +353,7 @@ class OtpDetector { best: Candidate, hasOtpKeyword: Boolean, hasSafetyKeyword: Boolean, - hasChineseOtpChars: Boolean, - isParcel: Boolean + hasChineseOtpChars: Boolean ): Double { var confidence = 0.0 @@ -382,7 +362,6 @@ class OtpDetector { if (hasOtpKeyword || hasChineseOtpChars) confidence += 0.15 if (hasSafetyKeyword) confidence += 0.15 - if (isParcel) confidence += 0.15 return confidence.coerceIn(0.0, 1.0) } From 8d5f58fbbbdde1a818d17e68b96b611bbdeda4db Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:29:01 +0800 Subject: [PATCH 12/21] Update conversation_list_item.xml --- .../res/layout/conversation_list_item.xml | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/presentation/src/main/res/layout/conversation_list_item.xml b/presentation/src/main/res/layout/conversation_list_item.xml index 387194135..b25285cc4 100644 --- a/presentation/src/main/res/layout/conversation_list_item.xml +++ b/presentation/src/main/res/layout/conversation_list_item.xml @@ -93,12 +93,35 @@ android:textSize="12sp" android:visibility="gone" android:background="@drawable/otp_tag_background" - app:layout_constraintEnd_toStartOf="@id/scheduled" + app:layout_constraintEnd_toStartOf="@id/parcelTag" app:layout_constraintTop_toTopOf="@id/snippet" app:layout_constraintBottom_toBottomOf="@id/snippet" app:layout_constraintHorizontal_bias="1.0" app:layout_constraintStart_toEndOf="@id/snippet" /> + + + + Date: Sat, 31 Jan 2026 22:29:36 +0800 Subject: [PATCH 13/21] Update ConversationsAdapter.kt --- .../conversations/ConversationsAdapter.kt | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) 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 664d946f0..270f7c6f7 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 @@ -32,6 +32,7 @@ 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 @@ -149,9 +150,9 @@ class ConversationsAdapter @Inject constructor( // Choose appropriate tag text based on language val locale = context.resources.configuration.locales[0] val otpText = if (locale.language == "zh") { - "验证码" // Show "验证码" for Chinese locale + context.getString(R.string.otp_tag_chinese) // Show "验证码" for Chinese locale } else { - "OTP" // Show "OTP" for other locales + context.getString(R.string.otp_tag) // Show "OTP" for other locales } binding.otpTag.text = otpText @@ -160,6 +161,30 @@ class ConversationsAdapter @Inject constructor( 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) { + // Choose appropriate tag text based on language + val locale = context.resources.configuration.locales[0] + val parcelText = if (locale.language == "zh") { + context.getString(R.string.parcel_tag_chinese) // Show "取件码" for Chinese locale + } else { + context.getString(R.string.parcel_tag) // Show "Parcel" for other locales + } + 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 { @@ -187,4 +212,3 @@ class ConversationsAdapter @Inject constructor( } } - From 06fb3a06fb953bb9cd065bc125f82da2ebd73e54 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:30:47 +0800 Subject: [PATCH 14/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 386 +++--------------- 1 file changed, 46 insertions(+), 340 deletions(-) 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 a0c0943a6..7db4acfec 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 @@ -1,368 +1,74 @@ /* * Copyright (C) 2025 Saalim Quadri */ +package org.prauga.messages.app.utils -package org.prauga.messages.common.util +import java.util.regex.Matcher +import java.util.regex.Pattern -data class OtpDetectionResult( - val isOtp: Boolean, - val code: String?, - val confidence: Double, +data class ParcelDetectionResult( + val address: String, + val code: String, + val success: Boolean, val reason: String ) -class OtpDetector { - - private val otpKeywords = listOf( - "otp", - "one time password", - "one-time password", - "verification code", - "verification number", - "login code", - "login otp", - "security code", - "2-step code", - "2 factor code", - "2fa code", - "mfa code", - "auth code", - "passcode", - "access code", - "reset code", - "transaction code", - "confirm code", - "confirmation code", - "code", - "验证码", - "登录码", - "安全码", - "动态码", - "一次性密码", - "二次验证码", - "两步验证" - ).map { it.lowercase() } - - private val safetyKeywords = listOf( - "do not share", - "don't share", - "never share", - "do not disclose", - "do not forward", - "keep this code secret", - "valid for", - "expires in", - "expires within", - "expires after", - "请勿分享", - "切勿分享", - "不要分享", - "保密", - "有效期", - "将在", - "分钟内过期" - ).map { it.lowercase() } - - private val moneyIndicators = listOf( - "rs", "inr", "usd", "eur", "gbp", "₹", "$", "€", "£", "balance", - "amount", "debited", "credited", "txn", "transaction id", "order id" - ).map { it.lowercase() } - - fun detect(rawMessage: String): OtpDetectionResult { - val message = rawMessage.trim() - if (message.isEmpty()) { - return OtpDetectionResult(false, null, 0.0, "Empty message") - } - - val normalized = normalizeWhitespace(message) - val lower = normalized.lowercase() - - 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 || hasChineseOtpChars) { - "Contains OTP-like keywords but no numeric/alphanumeric candidate code found" - } else { - "No OTP-like keywords and no candidate code found" - } - return OtpDetectionResult(false, null, 0.1, reason) - } - - // Compute a score for each candidate - val scored = candidates.map { candidate -> - val score = - scoreCandidate(candidate, normalized, lower, hasOtpKeyword, hasSafetyKeyword) - candidate.copy(score = score) - } - - val best = scored.maxByOrNull { it.score }!! - - val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars) - - val isOtp = globalConfidence >= 0.6 - - val reason = buildString { - append( - "Best candidate: '${best.code}' (len=${best.code.length}), score=${ - "%.2f".format( - best.score - ) - }. " - ) - append( - "HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, GlobalConfidence=${ - "%.2f".format( - globalConfidence - ) - }." - ) - } - - return OtpDetectionResult( - isOtp = isOtp, - code = if (isOtp) best.code else null, - confidence = globalConfidence, - reason = reason - ) - } - - private data class Candidate( - val code: String, - val startIndex: Int, - val endIndex: Int, - val isNumeric: Boolean, - val score: Double = 0.0 +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,})*""" ) - private fun normalizeWhitespace(input: String): String = - input.replace(Regex("\\s+"), " ").trim() - private fun extractCandidates(message: String): List { - val candidates = mutableListOf() - // 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( - code = code, - startIndex = match.range.first, - endIndex = match.range.last, - isNumeric = true - ) - } + fun detectParcel(sms: String): ParcelDetectionResult { + var foundAddress = "" + var foundCode = "" - // 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 - ) - } - } + // 优先匹配柜号地址,其次默认规则 + val lockerMatcher: Matcher = lockerPattern.matcher(sms) + foundAddress = if (lockerMatcher.find()) lockerMatcher.group().toString() ?: "" else "" - // 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 - ) + 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 } - return candidates - } - - private fun scoreCandidate( - candidate: Candidate, - original: String, - lower: String, - hasOtpKeyword: Boolean, - hasSafetyKeyword: Boolean - ): Double { - var score = 0.0 - - val len = candidate.code.length - - // Length preference: 6 is king, but 4–8 is common. - score += when { - len == 6 -> 3.0 - len in 4..8 -> 2.0 - len in 3..10 -> 1.0 - else -> -1.0 - } - - // Numeric gets a slight boost over alphanumeric (based on current SMS trends) - if (candidate.isNumeric) { - score += 0.5 - } - - // Penalize "weird" lengths that look like phone numbers or ids - if (candidate.code.length >= 9 && candidate.isNumeric) { - score -= 1.5 - } - - // Local context: the 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() - if (trimmedLine == candidate.code) { - score += 2.5 - } - - // Typical OTP line patterns - if (Regex( - "(otp|code|password|passcode)", - RegexOption.IGNORE_CASE - ).containsMatchIn(lineInfo.line) - ) { - score += 2.0 + 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("取件", "") // 移除"取件" - if (Regex("(:|is|=)\\s*${Regex.escape(candidate.code)}").containsMatchIn(lineInfo.line)) { - score += 1.5 - } - - // Distance to OTP keywords (global) - val minKeywordDistance = minKeywordDistance(lower, candidate) - if (minKeywordDistance != null) { + val success = foundAddress.isNotEmpty() && foundCode.isNotEmpty() + val reason = if (success) { + "Successfully detected parcel: address='$foundAddress', code='$foundCode'" + } else { when { - minKeywordDistance <= 20 -> score += 2.0 - minKeywordDistance <= 40 -> score += 1.0 - minKeywordDistance <= 80 -> score += 0.5 + foundAddress.isEmpty() && foundCode.isEmpty() -> "No address and no code found" + foundAddress.isEmpty() -> "No address found" + else -> "No code found" } } - // Safety keywords in the whole message => boost - if (hasSafetyKeyword) { - score += 1.0 - } - - // Money / transaction amount heuristics - val hasMoneyContextNear = hasIndicatorNear( - text = lower, - indicators = moneyIndicators, - index = candidate.startIndex, - radius = 25 - ) - if (hasMoneyContextNear) { - score -= 2.0 // looks like an amount, not an OTP - } - - // Phone number shape (country code, leading +, etc.) - if (looksLikePhoneNumber(candidate, original)) { - score -= 2.5 - } - - return score + return ParcelDetectionResult(foundAddress, foundCode, success, reason) } - private data class LineContext(val line: String, val from: Int, val to: Int) - - private fun extractLineContext(text: String, start: Int, end: Int): LineContext { - var lineStart = start - var lineEnd = end - - while (lineStart > 0 && text[lineStart - 1] != '\n') { - lineStart-- - } - while (lineEnd < text.lastIndex && text[lineEnd + 1] != '\n') { - lineEnd++ - } - return LineContext( - line = text.substring(lineStart..lineEnd), - from = lineStart, - to = lineEnd - ) - } - - private fun minKeywordDistance(lower: String, candidate: Candidate): Int? { - val candidateCenter = (candidate.startIndex + candidate.endIndex) / 2 - var best: Int? = null - - otpKeywords.forEach { keyword -> - var index = lower.indexOf(keyword) - while (index >= 0) { - val keywordCenter = index + keyword.length / 2 - val distance = kotlin.math.abs(candidateCenter - keywordCenter) - if (best == null || distance < best!!) { - best = distance - } - index = lower.indexOf(keyword, startIndex = index + keyword.length) - } - } - - return best - } - - private fun hasIndicatorNear( - text: String, - indicators: List, - index: Int, - radius: Int - ): Boolean { - val start = (index - radius).coerceAtLeast(0) - val end = (index + radius).coerceAtMost(text.length) - if (start >= end) return false - val window = text.substring(start, end) - return indicators.any { window.contains(it) } - } - - private fun looksLikePhoneNumber(candidate: Candidate, original: String): Boolean { - // If the candidate is preceded by + or "tel"/"call", it's probably a phone. - val start = candidate.startIndex - val prefixStart = (start - 5).coerceAtLeast(0) - val prefix = original.substring(prefixStart, start) - - if (prefix.contains("+")) return true - if (prefix.lowercase().contains("tel") || prefix.lowercase().contains("call")) return true - - // 9+ digits and starting with 1 or typical mobile prefixes could be phone number - if (candidate.isNumeric && candidate.code.length >= 9) { - return true - } - - return false - } - - private fun computeGlobalConfidence( - best: Candidate, - hasOtpKeyword: 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 || hasChineseOtpChars) confidence += 0.15 - if (hasSafetyKeyword) confidence += 0.15 - - return confidence.coerceIn(0.0, 1.0) - } } From be18b2d907bc9a81025a1a900be2115b1aeb556f Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:40:29 +0800 Subject: [PATCH 15/21] Update strings.xml --- presentation/src/main/res/values-zh-rCN/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/presentation/src/main/res/values-zh-rCN/strings.xml b/presentation/src/main/res/values-zh-rCN/strings.xml index dc69a311f..72c7e6fcf 100644 --- a/presentation/src/main/res/values-zh-rCN/strings.xml +++ b/presentation/src/main/res/values-zh-rCN/strings.xml @@ -490,4 +490,8 @@ 抱歉,加载语音转文字提供程序时出错。 朗读你的消息 分享失败 + + + 验证码 + 取件码 From 9a2813598e3c1bb17a7cf1a93804257eb6ce5767 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:41:12 +0800 Subject: [PATCH 16/21] Update strings.xml --- presentation/src/main/res/values/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) 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 From 3edfbfce61ee713cecb06e8760d2eacfdd8647ab Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:42:03 +0800 Subject: [PATCH 17/21] Update ConversationsAdapter.kt --- .../conversations/ConversationsAdapter.kt | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) 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 270f7c6f7..3853f13f9 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 @@ -147,13 +147,8 @@ class ConversationsAdapter @Inject constructor( 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") { - context.getString(R.string.otp_tag_chinese) // Show "验证码" for Chinese locale - } else { - context.getString(R.string.otp_tag) // Show "OTP" for other locales - } + // 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 @@ -171,13 +166,8 @@ class ConversationsAdapter @Inject constructor( binding.parcelTag.isVisible = parcelResult.success if (parcelResult.success) { - // Choose appropriate tag text based on language - val locale = context.resources.configuration.locales[0] - val parcelText = if (locale.language == "zh") { - context.getString(R.string.parcel_tag_chinese) // Show "取件码" for Chinese locale - } else { - context.getString(R.string.parcel_tag) // Show "Parcel" for other locales - } + // 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 From b0e723f21fa02a5f7967c1f53c36b19c4bf6d162 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sun, 1 Feb 2026 01:15:28 +0800 Subject: [PATCH 18/21] Update OtpDetector.kt --- .../prauga/messages/app/utils/OtpDetector.kt | 386 +++++++++++++++--- 1 file changed, 340 insertions(+), 46 deletions(-) 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 7db4acfec..46c834210 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 @@ -1,74 +1,368 @@ /* * Copyright (C) 2025 Saalim Quadri */ -package org.prauga.messages.app.utils -import java.util.regex.Matcher -import java.util.regex.Pattern +package org.prauga.messages.app.utils -data class ParcelDetectionResult( - val address: String, - val code: String, - val success: Boolean, +data class OtpDetectionResult( + val isOtp: Boolean, + val code: String?, + val confidence: Double, 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,})*""" +class OtpDetector { + + private val otpKeywords = listOf( + "otp", + "one time password", + "one-time password", + "verification code", + "verification number", + "login code", + "login otp", + "security code", + "2-step code", + "2 factor code", + "2fa code", + "mfa code", + "auth code", + "passcode", + "access code", + "reset code", + "transaction code", + "confirm code", + "confirmation code", + "code", + "验证码", + "登录码", + "安全码", + "动态码", + "一次性密码", + "二次验证码", + "两步验证" + ).map { it.lowercase() } + + private val safetyKeywords = listOf( + "do not share", + "don't share", + "never share", + "do not disclose", + "do not forward", + "keep this code secret", + "valid for", + "expires in", + "expires within", + "expires after", + "请勿分享", + "切勿分享", + "不要分享", + "保密", + "有效期", + "将在", + "分钟内过期" + ).map { it.lowercase() } + + private val moneyIndicators = listOf( + "rs", "inr", "usd", "eur", "gbp", "₹", "$", "€", "£", "balance", + "amount", "debited", "credited", "txn", "transaction id", "order id" + ).map { it.lowercase() } + + fun detect(rawMessage: String): OtpDetectionResult { + val message = rawMessage.trim() + if (message.isEmpty()) { + return OtpDetectionResult(false, null, 0.0, "Empty message") + } + + val normalized = normalizeWhitespace(message) + val lower = normalized.lowercase() + + 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 || hasChineseOtpChars) { + "Contains OTP-like keywords but no numeric/alphanumeric candidate code found" + } else { + "No OTP-like keywords and no candidate code found" + } + return OtpDetectionResult(false, null, 0.1, reason) + } + + // Compute a score for each candidate + val scored = candidates.map { candidate -> + val score = + scoreCandidate(candidate, normalized, lower, hasOtpKeyword, hasSafetyKeyword) + candidate.copy(score = score) + } + + val best = scored.maxByOrNull { it.score }!! + + val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars) + + val isOtp = globalConfidence >= 0.6 + + val reason = buildString { + append( + "Best candidate: '${best.code}' (len=${best.code.length}), score=${ + "%.2f".format( + best.score + ) + }. " + ) + append( + "HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, GlobalConfidence=${ + "%.2f".format( + globalConfidence + ) + }." + ) + } + + return OtpDetectionResult( + isOtp = isOtp, + code = if (isOtp) best.code else null, + confidence = globalConfidence, + reason = reason + ) + } + + private data class Candidate( + val code: String, + val startIndex: Int, + val endIndex: Int, + val isNumeric: Boolean, + val score: Double = 0.0 ) + private fun normalizeWhitespace(input: String): String = + input.replace(Regex("\\s+"), " ").trim() + private fun extractCandidates(message: String): List { + val candidates = mutableListOf() - fun detectParcel(sms: String): ParcelDetectionResult { - var foundAddress = "" - var foundCode = "" + // 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( + code = code, + startIndex = match.range.first, + endIndex = match.range.last, + isNumeric = true + ) + } - // 优先匹配柜号地址,其次默认规则 - val lockerMatcher: Matcher = lockerPattern.matcher(sms) - foundAddress = if (lockerMatcher.find()) lockerMatcher.group().toString() ?: "" else "" + // 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 + ) + } + } - 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 + // 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 + ) } } - 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-, ]"), "") + return candidates + } + + private fun scoreCandidate( + candidate: Candidate, + original: String, + lower: String, + hasOtpKeyword: Boolean, + hasSafetyKeyword: Boolean + ): Double { + var score = 0.0 + + val len = candidate.code.length + + // Length preference: 6 is king, but 4–8 is common. + score += when { + len == 6 -> 3.0 + len in 4..8 -> 2.0 + len in 3..10 -> 1.0 + else -> -1.0 + } + + // Numeric gets a slight boost over alphanumeric (based on current SMS trends) + if (candidate.isNumeric) { + score += 0.5 + } + + // Penalize "weird" lengths that look like phone numbers or ids + if (candidate.code.length >= 9 && candidate.isNumeric) { + score -= 1.5 + } + + // Local context: the 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() + if (trimmedLine == candidate.code) { + score += 2.5 + } + + // Typical OTP line patterns + if (Regex( + "(otp|code|password|passcode)", + RegexOption.IGNORE_CASE + ).containsMatchIn(lineInfo.line) + ) { + score += 2.0 + } + + if (Regex("(:|is|=)\\s*${Regex.escape(candidate.code)}").containsMatchIn(lineInfo.line)) { + score += 1.5 } - 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 { + // Distance to OTP keywords (global) + val minKeywordDistance = minKeywordDistance(lower, candidate) + if (minKeywordDistance != null) { when { - foundAddress.isEmpty() && foundCode.isEmpty() -> "No address and no code found" - foundAddress.isEmpty() -> "No address found" - else -> "No code found" + minKeywordDistance <= 20 -> score += 2.0 + minKeywordDistance <= 40 -> score += 1.0 + minKeywordDistance <= 80 -> score += 0.5 + } + } + + // Safety keywords in the whole message => boost + if (hasSafetyKeyword) { + score += 1.0 + } + + // Money / transaction amount heuristics + val hasMoneyContextNear = hasIndicatorNear( + text = lower, + indicators = moneyIndicators, + index = candidate.startIndex, + radius = 25 + ) + if (hasMoneyContextNear) { + score -= 2.0 // looks like an amount, not an OTP + } + + // Phone number shape (country code, leading +, etc.) + if (looksLikePhoneNumber(candidate, original)) { + score -= 2.5 + } + + return score + } + + private data class LineContext(val line: String, val from: Int, val to: Int) + + private fun extractLineContext(text: String, start: Int, end: Int): LineContext { + var lineStart = start + var lineEnd = end + + while (lineStart > 0 && text[lineStart - 1] != '\n') { + lineStart-- + } + while (lineEnd < text.lastIndex && text[lineEnd + 1] != '\n') { + lineEnd++ + } + + return LineContext( + line = text.substring(lineStart..lineEnd), + from = lineStart, + to = lineEnd + ) + } + + private fun minKeywordDistance(lower: String, candidate: Candidate): Int? { + val candidateCenter = (candidate.startIndex + candidate.endIndex) / 2 + var best: Int? = null + + otpKeywords.forEach { keyword -> + var index = lower.indexOf(keyword) + while (index >= 0) { + val keywordCenter = index + keyword.length / 2 + val distance = kotlin.math.abs(candidateCenter - keywordCenter) + if (best == null || distance < best!!) { + best = distance + } + index = lower.indexOf(keyword, startIndex = index + keyword.length) } } - return ParcelDetectionResult(foundAddress, foundCode, success, reason) + return best + } + + private fun hasIndicatorNear( + text: String, + indicators: List, + index: Int, + radius: Int + ): Boolean { + val start = (index - radius).coerceAtLeast(0) + val end = (index + radius).coerceAtMost(text.length) + if (start >= end) return false + val window = text.substring(start, end) + return indicators.any { window.contains(it) } + } + + private fun looksLikePhoneNumber(candidate: Candidate, original: String): Boolean { + // If the candidate is preceded by + or "tel"/"call", it's probably a phone. + val start = candidate.startIndex + val prefixStart = (start - 5).coerceAtLeast(0) + val prefix = original.substring(prefixStart, start) + + if (prefix.contains("+")) return true + if (prefix.lowercase().contains("tel") || prefix.lowercase().contains("call")) return true + + // 9+ digits and starting with 1 or typical mobile prefixes could be phone number + if (candidate.isNumeric && candidate.code.length >= 9) { + return true + } + + return false } + private fun computeGlobalConfidence( + best: Candidate, + hasOtpKeyword: 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 || hasChineseOtpChars) confidence += 0.15 + if (hasSafetyKeyword) confidence += 0.15 + + return confidence.coerceIn(0.0, 1.0) + } } From 23470e639890dfe4e2deed3237ebbb2a06fccde3 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sun, 1 Feb 2026 01:18:13 +0800 Subject: [PATCH 19/21] Update ConversationsAdapter.kt --- .../QKSMS/feature/conversations/ConversationsAdapter.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 3853f13f9..3017123a3 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,7 +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.app.utils.OtpDetector import org.prauga.messages.app.utils.ParcelDetector import org.prauga.messages.common.util.extensions.resolveThemeColor import org.prauga.messages.common.util.extensions.setTint @@ -78,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 From 10e1e16ef6138268fec6a434d42701bbca115e19 Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sun, 1 Feb 2026 01:26:43 +0800 Subject: [PATCH 20/21] Update OtpDetector.kt --- app/src/main/java/org/prauga/messages/app/utils/OtpDetector.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 46c834210..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 @@ -2,7 +2,7 @@ * Copyright (C) 2025 Saalim Quadri */ -package org.prauga.messages.app.utils +package org.prauga.messages.common.util data class OtpDetectionResult( val isOtp: Boolean, From a3962c5c10fb033091bb5727837fbdac42eb095e Mon Sep 17 00:00:00 2001 From: ADAIBLOG <186315423+ADAIBLOG@users.noreply.github.com> Date: Sun, 1 Feb 2026 01:32:03 +0800 Subject: [PATCH 21/21] Update ConversationsAdapter.kt --- .../moez/QKSMS/feature/conversations/ConversationsAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3017123a3..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,7 +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.app.utils.OtpDetector +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