Skip to content

Commit baf78ef

Browse files
committed
fix: report a number nothing flagged as spam, and keep Not spam's Undo first
Number Detail sends the live check's type, which is empty for an unflagged number; the Worker filed it as unknown and My reports couldn't decode the entry. The submitter now defaults a blank type to spam. The report result snackbar could hold the Not spam Undo past its five second send timer, so Not spam dismisses whatever is showing first. "Already in the database" now needs a live shared row: the hot list and rows whose evidence ran out don't count. Refs #28
1 parent 2dd6bd5 commit baf78ef

6 files changed

Lines changed: 65 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,11 @@ All notable changes to CallShield will be documented in this file.
131131
there as a smaller "Report publicly on GitHub" link, and the second
132132
Report spam button lower down is gone. The screen also says when a number
133133
is already in the database, or when you reported it in the last 90 days,
134-
before you report it again.
134+
before you report it again. Reporting a number nothing had flagged now
135+
sends it as spam. It used to go out with no category, so the service
136+
filed it as unknown and My reports couldn't show it. Tapping Not spam
137+
right after a report no longer leaves its Undo waiting behind the
138+
report's message.
135139
- The README explains how to install on phones that enforce Google's
136140
developer verification, which started 2026-09-30 in Brazil, Indonesia,
137141
Singapore and Thailand. You can turn on "Allow apps from unverified

‎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%2C368-f38ba8?style=flat-square" alt="51,368 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1948-94e2d5?style=flat-square" alt="1948 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1950-94e2d5?style=flat-square" alt="1950 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">
@@ -556,12 +556,12 @@ RELEASE_KEY_PASSWORD=...
556556
## Testing
557557

558558
```bash
559-
./gradlew testDebugUnitTest # 1948 tests
559+
./gradlew testDebugUnitTest # 1950 tests
560560
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
561561
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
562562
```
563563

