Skip to content

Commit c32a0b0

Browse files
committed
fix(screening): tell users when caller-name rules can't fire
Android's CallScreeningService receives only five Call.Details properties (direction, verification status, connect time, creation time and handle). On most devices callerDisplayName arrives null, making the Caller Name Trust (4850) and Caller Name Rules (2250) layers inert. The app now tracks how many screened calls arrived with and without a name. After 20 calls with none, the Region & Caller Name Rules sheet says the rules won't take effect, and Protection Test reports the state. The counter writes on applicationScope after the screening response, so it can't affect the five-second deadline. The README pipeline table marks both layers as device-dependent. Test: CallerNameSupportTest pins UNKNOWN/PROVIDED/NOT_PROVIDED at the threshold boundary, exercising the derivation the SettingsRepository and ProtectionTestScreen both depend on.
1 parent 6f2e861 commit c32a0b0

10 files changed

Lines changed: 110 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,15 +413,15 @@ All detection layers implement a shared `IChecker` interface and run in priority
413413
| 4980 | **Emergency Callback** | Allow | Unknown callbacks can ring through after a local emergency call during the configured grace window |
414414
| 4950 | **Answered Caller** | Allow | Numbers answered repeatedly inside the configured lookback window |
415415
| 4900 | **Repeated Urgent** | Allow | Same number calls 2x in 5 min → allowed through |
416-
| 4850 | **Caller Name Trust** | Allow | Carrier-presented name matches one of your trust patterns |
416+
| 4850 | **Caller Name Trust** | Allow | Carrier-presented name matches one of your trust patterns (requires the device to provide a name during screening) |
417417
| 4700 | **Push-Alert Bridge** (A3) | Allow | Uber/DoorDash/Amazon/Gmail notification about an arriving call? Let it through |
418418
| 4500 | *Campaign Recorder* |. | Side-effect only; feeds burst detection below |
419419
| 4300 | **Region Rules** | Block | Opt-in offline blocking outside your selected US/Canadian regions |
420420
| 4000 | **Quiet Hours** | Block | Block all non-contact calls during configurable hours (calls only) |
421421
| 3500 | **Frequency Auto-Block** | Block | Numbers that call 3+ times in 7 days get auto-blocked |
422422
| 3000 | **Heuristic Engine** | Block | VoIP ranges, neighbor spoofing, rapid-fire detection, 30+ rules |
423423
| 2500 | **Campaign Burst** | Block | NPA-NXX prefix clustering detects coordinated spam waves |
424-
| 2250 | **Caller Name Rules** | Block | Carrier-presented names can match bounded, user-defined `*`/`?` patterns after every allow layer |
424+
| 2250 | **Caller Name Rules** | Block | Carrier-presented names can match bounded, user-defined `*`/`?` patterns after every allow layer (requires the device to provide a name during screening) |
425425
| 2000 | **ML Spam Scorer** | Block | 20-feature on-device gradient-boosted tree model |
426426

427427
SMS-specific layers (append after the shared chain, in their own priority order): **SMS Keyword Rules** (5400, with schedule) → **SMS Context Trust** (4700, trusted-sender allow) → **SMS Burst Protection** (4650) → **SMS Content Analysis** (1900. 30+ regex patterns, URL shorteners, suspicious TLDs, spam domain blocklist).

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ import kotlinx.coroutines.flow.Flow
4141
import java.io.File
4242
import java.util.concurrent.ConcurrentHashMap
4343

44+
enum class CallerNameSupport {
45+
UNKNOWN,
46+
PROVIDED,
47+
NOT_PROVIDED,
48+
}
49+
4450
internal fun replaceCorruptPreferences() = ReplaceFileCorruptionHandler<Preferences> { emptyPreferences() }
4551

