Skip to content

Commit 3bbc425

Browse files
committed
fix(overlay): show the Possible spam warning only once a call will ring
HeuristicChecker started the incoming overlay in the middle of the checker pipeline, so a mid-band call that a lower-priority checker (campaign burst, caller name, ML) then rejected flashed the warning until the rejection withdrew it. The checker now records the score and reason on CheckContext.suspicion, SpamRepositoryImpl carries it on the SpamCheckResult, and the screening service shows it after the verdict: on an allow, on a silence, and never on a reject. Test changes: the setUp overlay launcher lambda now takes the live lookup flag, because the screening service passes it where the heuristic used to read it itself, and tearDown resets the shared hot ranges the new tests seed. Three new tests cover a mid-band call that rings, one a later check rejects and one it silences. No existing test assertion changed. With the old direct overlay start put back, the reject and ring tests fail.
1 parent 21d6413 commit 3bbc425

9 files changed

Lines changed: 164 additions & 54 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ All notable changes to CallShield will be documented in this file.
44

55
## Unreleased
66

7+
- The "Possible spam" popup no longer flashes up for a moment on a call that CallShield then rejects. It used to open while the call was still being checked, so a later check that rejected the call left it on screen until it was taken back. Now it waits until the call is going to ring, or go to silent voicemail, and a rejected call never shows it.
78
- With TalkBack on and a large font size, the bottom tabs other than the open one had no name for TalkBack to read. At 150% and up the bar shows just the open tab's label, and the others now read their names from their icons. At normal sizes each tab still reads its name once.
89
- Tapping Not spam on a blocked call alert for a number you blocked yourself now shows a short "Your own block still applies." and turns the alert into a notification that explains why. Tap it and the number opens in CallShield, where you can remove the block. The old message was a toast, and Android 12 and later cut it off after two lines, so the part about where to remove the block never showed. The Not spam and empty crash log messages are shorter too, so they fit at the largest font size.
910
- The caller ID popup no longer stays up for 20 seconds after CallShield rejects a call it first showed as possible spam. A rejected call never rings, so nothing told the popup the call was over, and it kept offering Block for a call that was already gone. It closes as soon as the call is rejected now.

‎README.md‎

Lines changed: 4 additions & 4 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%2C403-f38ba8?style=flat-square" alt="51,403 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2071-94e2d5?style=flat-square" alt="2071 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2074-94e2d5?style=flat-square" alt="2074 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">
@@ -592,12 +592,12 @@ RELEASE_KEY_PASSWORD=...
592592
## Testing
593593

594594
```bash
595-
./gradlew testDebugUnitTest # 2071 tests
595+
./gradlew testDebugUnitTest # 2074 tests
596596
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
597597
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
598598
```
599599

600-
The suite is **2071 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
600+
The suite is **2074 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
601601

602602
Two GitHub workflows run without building the app. **Validation** runs the Worker and
603603
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -634,7 +634,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
634634
| Community API | Cloudflare Workers |
635635
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
636636
| Verification | Local Gradle, lint, and release-artifact checks |
637-
| Tests | 2071 JVM unit tests (JUnit) |
637+
| Tests | 2074 JVM unit tests (JUnit) |
638638
| Strings | 1796 string resources and 49 plural groups (translation-ready) |
639639
| Accessibility | 100+ content descriptions, 48dp touch targets |
640640
| Min SDK | 29 (Android 10) |

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

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import com.sysadmindoc.callshield.data.SpamRepository
2727
import com.sysadmindoc.callshield.data.SystemBlockList
2828
import com.sysadmindoc.callshield.data.model.SpamNumber
2929
import com.sysadmindoc.callshield.data.repository.SpamRepositoryImpl
30+
import com.sysadmindoc.callshield.domain.model.CallSuspicion
3031
import com.sysadmindoc.callshield.permissions.CallShieldPermissions
31-
import com.sysadmindoc.callshield.service.CallerIdOverlayService
3232
import com.sysadmindoc.callshield.ui.describeIdentityEvidence
3333
import com.sysadmindoc.callshield.ui.joinSignalLabels
3434
import com.sysadmindoc.callshield.ui.signalLabel
@@ -1046,17 +1046,13 @@ internal class HeuristicChecker(
10461046
)
10471047
}
10481048

