Skip to content

Commit 0076a3a

Browse files
committed
fix(screening): make Expecting a call let hidden callers ring
The window promised unknown callers could ring, but a call with no number was rejected as hidden_number whenever block-unknown was on (set by the Contacts only, Personal, Sleep and Maximum profiles) and meeting mode still silenced it, because the empty-number branch runs before the checkers. - ExpectingCall.withheldCallBlockSource: an active window allows first, then block-unknown, then meeting mode. - Strings (en, zh-rCN) name what still blocks, in the block explanation's words, instead of "rules still apply". - The tile runs through unlockAndRun on a locked phone. - An ended window's end time is removed once seen (screening, tile, boot restore, app start), so a clock stepped back can't reopen it. Test files only add cases: the withheld-call decision, the clock-step check, two screening-service cases and a new tile test. README test count 1646 -> 1652.
1 parent 9067cbb commit 0076a3a

14 files changed

Lines changed: 201 additions & 30 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ All notable changes to CallShield will be documented in this file.
2323
database fix their stored copy once, on the first start after the update.
2424
A trending number blocks again when its database entry has run out, and the
2525
weekly pipeline check counts expiry the same way the app does.
26+
- Expecting a call lets hidden callers ring too, including under the Contacts
27+
only, Personal, Sleep and Maximum profiles and during a meeting. Its text now
28+
says what still blocks: your blocked numbers, wildcard rules and a failed
29+
caller ID check. On a locked phone the Quick Settings tile asks you to unlock
30+
before it opens the window, and a window that has ended stays ended even if
31+
the phone's clock moves back.
2632

2733
## v1.10.0 (2026-09-28)
2834

‎README.md‎

Lines changed: 6 additions & 6 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-1646-94e2d5?style=flat-square" alt="1646 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1652-94e2d5?style=flat-square" alt="1652 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">
@@ -82,7 +82,7 @@ Version highlights for each release are in [CHANGELOG.md](CHANGELOG.md).
8282

8383
Let unknown callers ring when you're waiting for a call, and a fix that keeps the spam database blocking between updates.
8484

85-
- **Expecting a call.** From Home or its own Quick Settings tile, unknown callers ring for an hour, three hours or until midnight. Your blocked numbers, wildcard rules and a failed caller ID check still block, and a countdown notification offers End now.
85+
- **Expecting a call.** From Home or its own Quick Settings tile, unknown and hidden callers ring for an hour, three hours or until midnight. Your blocked numbers, wildcard rules and a failed caller ID check still block, and a countdown notification offers End now. On a locked phone the tile asks you to unlock first.
8686
- **The database keeps working.** Every number's evidence was due to expire in October, and a phone stops matching a number once that happens. Complaint evidence now lasts a year, and the weekly data check warns a month ahead. Phones on 1.9.0 get this with their next sync.
8787
- **Reviewed numbers.** A number the maintainer checks by hand can join the database when no imported source covers it, starting with a Munich number reported on GitHub.
8888
- **Easier to use.** The caller ID popup works with TalkBack, the Blocked log and Blocklist fit small screens with large text, and a blocked-call alert offers Not spam for a day.
@@ -116,7 +116,7 @@ All detection layers implement a shared `IChecker` interface and run in priority
116116
| 6900 | **System Block List** (A4) | Block | Read-only bridge to Android's `BlockedNumberContract`. Respects stock Phone/Messages blocks |
117117
| 5500 | **Wildcard / Regex** | Block | Custom patterns like `+1832555*` or full regex, now with optional schedule |
118118
| 5400 | **Range Patterns** (A5) | Block | Length-locked `#` patterns like `+33162######`, with schedule + coverage safety rail |
119-
| 5360 | **Expecting a Call** | Allow | Opt-in window (1 hour, 3 hours or until midnight) from Home or a Quick Settings tile. Unknown callers ring, and contacts-only mode steps aside. Your own blocks, wildcard and range rules and a failed caller ID check still win |
119+
| 5360 | **Expecting a Call** | Allow | Opt-in window (1 hour, 3 hours or until midnight) from Home or a Quick Settings tile. Unknown and hidden callers ring, and contacts-only mode and meeting mode step aside. Your own blocks, wildcard and range rules and a failed caller ID check still win |
120120
| 5350 | **Temporary Allow** | Allow | One-off false-positive recovery from the Blocked Log. Beats all downloaded data, never your own rules |
121121
| 5320 | **Prefix Rules** | Block | Downloaded wangiri country codes, US premium rate (+1900), international premium |
122122
| 5310 | **Regulatory Prefix** | Block | Opt-in (Settings > Telemarketing ranges) blocks for ranges regulators set aside for sales calls: Spain 400 (from 17 October 2026), India 140 (TRAI), Brazil 0303 (ANATEL). Matches the number with its country code, and without it on a phone from that country |
@@ -509,12 +509,12 @@ RELEASE_KEY_PASSWORD=...
509509
## Testing
510510

