Skip to content
Closed
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
155 changes: 105 additions & 50 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,23 @@ class OtpDetector {
"transaction code",
"confirm code",
"confirmation code",
"code"
"code",
"验证码",
"登录码",
"安全码",
"校验码",
"密码",
"动态码",
"一次性密码",
"授权码",
"访问码",
"重置码",
"交易码",
"确认码",
"认证码",
"OTP",
"2FA",
"MFA"
).map { it.lowercase() }

private val safetyKeywords = listOf(
Expand All @@ -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 {
Expand Down Expand Up @@ -127,54 +156,79 @@ class OtpDetector {
input.replace(Regex("\\s+"), " ").trim()

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")
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<String, Candidate>()

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(
Expand Down Expand Up @@ -206,26 +260,26 @@ 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()
if (trimmedLine == candidate.code) {
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
}

Expand Down Expand Up @@ -348,3 +402,4 @@ class OtpDetector {
return confidence.coerceIn(0.0, 1.0)
}
}

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,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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -160,3 +187,4 @@ class ConversationsAdapter @Inject constructor(
}

}

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>
25 changes: 24 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,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" />

<!-- 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/scheduled"
app:layout_constraintTop_toTopOf="@id/snippet"
app:layout_constraintBottom_toBottomOf="@id/snippet"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@id/snippet" />

<ImageView
android:id="@+id/pinned"
android:layout_width="20dp"
Expand Down
Loading