1049-
// Suspicious-but-not-blocked overlay (realtime CALLS only, score 30..threshold).
1049+
// Suspicious-but-not-blocked warning (realtime CALLS only, score 30..threshold).
10501050
// Guard on smsBody == null so a suspicious SMS re-scored through the shared
10511051
// chain never pops an incoming-call overlay or fires live caller lookups.
1052+
// The screening service shows it once it knows a later checker didn't
1053+
// reject the call, so a rejected call never flashes it.
10521054
if (ctx.realtimeCall && ctx.smsBody == null && hResult.score in 30 until threshold) {
1053-
showCallerIdOverlay(
1054-
appContext,
1055-
ctx.number,
1056-
hResult.score,
1057-
hResult.reasons.firstOrNull() ?: "suspicious",
1058-
ctx.prefs[SpamRepository.KEY_LIVE_CALLER_ENRICHMENT] ?: false,
1059-
)
1055+
ctx.suspicion = CallSuspicion(hResult.score, hResult.reasons.firstOrNull() ?: "suspicious")
10601056
}
10611057
return null
10621058
}
@@ -1082,20 +1078,6 @@ internal class HeuristicChecker(
10821078
(ctx.prefs[SpamRepository.KEY_SMS_CONTENT] ?: true) && !ctx.smsContextTrusted
10831079
}
10841080
}
1085-
1086-
private fun showCallerIdOverlay(
1087-
ctx: Context,
1088-
number: String,
1089-
confidence: Int,
1090-
reason: String,
1091-
liveEnrichmentEnabled: Boolean,
1092-
) {
1093-
try {
1094-
CallerIdOverlayService.startIncoming(ctx, number, confidence, reason, liveEnrichmentEnabled)
1095-
} catch (_: Exception) {
1096-
// overlay is best-effort
1097-
}
1098-
}
10991081
}
11001082