511511
```bash
512-
./gradlew testDebugUnitTest # 1646 tests
512+
./gradlew testDebugUnitTest # 1652 tests
513513
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
514514
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
515515
```
516516

517-
The suite is **1646 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
517+
The suite is **1652 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
518518

519519
Two GitHub workflows run without building the app. **Validation** runs the Worker and
520520
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -551,7 +551,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
551551
| Community API | Cloudflare Workers |
552552
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
553553
| Verification | Local Gradle, lint, and release-artifact checks |
554-
| Tests | 1646 JVM unit tests (JUnit) |
554+
| Tests | 1652 JVM unit tests (JUnit) |
555555
| Strings | 1708 string resources and 38 plural groups (translation-ready) |
556556
| Accessibility | 100+ content descriptions, 48dp touch targets |
557557
| Min SDK | 29 (Android 10) |

‎app/src/main/java/com/sysadmindoc/callshield/CallShieldApp.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,14 @@ class CallShieldApp :
173173
Log.w("CallShieldApp", "Failed to update stored evidence expiry", e)
174174
}
175175
}
176+
177+
appScope.launch {
178+
try {
179+
SpamRepository.getInstance(this@CallShieldApp).clearEndedExpectingCall(System.currentTimeMillis())
180+
} catch (e: Exception) {
181+
Log.w("CallShieldApp", "Failed to clear an ended expecting-a-call window", e)
182+
}
183+
}
176184
appScope.launch {
177185
try {
178186
DirectBootScreeningStore.observeAndMirror(

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.sysadmindoc.callshield.data
22

33
import androidx.datastore.preferences.core.Preferences
4+
import com.sysadmindoc.callshield.data.checker.MeetingModeChecker
45
import java.time.Instant
56
import java.time.ZoneId
67

@@ -51,4 +52,29 @@ internal object ExpectingCall {
5152
prefs: Preferences,
5253
now: Long,
5354
): Boolean = until(prefs) > now
55+
56+
/** A window that ran out. Its end time is removed once seen, so a clock stepped back can't reopen it. */
57+
fun hasEnded(
58+
prefs: Preferences,
59+
now: Long,
60+
): Boolean = until(prefs) in 1..now
61+
62+
/**
63+
* What rejects or silences a call that came with no number, or null to let it
64+
* ring. A window lets it ring: the expected caller may withhold their number,
65+
* and the window promises unknown callers get through, so it outranks both
66+
* "block hidden numbers" (set by the Contacts only, Personal, Sleep and
67+
* Maximum profiles) and meeting mode.
68+
*/
69+
fun withheldCallBlockSource(
70+
prefs: Preferences,
71+
now: Long,
72+
meetingApp: String?,
73+
): String? =
74+
when {
75+
isActive(prefs, now) -> null
76+
prefs[SpamRepository.KEY_BLOCK_UNKNOWN] ?: false -> "hidden_number"
77+
meetingApp != null -> MeetingModeChecker.MATCH_SOURCE
78+
else -> null
79+
}
5480
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,8 @@ class SpamRepository(
549549

550550
suspend fun setExpectingCallUntil(until: Long) = settingsRepository.setExpectingCallUntil(until)
551551

552+
suspend fun clearEndedExpectingCall(now: Long) = settingsRepository.clearEndedExpectingCall(now)
553+
552554
suspend fun togglePushAlertPackage(
553555
pkg: String,
554556
allowed: Boolean,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,12 @@ class SettingsRepository(
574574

575575
suspend fun setMeetingMode(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_MEETING_MODE] = enabled }
576576

577+
suspend fun clearEndedExpectingCall(now: Long) =
578+
dataStore.edit {
579+
val until = it[SpamRepository.KEY_EXPECTING_CALL_UNTIL]
580+
if (until != null && until <= now) it.remove(SpamRepository.KEY_EXPECTING_CALL_UNTIL)
581+
}
582+
577583
suspend fun setExpectingCallUntil(until: Long) =
578584
dataStore.edit {
579585
if (until > 0L) it[SpamRepository.KEY_EXPECTING_CALL_UNTIL] = until else it.remove(SpamRepository.KEY_EXPECTING_CALL_UNTIL)

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

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import android.util.Log
1010
import com.sysadmindoc.callshield.data.CategoryCallAction
1111
import com.sysadmindoc.callshield.data.CategoryCallPolicy
1212
import com.sysadmindoc.callshield.data.ContactGroupCatalog
13+
import com.sysadmindoc.callshield.data.ExpectingCall
1314
import com.sysadmindoc.callshield.data.OutgoingRiskPolicy
1415
import com.sysadmindoc.callshield.data.OutgoingRiskWarning
1516
import com.sysadmindoc.callshield.data.PhoneIdentityCanonicalizer
@@ -154,6 +155,10 @@ class CallShieldScreeningService : CallScreeningService() {
154155
// One snapshot of all prefs — the 5-second deadline is tight
155156
// and individual Flow.first() calls each spin up a collector.
156157
val prefs = repository.readPrefsSnapshot()
158+
val now = System.currentTimeMillis()
159+
if (ExpectingCall.hasEnded(prefs, now)) {
160+
applicationScope.launch { repository.clearEndedExpectingCall(now) }
161+
}
157162

158163
if (!(prefs[SpamRepository.KEY_BLOCK_CALLS] ?: true)) {
159164
respondAllow(responseGate)
@@ -164,20 +169,14 @@ class CallShieldScreeningService : CallScreeningService() {
164169
val number = repository.normalizeNumber(handle?.schemeSpecificPart.orEmpty())
165170

166171
if (number.isEmpty()) {
167-
// A withheld number never reaches the checkers, so meeting
168-
// mode is applied here; "block unknown" still rejects first.
169-
when {
170-
prefs[SpamRepository.KEY_BLOCK_UNKNOWN] ?: false -> {
171-
respondBlock(callDetails, responseGate, number, "hidden_number", prefs = prefs)
172-
}
173-
174-
MeetingModeChecker.meetingAppInUse(prefs) != null -> {
175-
respondBlock(callDetails, responseGate, number, MeetingModeChecker.MATCH_SOURCE, prefs = prefs)
176-
}
177-
178-
else -> {
179-
respondAllow(responseGate)
180-
}
172+
// A withheld number never reaches the checkers, so the
173+
// expecting-a-call window, "block unknown" and meeting mode
174+
// are applied here, in that order.
175+
val source = ExpectingCall.withheldCallBlockSource(prefs, now, MeetingModeChecker.meetingAppInUse(prefs))
176+
if (source == null) {
177+
respondAllow(responseGate)
178+
} else {
179+
respondBlock(callDetails, responseGate, number, source, prefs = prefs)
181180
}
182181
return@withTimeoutOrNull
183182
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ internal object ExpectingCallController {
3131
refreshTile(context)
3232
}
3333

34-
/** A reboot clears the ongoing notification while the window may still be running. */
34+
/** A reboot clears the ongoing notification while the window may still be running; an ended one is cleared. */
3535
suspend fun restoreNotification(
3636
context: Context,
3737
repo: SpamRepository,
3838
now: Long = System.currentTimeMillis(),
3939
) {
4040
val until = repo.expectingCallUntil.first()
41-
if (until > now) NotificationHelper.showExpectingCall(context, until, now)
41+
if (until > now) NotificationHelper.showExpectingCall(context, until, now) else repo.clearEndedExpectingCall(now)
4242
}
4343

4444
private fun refreshTile(context: Context) {

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ class ExpectingCallTileService : TileService() {
2525
updateTile()
2626
}
2727

28-
override fun onClick() {
29-
super.onClick()
28+
/** Starts an hour, or ends the running window. Replaced in tests. */
29+
internal var toggleWindow: () -> Unit = {
3030
// The process-wide scope, so closing the shade mid-tap can't cancel the write.
3131
CallShieldApp.appScope.launch {
3232
try {
@@ -43,12 +43,21 @@ class ExpectingCallTileService : TileService() {
4343
}
4444
}
4545

46+
override fun onClick() {
47+
super.onClick()
48+
// Opening the window lets strangers ring, so on a locked phone it waits for the unlock.
49+
if (isLocked) unlockAndRun { toggleWindow() } else toggleWindow()
50+
}
51+
4652
private fun updateTile() {
4753
if (qsTile == null) return
4854
scope.launch {
4955
try {
50-
val until = SpamRepository.getInstance(applicationContext).expectingCallUntil.first()
51-
val active = until > System.currentTimeMillis()
56+
val repo = SpamRepository.getInstance(applicationContext)
57+
val until = repo.expectingCallUntil.first()
58+
val now = System.currentTimeMillis()
59+
val active = until > now
60+
if (!active) repo.clearEndedExpectingCall(now)
5261
withContext(Dispatchers.Main) {
5362
val tile = qsTile ?: return@withContext
5463
tile.state = if (active) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE

‎app/src/main/res/values-zh-rCN/strings.xml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,14 +1155,14 @@
11551155
<string name="tile_protection_on">防护已开启</string>
11561156
<string name="tile_protection_off">防护已关闭</string>
11571157
<string name="expecting_call_title">等待来电</string>
1158-
<string name="expecting_call_subtitle">暂时让陌生来电响铃。你拦截的号码和规则仍然有效。</string>
1158+
<string name="expecting_call_subtitle">暂时让陌生来电和隐藏号码来电响铃。你拦截的号码、通配规则以及来电号码验证失败仍会拦截。</string>
11591159
<string name="expecting_call_active">在 %1$s 之前,陌生来电可以响铃。</string>
11601160
<string name="expecting_call_start">开始</string>
11611161
<string name="expecting_call_end">立即结束</string>
11621162
<string name="expecting_call_one_hour">1 小时</string>
11631163
<string name="expecting_call_three_hours">3 小时</string>
11641164
<string name="expecting_call_until_midnight">到午夜</string>
1165-
<string name="expecting_call_notification_text">在 %1$s 之前,陌生来电可以响铃。你拦截的号码和规则仍然有效。</string>
1165+
<string name="expecting_call_notification_text">在 %1$s 之前,陌生来电和隐藏号码来电可以响铃。你拦截的号码、通配规则以及来电号码验证失败仍会拦截。</string>
11661166
<string name="tile_expecting_call_off">点按开启 1 小时</string>
11671167
<string name="tile_expecting_call_on">到 %1$s</string>
11681168
<string name="settings_region_cnap_rules">地区与主叫姓名规则</string>

0 commit comments

Comments
 (0)