564-
The suite is **1948 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
564+
The suite is **1950 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
565565

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

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,17 @@ internal class CommunityReportSubmitter(
6868
val normalized =
6969
CommunityContributor.normalizeForReport(number)
7070
?: return ContributeResult(false, "Invalid number", ContributeOutcome.INVALID_NUMBER)
71-
val vote = CommunityReportLedger.voteOf(type)
71+
// Number Detail sends the live check's type, which is empty for a
72+
// number nothing flagged. The Worker filed that as "unknown", and My
73+
// reports couldn't read back an entry with an empty type.
74+
val reportType = type.ifBlank { "spam" }
75+
val vote = CommunityReportLedger.voteOf(reportType)
7276
if (!claim(normalized, vote, clock())) {
7377
// The community already has this report from this device, so it
7478
// counts as done rather than as a failure.
7579
return ContributeResult(true, "Already submitted", ContributeOutcome.ALREADY_SUBMITTED)
7680
}
77-
val report = CommunityReport(newId(), normalized, type, indicators)
81+
val report = CommunityReport(newId(), normalized, reportType, indicators)
7882
note(report, Delivery.QUEUED)
7983
val queued =
8084
try {

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/details/NumberDetailScreen.kt‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ import com.sysadmindoc.callshield.data.SourceDescriptions
3838
import com.sysadmindoc.callshield.data.SpamRepository
3939
import com.sysadmindoc.callshield.data.areacodes.AreaCodeLookup
4040
import com.sysadmindoc.callshield.data.model.BlockedCall
41+
import com.sysadmindoc.callshield.data.model.SpamNumber
4142
import com.sysadmindoc.callshield.data.remote.ExternalLookup
4243
import com.sysadmindoc.callshield.data.remote.RemoteLookupStatus
44+
import com.sysadmindoc.callshield.domain.model.BlockReasonCode
4345
import com.sysadmindoc.callshield.domain.model.SpamCheckResult
4446
import com.sysadmindoc.callshield.service.NotificationHelper
4547
import com.sysadmindoc.callshield.ui.MainViewModel
@@ -106,7 +108,7 @@ fun NumberDetailScreen(
106108
val lastReport = remember(myReports, forms) { CommunityReportHistory.lastSpamReport(myReports, forms) }
107109
val reportNote =
108110
when {
109-
dbEntry?.let { NotificationHelper.isSharedDatabaseRow(it.source) } == true -> {
111+
isListedInSharedDatabase(dbEntry, System.currentTimeMillis()) -> {
110112
stringResource(R.string.detail_report_note_listed)
111113
}
112114

@@ -625,6 +627,10 @@ fun NumberDetailScreen(
625627
onClick = {
626628
hapticTick(context)
627629
viewModel.scheduleNotSpam(number)
630+
// Snackbars queue, and the not-spam report goes out five
631+
// seconds after this tap, so Undo can't wait behind a
632+
// report result.
633+
snackbarHostState.currentSnackbarData?.dismiss()
628634
coroutineScope.launch {
629635
val result =
630636
snackbarHostState.showSnackbar(
@@ -739,6 +745,21 @@ fun NumberDetailScreen(
739745
}
740746
}
741747

748+
/**
749+
* Whether [row] is a live row of the shared database, which a report only
750+
* adds to. The user's own blocks, subscribed lists and the unverified hot
751+
* list don't count, and neither does a row whose evidence has run out, since
752+
* screening no longer uses it and a report is what brings it back.
753+
*/
754+
internal fun isListedInSharedDatabase(
755+
row: SpamNumber?,
756+
now: Long,
757+
): Boolean =
758+
row != null &&
759+
NotificationHelper.isSharedDatabaseRow(row.source) &&
760+
row.source != BlockReasonCode.HOT_LIST.wireValue &&
761+
(row.evidenceExpiresAt == null || row.evidenceExpiresAt > now)
762+
742763
/**
743764
* The report row under Take action. Report sends the anonymous community
744765
* report. GitHub is the smaller choice under it, because it files a public

‎app/src/test/java/com/sysadmindoc/callshield/data/CommunityReportSubmitterTest.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ class CommunityReportSubmitterTest {
106106
assertEquals(listOf("+12122340101:spam"), sent.keys())
107107
}
108108

109+
@Test
110+
fun `a report without a category goes out as spam`() {
111+
// Number Detail passes the live check's type, empty for a number
112+
// nothing flagged. It reached the Worker as "unknown" and left a My
113+
// reports entry that couldn't be read back.
114+
val store = store()
115+
116+
assertEquals(ContributeOutcome.REPORTED_SPAM, store.submit("+12122340101", " ").outcome)
117+
assertEquals(listOf("+12122340101:spam"), sent.keys())
118+
assertEquals(listOf("+12122340101:spam"), queued.keys())
119+
}
120+
109121
@Test
110122
fun `a category is not a separate vote`() {
111123
// A notification's Block sends "spam" and Number Detail sends the row's

‎app/src/test/java/com/sysadmindoc/callshield/ui/screens/details/NumberReportActionsTest.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import androidx.compose.ui.test.assertIsDisplayed
66
import androidx.compose.ui.test.junit4.v2.createComposeRule
77
import androidx.compose.ui.test.onNodeWithText
88
import androidx.compose.ui.test.performClick
9+
import com.sysadmindoc.callshield.data.model.SpamNumber
910
import com.sysadmindoc.callshield.ui.theme.CallShieldTheme
1011
import org.junit.Assert.assertEquals
12+
import org.junit.Assert.assertFalse
13+
import org.junit.Assert.assertTrue
1114
import org.junit.Rule
1215
import org.junit.Test
1316
import org.junit.runner.RunWith
@@ -62,4 +65,17 @@ class NumberReportActionsTest {
6265
composeRule.onNodeWithText("This number is already in the CallShield database.").assertIsDisplayed()
6366
assertEquals(emptyList<String>(), taps)
6467
}
68+
69+
@Test
70+
fun `only a live shared database row counts as already listed`() {
71+
val now = 1_790_000_000_000L
72+
val row = SpamNumber(number = "+12122340101", type = "robocall", source = "ftc")
73+
74+
assertTrue(isListedInSharedDatabase(row, now))
75+
assertTrue(isListedInSharedDatabase(row.copy(evidenceExpiresAt = now + 1), now))
76+
assertFalse("no row", isListedInSharedDatabase(null, now))
77+
assertFalse("evidence ran out", isListedInSharedDatabase(row.copy(evidenceExpiresAt = now), now))
78+
assertFalse("unverified hot list", isListedInSharedDatabase(row.copy(source = "hot_list"), now))
79+
assertFalse("the user's own block", isListedInSharedDatabase(row.copy(source = "user"), now))
80+
}
6581
}

0 commit comments

Comments
 (0)