11011083
internal class CampaignBurstChecker(

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.sysadmindoc.callshield.data.SenderProvenance
88
import com.sysadmindoc.callshield.data.SpamRepository
99
import com.sysadmindoc.callshield.data.repository.SpamRepositoryImpl
1010
import com.sysadmindoc.callshield.domain.model.BlockReasonCode
11+
import com.sysadmindoc.callshield.domain.model.CallSuspicion
1112
import com.sysadmindoc.callshield.domain.model.CallerIdentity
1213
import com.sysadmindoc.callshield.domain.model.ScreeningDiagnostics
1314
import kotlinx.coroutines.CancellationException
@@ -154,6 +155,13 @@ data class CheckContext(
154155
/** Shared plan classification for NANP-only checkers in this evaluation. */
155156
val numberingPlan: NumberingPlan = NumberingPlan.from(number, homeRegionIso)
156157

158+
/**
159+
* Set by [HeuristicChecker] when a live call scores too low to block. A
160+
* later checker can still block the call, so the screening service shows
161+
* it only once the call is going to ring.
162+
*/
163+
var suspicion: CallSuspicion? = null
164+
157165
/**
158166
* Forms to try when matching stored numbers exactly, canonical first.
159167
* Allow and block lookups both use it, so a number saved in any form

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,10 +264,10 @@ class SpamRepositoryImpl(
264264
val pipelineRun = CheckerPipeline.runWithDiagnostics(callChain, ctx)
265265
val verdict = pipelineRun.result
266266
if (verdict == null) {
267-
return SpamCheckResult(isSpam = false, screeningDiagnostics = pipelineRun.diagnostics)
267+
return SpamCheckResult(isSpam = false, screeningDiagnostics = pipelineRun.diagnostics, suspicion = ctx.suspicion)
268268
}
269269

270-
val result = verdict.toSpamCheckResult(pipelineRun.diagnostics)
270+
val result = verdict.toSpamCheckResult(pipelineRun.diagnostics).copy(suspicion = ctx.suspicion)
271271
return if (smsBody == null) CategoryCallPolicy.apply(result, prefs) else result
272272
}
273273

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,12 @@ data class SpamCheckResult(
1111
val screeningDiagnostics: ScreeningDiagnostics? = null,
1212
/** Raw signal tokens behind a scored block; [description] is display text. */
1313
val signals: List<String> = emptyList(),
14+
/** A live call's below-threshold heuristic score, shown only if the call goes on to ring. */
15+
val suspicion: CallSuspicion? = null,
16+
)
17+
18+
/** A heuristic [score] too low to block, and the first [reason] behind it. */
19+
data class CallSuspicion(
20+
val score: Int,
21+
val reason: String,
1422
)

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

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import com.sysadmindoc.callshield.data.areacodes.AreaCodeLookup
2222
import com.sysadmindoc.callshield.data.checker.MeetingModeChecker
2323
import com.sysadmindoc.callshield.data.local.AppDatabase
2424
import com.sysadmindoc.callshield.di.ApplicationScope
25+
import com.sysadmindoc.callshield.domain.model.CallSuspicion
2526
import com.sysadmindoc.callshield.domain.model.CallerIdentity
2627
import com.sysadmindoc.callshield.domain.usecase.CheckSpamUseCase
2728
import com.sysadmindoc.callshield.permissions.CallShieldPermissions
@@ -71,9 +72,9 @@ class CallShieldScreeningService : CallScreeningService() {
7172
}
7273

7374
/** Kept injectable so the RTT contract can prove that no overlay is launched. */
74-
internal var incomingOverlayLauncher: (android.content.Context, String, Int, String) -> Unit =
75-
{ context, number, confidence, reason ->
76-
CallerIdOverlayService.startIncoming(context, number, confidence, reason)
75+
internal var incomingOverlayLauncher: (android.content.Context, String, Int, String, Boolean) -> Unit =
76+
{ context, number, confidence, reason, liveEnrichment ->
77+
CallerIdOverlayService.startIncoming(context, number, confidence, reason, liveEnrichment)
7778
}
7879

7980
/** Kept injectable so a test can see which calls take their overlay back. */
@@ -250,6 +251,7 @@ class CallShieldScreeningService : CallScreeningService() {
250251
CategoryCallPolicy.parseMatchSource(result.matchSource)?.action
251252
?: CategoryCallAction.INHERIT
252253
if (categoryAction == CategoryCallAction.ALLOW) {
254+
result.suspicion?.let { showSuspicion(number, it, prefs) }
253255
respondAllow(responseGate)
254256
CodeDuringCallWarning.onCallAllowed(applicationScope, appContext, number, result.matchSource)
255257
logPipelineDiagnostic(repository, number, result.screeningDiagnostics?.toWireValue())
@@ -264,21 +266,27 @@ class CallShieldScreeningService : CallScreeningService() {
264266
pipelineDiagnostic = result.screeningDiagnostics?.toWireValue(),
265267
prefs = prefs,
266268
callerIdentity = callerIdentity,
269+
suspicion = result.suspicion,
267270
)
268271
}
269272
} else {
270273
val repeatedUrgentAllow = result.matchSource == "repeated_urgent"
271274
val suppressFeedback = shouldSuppressAfterCallFeedback(result.matchSource)
272-
// Unknown non-contact caller — area-code-only caller ID overlay.
273-
// A number other users reported gets it even with no area code
274-
// to name, since the popup is where its label shows.
275-
val location =
276-
AreaCodeLookup.lookup(number, PhoneIdentityCanonicalizer.cachedFromContext(appContext).homeRegionIso)
277-
?: "".takeIf { heuristics().communityWatchReporterCount(appContext, number) >= SpamHeuristics.COMMUNITY_WATCH_MIN_REPORTERS }
278-
if (location != null) {
279-
try {
280-
incomingOverlayLauncher(appContext, number, 0, location)
281-
} catch (_: Exception) {
275+
val suspicion = result.suspicion
276+
if (suspicion != null) {
277+
showSuspicion(number, suspicion, prefs)
278+
} else {
279+
// Unknown non-contact caller — area-code-only caller ID overlay.
280+
// A number other users reported gets it even with no area code
281+
// to name, since the popup is where its label shows.
282+
val location =
283+
AreaCodeLookup.lookup(number, PhoneIdentityCanonicalizer.cachedFromContext(appContext).homeRegionIso)
284+
?: "".takeIf { heuristics().communityWatchReporterCount(appContext, number) >= SpamHeuristics.COMMUNITY_WATCH_MIN_REPORTERS }
285+
if (location != null) {
286+
try {
287+
incomingOverlayLauncher(appContext, number, 0, location, false)
288+
} catch (_: Exception) {
289+
}
282290
}
283291
}
284292
respondAllow(responseGate)
@@ -378,6 +386,7 @@ class CallShieldScreeningService : CallScreeningService() {
378386
pipelineDiagnostic: String? = null,
379387
prefs: androidx.datastore.preferences.core.Preferences,
380388
callerIdentity: CallerIdentity? = null,
389+
suspicion: CallSuspicion? = null,
381390
) {
382391
val repository = repository()
383392
val logTimestamp = System.currentTimeMillis()
@@ -476,10 +485,12 @@ class CallShieldScreeningService : CallScreeningService() {
476485
),
477486
number,
478487
reason,
488+
suspicion,
489+
prefs,
479490
)
480491
}
481492
} else {
482-
respondRinging(responseGate, response, number, reason)
493+
respondRinging(responseGate, response, number, reason, suspicion, prefs)
483494
}
484495

485496
applicationScope.launch {
@@ -817,15 +828,17 @@ class CallShieldScreeningService : CallScreeningService() {
817828

818829
/**
819830
* Sends a block [response]. A silenced call still reaches the phone and can
820-
* be answered, so it's watched for one-time codes like an allowed one. A
821-
* rejected call never rings, so an overlay a checker opened for it while
822-
* screening ran is taken back.
831+
* be answered, so it's watched for one-time codes like an allowed one, and
832+
* gets the [suspicion] warning. A rejected call never rings, so it gets no
833+
* overlay, and one still up for the same caller is taken back.
823834
*/
824835
private fun respondRinging(
825836
responseGate: ScreeningResponseGate<CallResponse>,
826837
response: CallResponse,
827838
number: String,
828839
reason: String,
840+
suspicion: CallSuspicion?,
841+
prefs: androidx.datastore.preferences.core.Preferences,
829842
) {
830843
val unanswered = !responseGate.hasResponded
831844
responseGate.respond(response)
@@ -837,6 +850,27 @@ class CallShieldScreeningService : CallScreeningService() {
837850
incomingOverlayWithdrawer(applicationContext, number)
838851
} catch (_: Exception) {
839852
}
853+
} else if (unanswered && suspicion != null) {
854+
showSuspicion(number, suspicion, prefs)
855+
}
856+
}
857+
858+
/** Shows the "Possible spam" overlay for a call screening let ring. */
859+
private fun showSuspicion(
860+
number: String,
861+
suspicion: CallSuspicion,
862+
prefs: androidx.datastore.preferences.core.Preferences,
863+
) {
864+
try {
865+
incomingOverlayLauncher(
866+
applicationContext,
867+
number,
868+
suspicion.score,
869+
suspicion.reason,
870+
prefs[SpamRepository.KEY_LIVE_CALLER_ENRICHMENT] ?: false,
871+
)
872+
} catch (_: Exception) {
873+
// The overlay is best effort.
840874
}
841875
}
842876

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -292,12 +292,10 @@ class CallerIdOverlayService : Service() {
292292

293293
// What the active session is showing, so a later start for the SAME
294294
// caller with LESS information can be ignored instead of tearing the
295-
// session down. Concretely: HeuristicChecker shows a "Possible spam N%"
296-
// overlay for mid-band scores during checkSpam(), then the screening
297-
// service's not-spam path fires the generic area-code overlay
298-
// (confidence 0) for every NANP caller — without this guard the second
299-
// start destroyed the suspicion warning before the user could read it
300-
// and restarted the live lookups from scratch.
295+
// session down. Concretely: a generic area-code start (confidence 0)
296+
// arriving after a "Possible spam N%" warning for the same caller would
297+
// otherwise destroy the warning before the user could read it and
298+
// restart the live lookups from scratch.
301299
@Volatile private var activeNumber: String = ""
302300

303301
@Volatile private var activeConfidence = 0
@@ -730,7 +728,8 @@ class CallerIdOverlayService : Service() {
730728
// Auto-dismiss after 20 seconds (backstop). The call-state watcher below
731729
// dismisses sooner, right when the call ends, so a 2-3 s silenced call's
732730
// overlay doesn't hover over unrelated UI for the full 20 s. A call that
733-
// screening rejects never rings, so it's closed by withdrawIncoming().
731+
// screening rejects never rings and gets no overlay, and one still up
732+
// for the same caller is closed by withdrawIncoming().
734733
scheduleDismiss(sessionId, AUTO_DISMISS_MS)
735734
registerCallStateWatcher(sessionId, outgoingRiskWarning)
736735
return sessionId

0 commit comments

Comments
 (0)