Skip to content

Commit 5605356

Browse files
committed
feat(sms): read message text after undoing disguises
Homoglyphs and invisible characters survive SMS and RCS delivery and hide spam words from rules that read raw text (one Android filter fell from 89.6% to 41.2% accuracy in arXiv 2609.01171). SmsTextNormalizer applies NFKC, drops format characters (zero-width, bidi, soft hyphen, tags), variation selectors and blank-looking letters, and folds Cyrillic, Greek and IPA letters that render as Latin. - Folding applies to the whole message when it reads as Latin (Latin outnumbers Cyrillic and Greek, or every such letter has a Latin twin) and otherwise only inside words that mix scripts, so Russian and Greek messages keep their text. - URL and host checks read the unfolded form, so a Cyrillic look-alike host is never scored or reported as the real domain; invisible characters no longer split a host out of the URL pattern. - A URL host or brand name that hid characters or used look-alikes adds disguised_text (+30), labeled in en and zh. - User keyword rules match the text as written or folded on both sides, keeping their case rules; the checker folds the message once. - Invisible padding no longer stretches a short message with a link. Tests: SmsTextNormalizerTest (JVM) and SmsTextNormalizerIcuTest (Robolectric) share one case table; the ICU test also compares the JDK's NFKC with ICU's Normalizer2 and the format-character set with UCharacter over every code point both know. SmsEvaluationCorpusTest gains zero-width, look-alike, fullwidth and hidden-in-link copies of every flagged spam example (all stay flagged; run against the previous analyzer, dozens escaped), and pins false alarms at none, the count before this change. No existing assertion changed. The corpus README paragraph said seven locales; it has eight.
1 parent e120f25 commit 5605356

12 files changed

Lines changed: 608 additions & 30 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ All notable changes to CallShield will be documented in this file.
5353
the community report waits until that's over, so an undone block is never
5454
reported. Restore with Replace keeps a copy of the sections it's about to
5555
clear, and Undo next to the result puts them back for 30 seconds.
56+
- Text rules see through disguised spam. Fullwidth and math-style letters,
57+
zero-width and other invisible characters, and Cyrillic or Greek letters
58+
that look Latin are undone before the SMS content checks and your keyword
59+
rules run. A link or brand name with hidden or look-alike characters in it
60+
counts against the message. Russian and Greek messages are read as written,
61+
and a look-alike web address is never mistaken for the real one.
5662

5763
## v1.10.0 (2026-09-28)
5864

‎README.md‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<p align="center">
1313
<a href="https://github.com/SysAdminDoc/CallShield/releases/latest"><img src="https://img.shields.io/github/v/release/SysAdminDoc/CallShield?style=flat-square&color=a6e3a1" alt="Release"></a>
1414
<img src="https://img.shields.io/badge/Spam%20Numbers-51%2C363-f38ba8?style=flat-square" alt="51,363 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1675-94e2d5?style=flat-square" alt="1675 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1688-94e2d5?style=flat-square" alt="1688 JVM unit tests">
1616
<img src="https://img.shields.io/badge/Android-10%2B-89b4fa?style=flat-square" alt="Android 10+">
1717
<img src="https://img.shields.io/badge/License-MIT-cba6f7?style=flat-square" alt="MIT License">
1818
<img src="https://img.shields.io/badge/API%20Keys-None-fab387?style=flat-square" alt="No required API keys">
@@ -140,7 +140,7 @@ All detection layers implement a shared `IChecker` interface and run in priority
140140
| 2000 | **ML Spam Scorer** | Block | 20-feature on-device gradient-boosted tree model |
141141
| 1500 | **Meeting Mode** | Silence | Opt-in. While a meeting app you pick shows a call in progress, calls from outside your contacts go quietly to voicemail. Runs on notification access, with no calendar permission |
142142

