Skip to content

Commit 3677752

Browse files
committed
fix: rescan each text with the SIM it arrived on
The inbox scan passed no subscription, so a rescan read the default SIM's country for the carrier scam label while the live check read the receiving SIM's (fd13f8c). An "Unverified" text on an Australian second SIM was flagged when it arrived and cleared on a rescan. The scan now reads each row's sub_id; -1 or a missing value means none recorded.
1 parent 1b4bcb4 commit 3677752

3 files changed

Lines changed: 143 additions & 11 deletions

File tree

‎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%2C362-f38ba8?style=flat-square" alt="51,362 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1888-94e2d5?style=flat-square" alt="1888 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1889-94e2d5?style=flat-square" alt="1889 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">
@@ -530,12 +530,12 @@ RELEASE_KEY_PASSWORD=...
530530
## Testing
531531

532532
```bash
533-
./gradlew testDebugUnitTest # 1888 tests
533+
./gradlew testDebugUnitTest # 1889 tests
534534
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
535535
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
536536
```
537537

538-
The suite is **1888 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
538+
The suite is **1889 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
539539

540540
Two GitHub workflows run without building the app. **Validation** runs the Worker and
541541
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -572,7 +572,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
572572
| Community API | Cloudflare Workers |
573573
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
574574
| Verification | Local Gradle, lint, and release-artifact checks |
575-
| Tests | 1888 JVM unit tests (JUnit) |
575+
| Tests | 1889 JVM unit tests (JUnit) |
576576
| Strings | 1766 string resources and 38 plural groups (translation-ready) |
577577
| Accessibility | 100+ content descriptions, 48dp touch targets |
578578
| Min SDK | 29 (Android 10) |

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

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package com.sysadmindoc.callshield.service
22

