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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions app/src/main/java/org/prauga/messages/app/utils/OtpDetector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,14 @@ class OtpDetector {
"transaction code",
"confirm code",
"confirmation code",
"code"
"code",
"验证码",
"登录码",
"安全码",
"动态码",
"一次性密码",
"二次验证码",
"两步验证"
).map { it.lowercase() }

private val safetyKeywords = listOf(
Expand All @@ -46,7 +53,14 @@ class OtpDetector {
"valid for",
"expires in",
"expires within",
"expires after"
"expires after",
"请勿分享",
"切勿分享",
"不要分享",
"保密",
"有效期",
"将在",
"分钟内过期"
).map { it.lowercase() }

private val moneyIndicators = listOf(
Expand All @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -129,8 +146,8 @@ class OtpDetector {
private fun extractCandidates(message: String): List<Candidate> {
val candidates = mutableListOf<Candidate>()

// 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(
Expand Down Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions app/src/main/java/org/prauga/messages/app/utils/ParcelDetector.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright (C) 2025 Saalim Quadri <danascape@gmail.com>
*/
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)
}


}
5 changes: 5 additions & 0 deletions app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="notification_action_copy_otp">复制验证码 %s</string>
<string name="otp_copied">OTP %s 已复制到剪贴板</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions presentation/src/main/res/drawable/otp_tag_background.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- OTP tag background style: Rounded rectangle -->
<!-- Color will be dynamically set to theme color in code -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/tools_theme" />
<corners android:radius="12dp" />
</shape>
48 changes: 47 additions & 1 deletion presentation/src/main/res/layout/conversation_list_item.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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" />

<!-- OTP tag: Used to mark messages containing One Time Password -->
<!-- Initially hidden, only shown when OTP is detected -->
<org.prauga.messages.common.widget.QkTextView
android:id="@+id/otpTag"
style="@style/TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="7dp"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="OTP"
android:textSize="12sp"
android:visibility="gone"
android:background="@drawable/otp_tag_background"
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" />

<!-- Parcel tag: Used to mark messages containing parcel pickup code -->
<!-- Initially hidden, only shown when parcel code is detected -->
<org.prauga.messages.common.widget.QkTextView
android:id="@+id/parcelTag"
style="@style/TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="7dp"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="取件码"
android:textSize="12sp"
android:visibility="gone"
android:background="@drawable/otp_tag_background"
app:layout_constraintEnd_toStartOf="@id/scheduled"
app:layout_constraintTop_toTopOf="@id/snippet"
app:layout_constraintBottom_toBottomOf="@id/snippet"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@id/otpTag" />

<ImageView
android:id="@+id/pinned"
android:layout_width="20dp"
Expand Down
4 changes: 4 additions & 0 deletions presentation/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -490,4 +490,8 @@
<string name="error_stt_toast">抱歉,加载语音转文字提供程序时出错。</string>
<string name="stt_toast_extra_prompt">朗读你的消息</string>
<string name="messages_text_share_file_error">分享失败</string>

<!-- OTP and Parcel tags -->
<string name="otp_tag">验证码</string>
<string name="parcel_tag">取件码</string>
</resources>
4 changes: 4 additions & 0 deletions presentation/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -597,4 +597,8 @@
<string name="stt_toast_extra_prompt">Speak your message</string>

<string name="messages_text_share_file_error">Oops! Something went wrong while sharing</string>

<!-- OTP and Parcel tags -->
<string name="otp_tag">OTP</string>
<string name="parcel_tag">Parcel</string>
</resources>
Loading