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
69 changes: 54 additions & 15 deletions app/src/main/java/org/prauga/messages/app/utils/OtpDetector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,7 +34,26 @@ class OtpDetector {
"transaction code",
"confirm code",
"confirmation code",
"code"
"code",
"验证码",
"登录码",
"安全码",
"动态码",
"一次性密码",
"二次验证码",
"两步验证"
).map { it.lowercase() }

private val parcelKeywords = listOf(
"collection code",
"pickup code",
"collect code",
"use code",
"code",
"取件码",
"提取码",
"凭码",
"收集码"
).map { it.lowercase() }

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

private val moneyIndicators = listOf(
Expand All @@ -65,14 +92,21 @@ 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("码")

// 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) {
"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)
}
Expand All @@ -86,7 +120,8 @@ class OtpDetector {

val best = scored.maxByOrNull { it.score }!!

val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword)
val isParcel = hasParcelKeyword || hasChineseParcelChars
val globalConfidence = computeGlobalConfidence(best, hasOtpKeyword, hasSafetyKeyword, hasChineseOtpChars, isParcel)

val isOtp = globalConfidence >= 0.6

Expand All @@ -99,7 +134,7 @@ class OtpDetector {
}. "
)
append(
"HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, GlobalConfidence=${
"HasOtpKeyword=$hasOtpKeyword, HasSafetyKeyword=$hasSafetyKeyword, IsParcel=$isParcel, GlobalConfidence=${
"%.2f".format(
globalConfidence
)
Expand All @@ -109,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
)
}

Expand All @@ -129,8 +165,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,15 +371,18 @@ class OtpDetector {
private fun computeGlobalConfidence(
best: Candidate,
hasOtpKeyword: Boolean,
hasSafetyKeyword: Boolean
hasSafetyKeyword: Boolean,
hasChineseOtpChars: Boolean,
isParcel: 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
if (isParcel) confidence += 0.15

return confidence.coerceIn(0.0, 1.0)
}
Expand Down
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