33
import android.content.Context
44
import android.net.Uri
5+
import android.provider.Telephony
56
import com.sysadmindoc.callshield.R
67
import com.sysadmindoc.callshield.data.SpamRepository
78
import com.sysadmindoc.callshield.data.repository.SpamRepositoryAdapter
9+
import com.sysadmindoc.callshield.domain.model.SpamCheckResult
810
import com.sysadmindoc.callshield.domain.usecase.CheckSpamSmsUseCase
911
import com.sysadmindoc.callshield.permissions.CallShieldPermissions
1012
import kotlinx.coroutines.Dispatchers
@@ -32,6 +34,22 @@ object SmsInboxScanner {
3234
suspend fun scan(
3335
context: Context,
3436
limit: Int = 200,
37+
): ScanResult {
38+
// Built only once the permission check passes.
39+
val checkSpamSms by lazy { CheckSpamSmsUseCase(SpamRepositoryAdapter(SpamRepository.getInstance(context))) }
40+
// realtimeCall = false so the historical scan doesn't feed
41+
// CampaignDetector with old senders or pop caller-ID overlays for
42+
// messages that already arrived.
43+
return scan(context, limit) { address, body, subscriptionId ->
44+
checkSpamSms(address, body, realtimeCall = false, subscriptionId = subscriptionId)
45+
}
46+
}
47+
48+
/** [check] gets each text's sender, body and the SIM it arrived on, when the inbox recorded one. */
49+
internal suspend fun scan(
50+
context: Context,
51+
limit: Int,
52+
check: suspend (address: String, body: String, subscriptionId: Int?) -> SpamCheckResult,
3553
): ScanResult =
3654
withContext(Dispatchers.IO) {
3755
if (!CallShieldPermissions.canReadSmsInbox(context)) {
@@ -43,16 +61,14 @@ object SmsInboxScanner {
4361
)
4462
}
4563

46-
val repo = SpamRepository.getInstance(context)
47-
val checkSpamSms = CheckSpamSmsUseCase(SpamRepositoryAdapter(repo))
4864
val spamList = mutableListOf<ScannedSms>()
4965
var scanned = 0
5066

5167
try {
5268
val cursor =
5369
context.contentResolver.query(
5470
Uri.parse("content://sms/inbox"),
55-
arrayOf("address", "body", "date"),
71+
arrayOf("address", "body", "date", Telephony.Sms.SUBSCRIPTION_ID),
5672
null,
5773
null,
5874
"date DESC",
@@ -63,19 +79,21 @@ object SmsInboxScanner {
6379
val addrIdx = c.getColumnIndex("address")
6480
val bodyIdx = c.getColumnIndex("body")
6581
val dateIdx = c.getColumnIndex("date")
82+
val subscriptionIdx = c.getColumnIndex(Telephony.Sms.SUBSCRIPTION_ID)
6683
if (addrIdx < 0 || bodyIdx < 0) return@withContext ScanResult(0, 0, emptyList())
6784

6885
while (c.moveToNext() && scanned < limit) {
6986
val address = c.getString(addrIdx) ?: continue
7087
val body = c.getString(bodyIdx) ?: ""
7188
val date = if (dateIdx >= 0) c.getLong(dateIdx) else 0L
89+
// The carrier scam label is read for the SIM a text
90+
// arrived on, as for a live text; -1 means none recorded.
91+
val subscriptionId =
92+
if (subscriptionIdx >= 0 && !c.isNull(subscriptionIdx)) c.getInt(subscriptionIdx).takeIf { it >= 0 } else null
7293
scanned++
7394

7495
try {
75-
// realtimeCall = false so the historical scan doesn't
76-
// feed CampaignDetector with old senders or pop
77-
// caller-ID overlays for messages that already arrived.
78-
val result = checkSpamSms(address, body, realtimeCall = false)
96+
val result = check(address, body, subscriptionId)
7997
if (result.isSpam) {
8098
spamList.add(
8199
ScannedSms(
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.sysadmindoc.callshield.service
2+
3+
import android.Manifest
4+
import android.app.Application
5+
import android.content.ContentProvider
6+
import android.content.ContentValues
7+
import android.content.Context
8+
import android.database.Cursor
9+
import android.database.MatrixCursor
10+
import android.net.Uri
11+
import android.provider.Telephony
12+
import androidx.test.core.app.ApplicationProvider
13+
import com.sysadmindoc.callshield.domain.model.SpamCheckResult
14+
import kotlinx.coroutines.runBlocking
15+
import org.junit.Assert.assertEquals
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import org.robolectric.Robolectric
19+
import org.robolectric.RobolectricTestRunner
20+
import org.robolectric.Shadows.shadowOf
21+
import org.robolectric.annotation.Config
22+
23+
/** A rescan reads each text the way it was read when it arrived, SIM included. */
24+
@RunWith(RobolectricTestRunner::class)
25+
@Config(sdk = [34])
26+
class SmsInboxScannerTest {
27+
private val context: Context = ApplicationProvider.getApplicationContext()
28+
29+
@Test
30+
fun `each text is checked with the SIM it arrived on`() {
31+
shadowOf(context.applicationContext as Application).grantPermissions(Manifest.permission.READ_SMS)
32+
val inbox = Robolectric.setupContentProvider(FakeSmsInbox::class.java, "sms")
33+
inbox.rows =
34+
listOf(
35+
InboxRow("Unverified", "Your appointment is on Tuesday", date = 3L, subscriptionId = 2),
36+
InboxRow("+15550100001", "See you at 6", date = 2L, subscriptionId = 1),
37+
InboxRow("+15550100002", "Running late", date = 1L, subscriptionId = -1),
38+
InboxRow("+15550100003", "Lunch?", date = 0L, subscriptionId = null),
39+
)
40+
val asked = mutableListOf<Pair<String, Int?>>()
41+
42+
val result =
43+
runBlocking {
44+
SmsInboxScanner.scan(context, limit = 10) { address, _, subscriptionId ->
45+
asked += address to subscriptionId
46+
SpamCheckResult(isSpam = address == "Unverified", matchSource = "carrier_label")
47+
}
48+
}
49+
50+
assertEquals(
51+
listOf("Unverified" to 2, "+15550100001" to 1, "+15550100002" to null, "+15550100003" to null),
52+
asked,
53+
)
54+
assertEquals(listOf("Unverified"), result.spamMessages.map { it.number })
55+
}
56+
57+
data class InboxRow(
58+
val address: String,
59+
val body: String,
60+
val date: Long,
61+
val subscriptionId: Int?,
62+
)
63+
64+
class FakeSmsInbox : ContentProvider() {
65+
var rows: List<InboxRow> = emptyList()
66+
67+
override fun onCreate(): Boolean = true
68+
69+
override fun query(
70+
uri: Uri,
71+
projection: Array<out String>?,
72+
selection: String?,
73+
selectionArgs: Array<out String>?,
74+
sortOrder: String?,
75+
): Cursor {
76+
val columns = projection ?: emptyArray()
77+
val result = MatrixCursor(columns)
78+
for (row in rows.sortedByDescending { it.date }) {
79+
result.addRow(
80+
Array<Any?>(columns.size) { index ->
81+
when (columns[index]) {
82+
"address" -> row.address
83+
"body" -> row.body
84+
"date" -> row.date
85+
Telephony.Sms.SUBSCRIPTION_ID -> row.subscriptionId
86+
else -> null
87+
}
88+
},
89+
)
90+
}
91+
return result
92+
}
93+
94+
override fun getType(uri: Uri): String? = null
95+
96+
override fun insert(
97+
uri: Uri,
98+
values: ContentValues?,
99+
): Uri? = null
100+
101+
override fun delete(
102+
uri: Uri,
103+
selection: String?,
104+
selectionArgs: Array<out String>?,
105+
): Int = 0
106+
107+
override fun update(
108+
uri: Uri,
109+
values: ContentValues?,
110+
selection: String?,
111+
selectionArgs: Array<out String>?,
112+
): Int = 0
113+
}
114+
}

0 commit comments

Comments
 (0)