Skip to content

Commit e19299e

Browse files
committed
fix(i18n): label heuristic and message signals in the app language
A refuting review of the system-text change found the heuristic and SMS-content descriptions still joined raw tokens ("neighbor spoof, shortened url"), and the RCS screened-message notification and two Protection Test results printed checker names ("sms_content"). Every analyzer token now maps to a string (the overlay's call wording plus seven message signals, all translated to zh-rCN along with the eleven overlay reasons and the two sender-provenance reasons), joined once each with a per-language separator. BlockReasoning splits a stored list on the Chinese separators as well as the comma. CallCategoryResolver matched heuristic categories by searching the description for underscored tokens, which the checker had already replaced with spaces, so neighbor-spoof, campaign-range and toll-free blocks never got a category. Results now carry the raw tokens as `signals`, and the resolver reads those; the description stays a fallback for callers that still pass tokens there. Tests: SignalLabelsTest (new) scans both analyzers for every reason token and fails on one without a label or one whose label contains a list separator. LocalizedSystemTextTest, CallCategoryResolverTest and BlockReasoningTest gain cases for the Chinese descriptions, the signals path and the Chinese separators. No existing test changed; each new case failed against a mutation that undid its fix.
1 parent fa457f1 commit e19299e

17 files changed

Lines changed: 324 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ All notable changes to CallShield will be documented in this file.
2424
payslip, a part-time shift rate, a house-cleaning job or a cashback offer
2525
stays clean.
2626

27+
- Your call category rules now reach more suspicious-pattern blocks. A call
28+
flagged as possible neighbor spoofing counts as a scam, one from an active
29+
campaign range as a robocall, and a toll-free one as a telemarketer, so
30+
the action you picked for that category applies. Those mappings existed
31+
but never matched, because they looked for internal names the block
32+
description had already turned into plain words.
33+
2734
- CallShield starts faster when a call wakes it. Looking for an interrupted
2835
backup restore held the main thread about 0.7 seconds on every start
2936
(measured on an Android 10 emulator), out of the five seconds Android gives
@@ -454,6 +461,14 @@ The rest of this section takes effect when the report Worker is next deployed.
454461
notification, and the explanation each detection layer gives in Lookup and
455462
the block log. Chinese has all of it. The Chinese daily summary also keeps
456463
its line breaks now, where before it ran together on one line.
464+
- The signals behind a suspicious-pattern or message-content block are
465+
translated too. The block log and the "why" panel listed internal names
466+
like "neighbor spoof" or "shortened url". The screened-message
467+
notification and two Protection Test results named the deciding check by
468+
its code name ("heuristic", "sms_content"). Each signal now reads as a
469+
phrase in the app language ("Possible neighbor spoofing", "Risky link
470+
pattern"), listed once even when several links trip it, and each check
471+
goes by the name the block log uses.
457472

458473
- A shipped locale can no longer decay silently. Each has a coverage floor in
459474
`scripts/translation_floors.json`, and dropping below it fails the build.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
510510
| URL Safety | Local spam-domain data; optional URLhaus (abuse.ch) |
511511
| Verification | Local Gradle, lint, and release-artifact checks |
512512
| Tests | 1100 JVM unit tests (JUnit) |
513-
| Strings | 1530 string resources and 38 plural groups (translation-ready) |
513+
| Strings | 1538 string resources and 38 plural groups (translation-ready) |
514514
| Accessibility | 100+ content descriptions, 48dp touch targets |
515515
| Min SDK | 29 (Android 10) |
516516
| Target SDK | 36 |

‎app/src/main/java/com/sysadmindoc/callshield/data/BlockReasoning.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,9 +473,11 @@ object BlockReasoning {
473473
suffix: String = "",
474474
): String? = takeIf { it.isNotBlank() }?.let { "$prefix$it$suffix" }
475475

476+
// Signal lists are joined with the app language's separator, so a
477+
// Chinese row uses "、" (or a full-width comma) rather than ",".
476478
private fun listed(description: String): List<String> =
477479
description
478-
.split(",")
480+
.split(',', ',', '、')
479481
.map { it.trim().replace("_", " ") }
480482
.filter { it.isNotBlank() }
481483
.map { "• $it" }

‎app/src/main/java/com/sysadmindoc/callshield/data/CallCategory.kt‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,20 @@ object CallCategoryResolver {
114114
// Campaign burst = coordinated robocall wave, by definition.
115115
if (result.reasonCode == BlockReasonCode.CAMPAIGN_BURST) return CallCategory.Robocall
116116

117-
// Heuristic reasons: map the strongest signals.
117+
// Heuristic reasons: map the strongest signals. A live result carries
118+
// the raw tokens in `signals`; its description is display text in the
119+
// app language (and always had the underscores replaced), so it only
120+
// helps callers that still pass tokens there.
118121
if (result.reasonCode == BlockReasonCode.HEURISTIC) {
122+
val evidence = (result.signals + desc).joinToString(" ")
119123
when {
120-
"wangiri" in desc -> return CallCategory.Wangiri
121-
"premium" in desc -> return CallCategory.Scam
122-
"neighbor_spoof" in desc -> return CallCategory.Scam
123-
"rapid_fire" in desc -> return CallCategory.Robocall
124-
"voip_spam_range" in desc -> return CallCategory.Robocall
125-
"hot_campaign_range" in desc -> return CallCategory.Robocall
126-
"toll_free" in desc -> return CallCategory.Telemarketer
124+
"wangiri" in evidence -> return CallCategory.Wangiri
125+
"premium" in evidence -> return CallCategory.Scam
126+
"neighbor_spoof" in evidence -> return CallCategory.Scam
127+
"rapid_fire" in evidence -> return CallCategory.Robocall
128+
"voip_spam_range" in evidence -> return CallCategory.Robocall
129+
"hot_campaign_range" in evidence -> return CallCategory.Robocall
130+
"toll_free" in evidence -> return CallCategory.Telemarketer
127131
}
128132
}
129133

‎app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,7 @@ internal fun BlockResult.toSpamCheckResult(diagnostics: ScreeningDiagnostics?):
11711171
reasonCode = reasonCode,
11721172
ruleId = ruleId,
11731173
screeningDiagnostics = diagnostics,
1174+
signals = signals,
11741175
)
11751176

11761177
internal fun sanitizeDatabaseNumbers(

‎app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt‎

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import com.sysadmindoc.callshield.data.model.SpamNumber
2323
import com.sysadmindoc.callshield.data.repository.SpamRepositoryImpl
2424
import com.sysadmindoc.callshield.domain.model.CallerIdentitySignals
2525
import com.sysadmindoc.callshield.service.CallerIdOverlayService
26+
import com.sysadmindoc.callshield.ui.joinSignalLabels
27+
import com.sysadmindoc.callshield.ui.signalLabel
2628
import kotlinx.coroutines.withTimeoutOrNull
2729
import java.time.LocalDate
2830
import java.time.ZoneOffset
@@ -916,29 +918,31 @@ internal class HeuristicChecker(
916918
matchSource = "heuristic",
917919
type = classifyHeuristicReasons(hResult.reasons),
918920
description =
919-
hResult.reasons.joinToString(", ") { reason ->
920-
when (reason) {
921-
"sender_provenance_unverified" -> {
922-
appContext.getString(
923-
R.string.block_reason_sender_provenance_unverified,
924-
ctx.senderProvenance?.regionIso.orEmpty(),
925-
)
921+
hResult.reasons
922+
.map { reason ->
923+
when (reason) {
924+
"sender_provenance_unverified" -> {
925+
appContext.getString(
926+
R.string.block_reason_sender_provenance_unverified,
927+
ctx.senderProvenance?.regionIso.orEmpty(),
928+
)
929+
}
930+
931+
"sender_provenance_unassigned" -> {
932+
appContext.getString(
933+
R.string.block_reason_sender_provenance_unassigned,
934+
ctx.senderProvenance?.matchedPrefix.orEmpty(),
935+
ctx.senderProvenance?.regionIso.orEmpty(),
936+
)
937+
}
938+
939+
else -> {
940+
signalLabel(appContext, reason)
941+
}
926942
}
927-
928-
"sender_provenance_unassigned" -> {
929-
appContext.getString(
930-
R.string.block_reason_sender_provenance_unassigned,
931-
ctx.senderProvenance?.matchedPrefix.orEmpty(),
932-
ctx.senderProvenance?.regionIso.orEmpty(),
933-
)
934-
}
935-
936-
else -> {
937-
reason.replace("_", " ")
938-
}
939-
}
940-
},
943+
}.joinSignalLabels(appContext),
941944
confidence = hResult.score,
945+
signals = hResult.reasons,
942946
)
943947
}
944948

@@ -1176,8 +1180,9 @@ internal class SmsContentChecker(
11761180
BlockResult.block(
11771181
matchSource = "sms_content",
11781182
type = "sms_spam",
1179-
description = result.reasons.joinToString(", ") { it.replace("_", " ") },
1183+
description = result.reasons.map { signalLabel(ctx.appContext, it) }.joinSignalLabels(ctx.appContext),
11801184
confidence = result.score,
1185+
signals = result.reasons,
11811186
)
11821187
} else {
11831188
null

‎app/src/main/java/com/sysadmindoc/callshield/data/checker/IChecker.kt‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ data class BlockResult(
169169
val reasonCode: BlockReasonCode = BlockReasonCode.fromMatchSource(matchSource),
170170
/** User-authored rule/row id when the deciding checker has one. */
171171
val ruleId: Long? = null,
172+
/**
173+
* Raw signal tokens behind a scored block ("neighbor_spoof", "toll_free"…).
174+
* [description] is display text in the app language, so code that
175+
* classifies a result reads these instead.
176+
*/
177+
val signals: List<String> = emptyList(),
172178
) {
173179
companion object {
174180
fun block(
@@ -177,7 +183,8 @@ data class BlockResult(
177183
description: String = "",
178184
confidence: Int = 100,
179185
ruleId: Long? = null,
180-
) = BlockResult(true, matchSource, type, description, confidence, BlockReasonCode.fromMatchSource(matchSource), ruleId)
186+
signals: List<String> = emptyList(),
187+
) = BlockResult(true, matchSource, type, description, confidence, BlockReasonCode.fromMatchSource(matchSource), ruleId, signals)
181188

182189
fun allow(
183190
matchSource: String,

‎app/src/main/java/com/sysadmindoc/callshield/domain/model/SpamCheckResult.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ data class SpamCheckResult(
99
val reasonCode: BlockReasonCode = BlockReasonCode.fromMatchSource(matchSource),
1010
val ruleId: Long? = null,
1111
val screeningDiagnostics: ScreeningDiagnostics? = null,
12+
/** Raw signal tokens behind a scored block; [description] is display text. */
13+
val signals: List<String> = emptyList(),
1214
)

‎app/src/main/java/com/sysadmindoc/callshield/service/RcsNotificationListener.kt‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.sysadmindoc.callshield.service
33
import android.app.Notification
44
import android.app.Person
55
import android.content.ComponentName
6+
import android.content.Context
67
import android.os.Build
78
import android.os.Bundle
89
import android.service.notification.NotificationListenerService
@@ -18,7 +19,11 @@ import com.sysadmindoc.callshield.data.SmsContentAnalyzer
1819
import com.sysadmindoc.callshield.data.SpamRepository
1920
import com.sysadmindoc.callshield.data.remote.UrlSafetyChecker
2021
import com.sysadmindoc.callshield.data.repository.SpamRepositoryAdapter
22+
import com.sysadmindoc.callshield.domain.model.SpamCheckResult
2123
import com.sysadmindoc.callshield.domain.usecase.CheckSpamSmsUseCase
24+
import com.sysadmindoc.callshield.ui.joinSignalLabels
25+
import com.sysadmindoc.callshield.ui.reasonCodeLabelRes
26+
import com.sysadmindoc.callshield.ui.signalLabel
2227
import com.sysadmindoc.callshield.util.filterAsciiDigits
2328
import kotlinx.coroutines.CoroutineScope
2429
import kotlinx.coroutines.Dispatchers
@@ -255,11 +260,7 @@ class RcsNotificationListener : NotificationListenerService() {
255260
}
256261
val isSpam = result?.isSpam == true || contentVerdict?.isSpam == true
257262
val confidence = maxOf(result?.confidence ?: 0, contentVerdict?.confidence ?: 0)
258-
val reason =
259-
result
260-
?.takeIf { it.isSpam }
261-
?.matchSource
262-
?: contentVerdict?.reason.orEmpty()
263+
val reason = screenedMessageReason(applicationContext, result, contentVerdict)
263264

264265
if (isSpam && source.category == NotificationScreeningCategory.RCS && result?.isSpam == true) {
265266
cancelNotification(sbn.key)
@@ -431,24 +432,45 @@ class RcsNotificationListener : NotificationListenerService() {
431432
internal data class ContentVerdict(
432433
val isSpam: Boolean,
433434
val confidence: Int,
434-
val reason: String,
435+
/** Raw signal tokens; the notification labels them in the app language. */
436+
val signals: List<String>,
435437
)
436438

437439
internal fun contentVerdict(
438440
body: String,
439441
enabled: Boolean,
440442
aggressive: Boolean,
441443
): ContentVerdict {
442-
if (!enabled) return ContentVerdict(false, 0, "")
444+
if (!enabled) return ContentVerdict(false, 0, emptyList())
443445
val analysis = SmsContentAnalyzer.analyze(body)
444446
val threshold = if (aggressive) AGGRESSIVE_CONTENT_THRESHOLD else DEFAULT_CONTENT_THRESHOLD
445447
return ContentVerdict(
446448
isSpam = analysis.score >= threshold,
447449
confidence = analysis.score,
448-
reason = analysis.reasons.joinToString(", ") { it.replace('_', ' ') },
450+
signals = analysis.reasons,
449451
)
450452
}
451453

454+
/**
455+
* The "why" line of a screened-message notification, in the app
456+
* language: the deciding check's label, or the content signals.
457+
* Both used to reach the notification as internal tokens
458+
* ("heuristic", "spam keywords, shortened url").
459+
*/
460+
internal fun screenedMessageReason(
461+
context: Context,
462+
result: SpamCheckResult?,
463+
contentVerdict: ContentVerdict?,
464+
): String =
465+
result
466+
?.takeIf { it.isSpam }
467+
?.let { context.getString(reasonCodeLabelRes(it.reasonCode)) }
468+
?: contentVerdict
469+
?.signals
470+
?.map { signalLabel(context, it) }
471+
?.joinSignalLabels(context)
472+
.orEmpty()
473+
452474
/** Return real content only; encrypted/empty placeholders stay sender-only. */
453475
internal fun bodyForAnalysis(body: String): String? = body.takeIf { it.isNotBlank() && !isEncryptedPlaceholder(it) }
454476

‎app/src/main/java/com/sysadmindoc/callshield/ui/MatchReasonLabels.kt‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
44
import androidx.compose.ui.res.stringResource
55
import com.sysadmindoc.callshield.R
66
import com.sysadmindoc.callshield.domain.model.BlockReasonCode
7+
import com.sysadmindoc.callshield.service.overlayReasonLabelRes
78

89
/**
910
* Resource id for a spam `type` token, or null when it is not one we know.
@@ -50,6 +51,34 @@ fun friendlySpamTypeLabel(type: String): String =
5051
?.let { stringResource(it) }
5152
?: stringResource(R.string.spam_type_other)
5253

54+
/**
55+
* Resource id for a heuristic or message-content signal token, or null when
56+
* it is not one we know. Tokens a call can raise share the overlay's wording.
57+
*/
58+
fun signalLabelRes(signal: String): Int? =
59+
when (signal.trim().lowercase().replace(' ', '_')) {
60+
"oversized_body" -> R.string.signal_oversized_body
61+
"excessive_caps" -> R.string.signal_excessive_caps
62+
"callback_number" -> R.string.signal_callback_number
63+
"special_chars" -> R.string.signal_special_chars
64+
"short_msg_with_url" -> R.string.signal_short_message_with_link
65+
"spam_domain" -> R.string.signal_spam_domain
66+
"lookalike_host" -> R.string.signal_lookalike_host
67+
else -> overlayReasonLabelRes(signal)
68+
}
69+
70+
/** A signal in the app language; a token without a label still reads as words. */
71+
fun signalLabel(
72+
context: android.content.Context,
73+
signal: String,
74+
): String = signalLabelRes(signal)?.let(context::getString) ?: signal.replace('_', ' ')
75+
76+
/**
77+
* Joins signal labels for a block description, each once: two risky links
78+
* or a shortened link next to a suspicious one would otherwise repeat.
79+
*/
80+
fun List<String>.joinSignalLabels(context: android.content.Context): String = distinct().joinToString(context.getString(R.string.signal_list_separator))
81+
5382
/** Resource id for a stable, user-facing pipeline checker label. */
5483
fun pipelineCheckerLabelRes(checkerName: String): Int =
5584
when (checkerName.trim().lowercase()) {

0 commit comments

Comments
 (0)