143-
SMS-specific layers run after the shared chain, in their own priority order: **SMS Keyword Rules** (5400, with schedules) → **Carrier Scam Label** (4750, a sender your carrier renamed to its scam label, such as Singapore's Likely-SCAM) → **SMS Context Trust** (4700, trusted-sender allow) → **SMS Burst Protection** (4650) → **SMS Content Analysis** (1900, with 30+ regex patterns, URL shorteners, suspicious TLDs and the spam-domain list).
143+
SMS-specific layers run after the shared chain, in their own priority order: **SMS Keyword Rules** (5400, with schedules) → **Carrier Scam Label** (4750, a sender your carrier renamed to its scam label, such as Singapore's Likely-SCAM) → **SMS Context Trust** (4700, trusted-sender allow) → **SMS Burst Protection** (4650) → **SMS Content Analysis** (1900, with 30+ regex patterns, URL shorteners, suspicious TLDs and the spam-domain list). Both SMS rule layers read the text after fullwidth letters, invisible characters and Cyrillic or Greek letters posing as Latin are undone, and a link or brand name hiding such characters counts against the message.
144144

145145
> **A flagged text still reaches your inbox.** Calls are different from
146146
> messages here, and the table above is about verdicts, not delivery. CallShield
@@ -214,11 +214,7 @@ An on-device **gradient-boosted tree** of 50 trees over 20 features, with a logi
214214

215215
The maintainer trains it by hand from the CallShield database (50K positive and 50K negative samples). The scorer uses the threshold stored in the weights file, which is 0.648 for the model that ships today. A quality gate scores the 20% of rows training held out, with the same inference the app runs, and fails when F1 drops below 0.45.
216216

217-
SMS content regressions use a separate CC0, CallShield-authored synthetic
218-
corpus covering seven locales, sender forms, link classes, legitimate messages,
219-
and hard negatives. The test reports precision, recall, and false-positive rate
220-
by locale and message type without shipping personal data:
221-
`./gradlew :app:testDebugUnitTest --tests com.sysadmindoc.callshield.data.SmsEvaluationCorpusTest`.
217+
SMS content regressions use a separate CC0 corpus that CallShield wrote itself. It covers eight languages, the three sender forms, each kind of link, legitimate messages and hard negatives, and the test reports precision, recall and false-positive rate by language and message type without shipping personal data. Every spam message it catches is checked again in disguise (zero-width characters between letters, Cyrillic look-alike letters, fullwidth text, a hidden character in its link) and each copy has to be caught too: `./gradlew :app:testDebugUnitTest --tests com.sysadmindoc.callshield.data.SmsEvaluationCorpusTest`.
222218

223219
## Features
224220

@@ -509,12 +505,12 @@ RELEASE_KEY_PASSWORD=...
509505
## Testing
510506

511507
```bash
512-
./gradlew testDebugUnitTest # 1675 tests
508+
./gradlew testDebugUnitTest # 1688 tests
513509
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
514510
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
515511
```
516512

517-
The suite is **1675 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
513+
The suite is **1688 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
518514

519515
Two GitHub workflows run without building the app. **Validation** runs the Worker and
520516
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -551,8 +547,8 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
551547
| Community API | Cloudflare Workers |
552548
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
553549
| Verification | Local Gradle, lint, and release-artifact checks |
554-
| Tests | 1675 JVM unit tests (JUnit) |
555-
| Strings | 1715 string resources and 37 plural groups (translation-ready) |
550+
| Tests | 1688 JVM unit tests (JUnit) |
551+
| Strings | 1716 string resources and 37 plural groups (translation-ready) |
556552
| Accessibility | 100+ content descriptions, 48dp touch targets |
557553
| Min SDK | 29 (Android 10) |
558554
| Target SDK | 36 |

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

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ class SmsContentAnalyzer
301301
val domains = linkedSetOf<String>()
302302
val indicators = linkedSetOf<String>()
303303

304-
urlPattern.findAll(analysisBody).forEach { match ->
304+
// Invisible characters would split a host and hide it from the pattern.
305+
urlPattern.findAll(SmsTextNormalizer.normalize(analysisBody).visible).forEach { match ->
305306
val url = match.value.lowercase()
306307
val domain = normalizeDomainCandidate(extractDomain(url))
307308
indicators.add("url_present")
@@ -352,11 +353,17 @@ class SmsContentAnalyzer
352353
} else {
353354
body
354355
}
356+
// Hosts are read without invisible characters but keep their own
357+
// letters, so a Cyrillic look-alike of a real domain doesn't pass
358+
// for it. Every other rule reads look-alikes as Latin.
359+
val normalized = SmsTextNormalizer.normalize(analysisBody)
360+
val visibleText = normalized.visible
361+
val ruleText = normalized.folded
355362

356363
// Check for URL shorteners (high spam signal)
357364
val urls =
358-
if ('.' in analysisBody || analysisBody.contains("http", ignoreCase = true)) {
359-
urlPattern.findAll(analysisBody).map { it.value.lowercase() }.toList()
365+
if ('.' in visibleText || visibleText.contains("http", ignoreCase = true)) {
366+
urlPattern.findAll(visibleText).map { it.value.lowercase() }.toList()
360367
} else {
361368
emptyList()
362369
}
@@ -372,10 +379,15 @@ class SmsContentAnalyzer
372379
}
373380
}
374381

382+
if (normalized.disguised) {
383+
score += 30
384+
reasons.add("disguised_text")
385+
}
386+
375387
// Check spam keyword patterns
376388
var patternHits = 0
377389
for (pattern in spamPatterns) {
378-
if (pattern.containsMatchIn(analysisBody)) {
390+
if (pattern.containsMatchIn(ruleText)) {
379391
patternHits++
380392
if (patternHits == 1) {
381393
score += 25
@@ -388,32 +400,31 @@ class SmsContentAnalyzer
388400
}
389401

390402
// All caps text (>50% of message) — shouting is a spam signal
391-
val alphaChars = analysisBody.filter { it.isLetter() }
403+
val alphaChars = ruleText.filter { it.isLetter() }
392404
if (alphaChars.length > 10 && alphaChars.count { it.isUpperCase() }.toFloat() / alphaChars.length > 0.5f) {
393405
score += 15
394406
reasons.add("excessive_caps")
395407
}
396408

397409
// Contains phone number in body (callback scam) — regex is
398410
// case-insensitive so we match the original body instead of lowercasing.
399-
if (phoneInBody.containsMatchIn(analysisBody)) {
411+
if (phoneInBody.containsMatchIn(ruleText)) {
400412
score += 10
401413
reasons.add("callback_number")
402414
}
403415

404416
// Excessive special characters / emoji (common in spam)
405417
val specialRatio =
406-
analysisBody.count { !it.isLetterOrDigit() && !it.isWhitespace() }.toFloat() /
407-
analysisBody.length.coerceAtLeast(1)
408-
if (specialRatio > 0.15f && analysisBody.length > 20) {
418+
ruleText.count { !it.isLetterOrDigit() && !it.isWhitespace() }.toFloat() /
419+
ruleText.length.coerceAtLeast(1)
420+
if (specialRatio > 0.15f && ruleText.length > 20) {
409421
score += 10
410422
reasons.add("special_chars")
411423
}
412424

413-
// Very short message with URL (likely phishing).
414-
// Uses original body length so a 20-char SMS still triggers when
415-
// [analysisBody] hasn't been truncated.
416-
if (body.length < 50 && urls.isNotEmpty()) {
425+
// Very short message with URL (likely phishing). Counted without
426+
// invisible characters, which would otherwise pad it past the limit.
427+
if (visibleText.length < 50 && urls.isNotEmpty()) {
417428
score += 20
418429
reasons.add("short_msg_with_url")
419430
}

0 commit comments

Comments
 (0)