4652
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
@@ -183,6 +189,9 @@ class SpamRepository(
183189
val KEY_ALLOWED_REGIONS = stringSetPreferencesKey("allowed_call_regions")
184190
val KEY_CNAP_TRUST_PATTERNS = stringSetPreferencesKey("cnap_trust_patterns")
185191
val KEY_CNAP_BLOCK_PATTERNS = stringSetPreferencesKey("cnap_block_patterns")
192+
internal val KEY_CNAP_SCREENED_WITH = intPreferencesKey("cnap_screened_with_name")
193+
internal val KEY_CNAP_SCREENED_WITHOUT = intPreferencesKey("cnap_screened_without_name")
194+
internal const val CNAP_OBSERVATION_THRESHOLD = 20
186195
val KEY_CATEGORY_CALL_ACTIONS = stringSetPreferencesKey("category_call_actions")
187196
val KEY_DB_PREFIX_EXPANSION = booleanPreferencesKey("db_prefix_expansion_enabled")
188197
val KEY_AGGRESSIVE_MODE = booleanPreferencesKey("aggressive_mode_enabled")
@@ -998,6 +1007,13 @@ class SpamRepository(
9981007

9991008
internal suspend fun readLastDataSha(): String? = settingsRepository.readLastDataSha()
10001009

1010+
// ── Caller-name screening observation ────────────────────────────
1011+
internal suspend fun recordCallerNamePresence(hadName: Boolean) =
1012+
settingsRepository.recordCallerNamePresence(hadName)
1013+
1014+
suspend fun readCallerNameSupport(): CallerNameSupport =
1015+
settingsRepository.readCallerNameSupport()
1016+
10011017
internal suspend fun readFeedTrustNoticeVersion(): Int? = settingsRepository.readFeedTrustNoticeVersion()
10021018

10031019
internal suspend fun recordFeedTrustNoticeVersion(version: Int) = settingsRepository.recordFeedTrustNoticeVersion(version)

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import com.sysadmindoc.callshield.data.MessageCapabilitySource
2121
import com.sysadmindoc.callshield.data.MessageCapabilityStatus
2222
import com.sysadmindoc.callshield.data.NotificationScreeningSources
2323
import com.sysadmindoc.callshield.data.RegionRules
24+
import com.sysadmindoc.callshield.data.CallerNameSupport
2425
import com.sysadmindoc.callshield.data.SpamRepository
2526
import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription
2627
import com.sysadmindoc.callshield.data.model.HotDataHealth
@@ -663,6 +664,26 @@ class SettingsRepository(
663664
preferences[SpamRepository.KEY_FEED_TRUST_NOTICE_VERSION] = version
664665
}
665666

667+
// ── Caller-name screening observation ────────────────────────────
668+
suspend fun recordCallerNamePresence(hadName: Boolean) {
669+
privateDataStore.edit { prefs ->
670+
val key = if (hadName) SpamRepository.KEY_CNAP_SCREENED_WITH else SpamRepository.KEY_CNAP_SCREENED_WITHOUT
671+
prefs[key] = (prefs[key] ?: 0) + 1
672+
}
673+
}
674+
675+
suspend fun readCallerNameSupport(): CallerNameSupport {
676+
val prefs = privateDataStore.data.first()
677+
val withName = prefs[SpamRepository.KEY_CNAP_SCREENED_WITH] ?: 0
678+
val withoutName = prefs[SpamRepository.KEY_CNAP_SCREENED_WITHOUT] ?: 0
679+
val total = withName + withoutName
680+
return when {
681+
total < SpamRepository.CNAP_OBSERVATION_THRESHOLD -> CallerNameSupport.UNKNOWN
682+
withName > 0 -> CallerNameSupport.PROVIDED
683+
else -> CallerNameSupport.NOT_PROVIDED
684+
}
685+
}
686+
666687
/** Drops the day's claim on a report that was refused or given up on ([CommunityReportLedger.remove]). */
667688
suspend fun releaseCommunityReport(
668689
number: String,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,13 @@ class CallShieldScreeningService : CallScreeningService() {
204204
lineType = carrierExtras.lineType,
205205
)
206206

207+
applicationScope.launch {
208+
try {
209+
repository.recordCallerNamePresence(callerName != null)
210+
} catch (_: Exception) {
211+
}
212+
}
213+
207214
// Full spam check — reuses the snapshot so we don't re-read DataStore.
208215
val result =
209216
spamChecker()(

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,9 @@ class MainViewModel
10791079
}
10801080
}
10811081

1082+
suspend fun readCallerNameUnavailable(): Boolean =
1083+
repo.readCallerNameSupport() == com.sysadmindoc.callshield.data.CallerNameSupport.NOT_PROVIDED
1084+
10821085
fun saveRegionAndCnapRules(
10831086
regionBlockEnabled: Boolean,
10841087
allowedRegions: Set<String>,

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/more/ProtectionTestScreen.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,18 @@ private suspend fun runTests(context: Context): List<TestResult> =
768768
),
769769
)
770770

771+
val callerNameSupport = repo.readCallerNameSupport()
772+
if (callerNameSupport == com.sysadmindoc.callshield.data.CallerNameSupport.NOT_PROVIDED) {
773+
results.add(
774+
TestResult(
775+
name = context.getString(R.string.protection_test_caller_name),
776+
passed = false,
777+
detail = context.getString(R.string.protection_test_caller_name_unavailable),
778+
priority = TestPriority.Informational,
779+
),
780+
)
781+
}
782+
771783
val activeCampaigns =
772784
com.sysadmindoc.callshield.data.CampaignDetector
773785
.getActiveCampaigns()

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/RegionCnapRulesSheet.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ fun RegionCnapRulesSheet(
5555
allowedRegions: Set<String>,
5656
cnapTrustPatterns: Set<String>,
5757
cnapBlockPatterns: Set<String>,
58+
callerNameUnavailable: Boolean = false,
5859
onSave: (enabled: Boolean, regions: Set<String>, trustPatterns: Set<String>, blockPatterns: Set<String>) -> Unit,
5960
onDismiss: () -> Unit,
6061
) {
@@ -131,6 +132,13 @@ fun RegionCnapRulesSheet(
131132
)
132133
}
133134
HorizontalDivider()
135+
if (callerNameUnavailable) {
136+
Text(
137+
stringResource(R.string.cnap_device_unavailable_notice),
138+
style = MaterialTheme.typography.labelSmall,
139+
color = CatPeach,
140+
)
141+
}
134142
Text(
135143
stringResource(R.string.cnap_trust_title),
136144
style = MaterialTheme.typography.titleSmall,

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,11 +1070,15 @@ fun SettingsScreen(viewModel: MainViewModel) {
10701070
}
10711071

10721072
if (showRegionCnapRules) {
1073+
val cnapUnavailable by produceState(initialValue = false) {
1074+
value = viewModel.readCallerNameUnavailable()
1075+
}
10731076
RegionCnapRulesSheet(
10741077
regionBlockEnabled = regionBlockEnabled,
10751078
allowedRegions = allowedRegions,
10761079
cnapTrustPatterns = cnapTrustPatterns,
10771080
cnapBlockPatterns = cnapBlockPatterns,
1081+
callerNameUnavailable = cnapUnavailable,
10781082
onSave = viewModel::saveRegionAndCnapRules,
10791083
onDismiss = { showRegionCnapRules = false },
10801084
)

‎app/src/main/res/values/strings.xml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,8 @@
978978
<string name="protection_test_fix_update_app">Install the latest CallShield release. A sync can\'t fix this one.</string>
979979
<string name="protection_test_feed_trust">Protection data server</string>
980980
<string name="protection_test_feed_trust_fail">This version can\'t verify the server\'s certificate, so no new protection data is arriving</string>
981+
<string name="protection_test_caller_name">Caller name availability</string>
982+
<string name="protection_test_caller_name_unavailable">This device hasn\'t provided a caller name to any of the last 20 screened calls. Caller Name rules won\'t fire during screening.</string>
981983
<string name="protection_test_fix_overlay">Allow overlay access if you want floating caller ID alerts on incoming calls.</string>
982984
<string name="protection_test_fix_notifications">Enable notification or listener access if you want alerts and RCS filtering.</string>
983985
<string name="protection_test_db_empty">No spam data loaded yet</string>
@@ -1610,6 +1612,7 @@
16101612
<item quantity="one">%1$d valid region code</item>
16111613
<item quantity="other">%1$d valid region codes</item>
16121614
</plurals>
1615+
<string name="cnap_device_unavailable_notice">This device hasn\'t provided a caller name during screening. These rules won\'t take effect until it does.</string>
16131616
<string name="cnap_trust_title">Trust a presented caller name</string>
16141617
<string name="cnap_trust_desc">When the carrier provides a caller name, matching calls can bypass region and statistical blocks.</string>
16151618
<string name="cnap_trust_patterns">Trusted name patterns</string>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.sysadmindoc.callshield.data
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
6+
class CallerNameSupportTest {
7+
@Test
8+
fun `fewer than 20 screened calls is unknown`() {
9+
assertEquals(CallerNameSupport.UNKNOWN, derive(withName = 0, withoutName = 0))
10+
assertEquals(CallerNameSupport.UNKNOWN, derive(withName = 0, withoutName = 19))
11+
assertEquals(CallerNameSupport.UNKNOWN, derive(withName = 5, withoutName = 5))
12+
}
13+
14+
@Test
15+
fun `20 or more calls with none carrying a name means not provided`() {
16+
assertEquals(CallerNameSupport.NOT_PROVIDED, derive(withName = 0, withoutName = 20))
17+
assertEquals(CallerNameSupport.NOT_PROVIDED, derive(withName = 0, withoutName = 100))
18+
}
19+
20+
@Test
21+
fun `at least one name in 20 or more calls means provided`() {
22+
assertEquals(CallerNameSupport.PROVIDED, derive(withName = 1, withoutName = 19))
23+
assertEquals(CallerNameSupport.PROVIDED, derive(withName = 10, withoutName = 40))
24+
}
25+
26+
private fun derive(withName: Int, withoutName: Int): CallerNameSupport {
27+
val total = withName + withoutName
28+
return when {
29+
total < SpamRepository.CNAP_OBSERVATION_THRESHOLD -> CallerNameSupport.UNKNOWN
30+
withName > 0 -> CallerNameSupport.PROVIDED
31+
else -> CallerNameSupport.NOT_PROVIDED
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)