Skip to content

Commit ce364b2

Browse files
committed
fix(details): describe imported complaints in words, without repeats
Number details, Lookup and the block explanation showed the database's description as stored: "FCC: Unwanted Calls; FCC callback_business: Unwanted Calls (Live Voice); FCC caller_id: Unwanted Calls; FTC caller ID: Computer & technical support" for +13152328257. SourceDescriptions turns the importers' segments into one line per kind of complaint (FCC about the caller ID, FCC giving it as the number to call back, FCC before the role split, FTC about the caller ID), drops the pre-split "FCC:" subject when a role already names it, collapses stray spaces and passes other sources through once. Formatting twice changes nothing. The database checker's verdict carries the readable text, Number details formats the stored row, and the explanation lists the lines as bullets instead of "Type on file: <raw text>" (reasoning_type_on_file goes). The stored description and report count don't change.
1 parent fb84d33 commit ce364b2

9 files changed

Lines changed: 150 additions & 6 deletions

File tree

‎CHANGELOG.md‎

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

55
## Unreleased
66

7+
- Number details, Lookup and the block explanation describe FCC and FTC
8+
complaints in words, one line per kind, instead of the importer's field names
9+
("FCC callback_business") and the same complaint repeated.
710
- The database is also published as data/spam_numbers.txt, one number per
811
line under a short header, signed like the app's feeds, for projects that
912
only want the list rather than the 51 MB JSON.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
553553
| URL Safety | Local spam-domain data; optional PhishTank and OpenPhish |
554554
| Verification | Local Gradle, lint, and release-artifact checks |
555555
| Tests | 1594 JVM unit tests (JUnit) |
556-
| Strings | 1665 string resources and 38 plural groups (translation-ready) |
556+
| Strings | 1668 string resources and 38 plural groups (translation-ready) |
557557
| Accessibility | 100+ content descriptions, 48dp touch targets |
558558
| Min SDK | 29 (Android 10) |
559559
| Target SDK | 36 |

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,11 @@ object BlockReasoning {
300300
): Reasoning? =
301301
when (reasonCode) {
302302
BlockReasonCode.DATABASE -> {
303-
reasoning(s(R.string.reasoning_database_headline), description.labeled(R.string.reasoning_type_on_file))
303+
// The description is the database's own text, one line per kind of complaint.
304+
Reasoning(
305+
headline = s(R.string.reasoning_database_headline),
306+
bullets = SourceDescriptions.readable(context, description).lines().filter { it.isNotBlank() },
307+
)
304308
}
305309

306310
BlockReasonCode.DB_PREFIX_EXPANSION -> {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.sysadmindoc.callshield.data
2+
3+
import android.content.Context
4+
import androidx.annotation.StringRes
5+
import com.sysadmindoc.callshield.R
6+
7+
/**
8+
* A spam database description in words. The importers write their own field
9+
* names into it ("FCC callback_business: Unwanted Calls"), and a row that
10+
* several sources or complaint roles reported repeats itself, so Number details
11+
* and Lookup showed raw keys and the same complaint twice. The stored text and
12+
* its report count don't change; this is only how it reads, one line per kind
13+
* of complaint, and running it twice changes nothing.
14+
*/
15+
internal object SourceDescriptions {
16+
private val complaint = Regex("""^(FCC|FTC)(?:\s+(caller_id|callback_business|caller ID))?:\s*(.+)$""")
17+
private val separator = Regex("""\s*[;\n]\s*""")
18+
private val whitespace = Regex("""\s+""")
19+
20+
private enum class Kind(
21+
@param:StringRes val format: Int,
22+
) {
23+
FCC_CALLER_ID(R.string.source_fcc_caller_id),
24+
FCC_CALLBACK(R.string.source_fcc_callback),
25+
FCC_UNSPECIFIED(R.string.source_fcc_unspecified),
26+
FTC_CALLER_ID(R.string.source_ftc_caller_id),
27+
}
28+
29+
fun readable(
30+
context: Context,
31+
raw: String,
32+
): String {
33+
val subjects = linkedMapOf<Kind, LinkedHashSet<String>>()
34+
val other = LinkedHashSet<String>()
35+
for (segment in raw.split(separator)) {
36+
val text = segment.replace(whitespace, " ").trim()
37+
if (text.isEmpty()) continue
38+
val match = complaint.matchEntire(text)
39+
if (match == null) {
40+
other += text
41+
continue
42+
}
43+
val (source, role, subject) = match.destructured
44+
val kind =
45+
when {
46+
source == "FTC" -> Kind.FTC_CALLER_ID
47+
role == "callback_business" -> Kind.FCC_CALLBACK
48+
role == "caller_id" -> Kind.FCC_CALLER_ID
49+
else -> Kind.FCC_UNSPECIFIED
50+
}
51+
subjects.getOrPut(kind) { LinkedHashSet() } += subject
52+
}
53+
// "FCC: X" predates the importer splitting complaints by role, and it
54+
// repeats a subject one of the roles already names.
55+
val named = subjects[Kind.FCC_CALLER_ID].orEmpty() + subjects[Kind.FCC_CALLBACK].orEmpty()
56+
subjects[Kind.FCC_UNSPECIFIED]?.removeAll(named)
57+
val lines =
58+
Kind.entries.mapNotNull { kind ->
59+
subjects[kind]?.takeIf { it.isNotEmpty() }?.let { context.getString(kind.format, it.joinToString(", ")) }
60+
}
61+
return (lines + other).joinToString("\n")
62+
}
63+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.sysadmindoc.callshield.data.RegionRules
1515
import com.sysadmindoc.callshield.data.RegulatoryPrefix
1616
import com.sysadmindoc.callshield.data.SmsContentAnalyzer
1717
import com.sysadmindoc.callshield.data.SmsContextChecker
18+
import com.sysadmindoc.callshield.data.SourceDescriptions
1819
import com.sysadmindoc.callshield.data.SourceEvidenceCodec
1920
import com.sysadmindoc.callshield.data.SpamHeuristics
2021
import com.sysadmindoc.callshield.data.SpamMLScorer
@@ -416,7 +417,7 @@ internal class DatabaseChecker(
416417
override suspend fun check(ctx: CheckContext): BlockResult? {
417418
val entry = ctx.lookupForms.firstNotNullOfOrNull { form -> repo.findByNumberInternal(form)?.takeUnless { it.isUserBlocked } }
418419
return if (entry != null) {
419-
BlockResult.block("database", entry.type, entry.description)
420+
BlockResult.block("database", entry.type, SourceDescriptions.readable(ctx.appContext, entry.description))
420421
} else {
421422
null
422423
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.sysadmindoc.callshield.R
3333
import com.sysadmindoc.callshield.data.BlockReasoning
3434
import com.sysadmindoc.callshield.data.PhoneFormatter
3535
import com.sysadmindoc.callshield.data.SmsBodyRedactor
36+
import com.sysadmindoc.callshield.data.SourceDescriptions
3637
import com.sysadmindoc.callshield.data.SpamRepository
3738
import com.sysadmindoc.callshield.data.areacodes.AreaCodeLookup
3839
import com.sysadmindoc.callshield.data.model.BlockedCall
@@ -388,7 +389,11 @@ fun NumberDetailScreen(
388389
}
389390
if (databaseEntry.description.isNotEmpty()) {
390391
Spacer(Modifier.height(8.dp))
391-
Text(databaseEntry.description, color = CatSubtext, style = MaterialTheme.typography.bodyMedium)
392+
Text(
393+
SourceDescriptions.readable(context, databaseEntry.description),
394+
color = CatSubtext,
395+
style = MaterialTheme.typography.bodyMedium,
396+
)
392397
}
393398
}
394399
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1310,7 +1310,10 @@
13101310
<string name="reasoning_hidden_number_headline">来电未附带电话号码。</string>
13111311
<string name="reasoning_hidden_number_detail">已按你的“拦截未知号码”设置拦截。</string>
13121312
<string name="reasoning_database_headline">此号码在 CallShield 垃圾号码库中。</string>
1313-
<string name="reasoning_type_on_file">记录类型:%1$s</string>
1313+
<string name="source_fcc_caller_id">FCC 投诉(针对来电号码):%1$s</string>
1314+
<string name="source_fcc_callback">FCC 投诉(作为回拨号码):%1$s</string>
1315+
<string name="source_fcc_unspecified">FCC 投诉:%1$s</string>
1316+
<string name="source_ftc_caller_id">FTC 投诉(针对来电号码):%1$s</string>
13141317
<string name="reasoning_db_prefix_headline">此号码与已确认的垃圾号码相邻。</string>
13151318
<string name="reasoning_db_prefix_detail">除最后两位外,它与垃圾号码库中的号码完全相同,且号码库前缀扩展已开启。</string>
13161319
<string name="reasoning_prefix_tag">前缀标记:%1$s</string>

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1809,7 +1809,10 @@
18091809
<string name="reasoning_hidden_number_headline">Call came in with no phone number attached.</string>
18101810
<string name="reasoning_hidden_number_detail">Blocked by your \"block unknown numbers\" setting.</string>
18111811
<string name="reasoning_database_headline">This number is in CallShield\'s spam database.</string>
1812-
<string name="reasoning_type_on_file">Type on file: %1$s</string>
1812+
<string name="source_fcc_caller_id">FCC complaints about the caller ID: %1$s</string>
1813+
<string name="source_fcc_callback">FCC complaints giving it as the number to call back: %1$s</string>
1814+
<string name="source_fcc_unspecified">FCC complaints: %1$s</string>
1815+
<string name="source_ftc_caller_id">FTC complaints about the caller ID: %1$s</string>
18131816
<string name="reasoning_db_prefix_headline">This number sits next to confirmed spam numbers.</string>
18141817
<string name="reasoning_db_prefix_detail">It shares all but its last two digits with numbers in the spam database, and database prefix expansion is on.</string>
18151818
<string name="reasoning_prefix_tag">Prefix tag: %1$s</string>
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.sysadmindoc.callshield.data
2+
3+
import android.content.Context
4+
import androidx.test.core.app.ApplicationProvider
5+
import com.sysadmindoc.callshield.domain.model.BlockReasonCode
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Assert.assertFalse
8+
import org.junit.Test
9+
import org.junit.runner.RunWith
10+
import org.robolectric.RobolectricTestRunner
11+
import org.robolectric.annotation.Config
12+
13+
/**
14+
* Number details showed the importers' raw field names and the same complaint
15+
* twice for bundled number +13152328257.
16+
*/
17+
@RunWith(RobolectricTestRunner::class)
18+
@Config(sdk = [34])
19+
class SourceDescriptionsTest {
20+
private val context: Context = ApplicationProvider.getApplicationContext()
21+
22+
// The stored description of +13152328257 in database v49.
23+
private val stored =
24+
"FCC: Unwanted Calls; FCC callback_business: Unwanted Calls (Live Voice); " +
25+
"FCC caller_id: Unwanted Calls; FTC caller ID: Computer & technical support"
26+
27+
@Test
28+
fun `complaints read as words, one line per kind, without repeats`() {
29+
assertEquals(
30+
listOf(
31+
"FCC complaints about the caller ID: Unwanted Calls",
32+
"FCC complaints giving it as the number to call back: Unwanted Calls (Live Voice)",
33+
"FTC complaints about the caller ID: Computer & technical support",
34+
),
35+
SourceDescriptions.readable(context, stored).lines(),
36+
)
37+
}
38+
39+
@Test
40+
fun `no raw field name survives, and formatting twice changes nothing`() {
41+
val once = SourceDescriptions.readable(context, stored)
42+
43+
assertFalse(once, once.contains("caller_id") || once.contains("callback_business"))
44+
assertEquals(once, SourceDescriptions.readable(context, once))
45+
}
46+
47+
@Test
48+
fun `other sources pass through once each`() {
49+
assertEquals(
50+
listOf("FCC complaints: Robocalls", "Community reported", "Saracroche: Opérateur"),
51+
SourceDescriptions.readable(context, "FCC: Robocalls; Community reported; Saracroche: Opérateur; Community reported").lines(),
52+
)
53+
assertEquals("", SourceDescriptions.readable(context, ""))
54+
}
55+
56+
@Test
57+
fun `the block explanation lists the complaints instead of the raw text`() {
58+
val reasoning = BlockReasoning.explain(context, reasonCode = BlockReasonCode.DATABASE, description = stored, confidence = 100)
59+
60+
assertEquals(SourceDescriptions.readable(context, stored).lines(), reasoning.bullets)
61+
}
62+
}

0 commit comments

Comments
 (0)