Skip to content

Commit 7bafcdd

Browse files
committed
fix(i18n): localize the remaining system text and open the Blocked log from the summary
The Quick Settings tile and notification-listener labels, the digest's source breakdown, the notes stored when a number is blocked or trusted from a notification, and every checker description in Checkers.kt were English literals. Checker descriptions are user-visible: Lookup's Details row and the block log show them. They now resolve through ctx.appContext, the way the caller-name and region checkers already did, and the digest keeps its stable bucket keys and maps each one to a translatable label. The copies that carried an em dash or a " - " lost it on the way. All 26 new strings and 3 plurals have zh-rCN text, so coverage rises from 76.1% to 76.5%. The zh-rCN digest_big_text used literal line breaks, which aapt2 collapses to spaces, so the Chinese digest ran together on one line. It uses \n now. The grouped "blocked recently" summary had no content intent. It now opens MainActivity with ACTION_OPEN_BLOCKED_LOG, which selects Activity and its Blocked tab once per request and closes an open number detail. Checked on the API 35 emulator: two blocked calls, then a tap on the collapsed group opened the Blocked log. Tests: new LocalizedSystemTextTest runs in zh-rCN, so an English literal can't pass for a resource; BlockedSummaryNotificationTest reads the summary's PendingIntent; launch routing and the digest labels are pinned. Each fails with its fix reverted. README's resource counts follow (1508 strings, 38 plurals).
1 parent c66e75f commit 7bafcdd

15 files changed

Lines changed: 408 additions & 39 deletions

File tree

‎CHANGELOG.md‎

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

156156
### Interface
157157

158+
- Tapping the "blocked recently" notification opens the Blocked log now. It
159+
used to do nothing at all.
160+
158161
- Setup now says what to do when Android won't let a permission through. Apps
159162
installed outside an app store run into Android's restricted settings. The
160163
grant screen refuses notification access from Android 13, and the overlay
@@ -423,6 +426,13 @@ The rest of this section takes effect when the report Worker is next deployed.
423426

424427
### Translations
425428

429+
- The last of the system text is translatable. That covers the Quick Settings
430+
tile and notification-access labels, the source breakdown in the daily
431+
summary, the note saved when you block or trust a number from a
432+
notification, and the explanation each detection layer gives in Lookup and
433+
the block log. Chinese has all of it. The Chinese daily summary also keeps
434+
its line breaks now, where before it ran together on one line.
435+
426436
- A shipped locale can no longer decay silently. Each has a coverage floor in
427437
`scripts/translation_floors.json`, and dropping below it fails the build.
428438
zh-rCN starts at 76.1%. A locale with no recorded floor warns instead, so

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
509509
| URL Safety | Local spam-domain data; optional URLhaus (abuse.ch) |
510510
| Verification | Local Gradle, lint, and release-artifact checks |
511511
| Tests | 1100 JVM unit tests (JUnit) |
512-
| Strings | 1482 string resources and 35 plural groups (translation-ready) |
512+
| Strings | 1508 string resources and 38 plural groups (translation-ready) |
513513
| Accessibility | 100+ content descriptions, 48dp touch targets |
514514
| Min SDK | 29 (Android 10) |
515515
| Target SDK | 36 |

‎app/src/main/AndroidManifest.xml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@
151151
<service
152152
android:name=".service.CallShieldTileService"
153153
android:icon="@drawable/ic_callshield_brand"
154-
android:label="CallShield"
154+
android:label="@string/app_name"
155155
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
156156
android:exported="true">
157157
<intent-filter>
@@ -179,7 +179,7 @@
179179

180180
<service
181181
android:name=".service.RcsNotificationListener"
182-
android:label="CallShield RCS Filter"
182+
android:label="@string/notification_listener_label"
183183
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
184184
android:exported="true">
185185
<intent-filter>

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

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ internal class EmergencyNumberFloorChecker : IChecker {
3939
return BlockResult.allow(
4040
matchSource = name,
4141
type = "safety_floor",
42-
description = "Emergency and public-safety numbers are never blocked",
42+
description = ctx.appContext.getString(R.string.block_reason_emergency_floor),
4343
)
4444
}
4545
}
@@ -57,7 +57,7 @@ internal class VerificationMessageFloorChecker(
5757
return BlockResult.allow(
5858
matchSource = name,
5959
type = "safety_floor",
60-
description = "Verification messages are exempt from blocking",
60+
description = ctx.appContext.getString(R.string.block_reason_verification_floor),
6161
)
6262
}
6363
}
@@ -136,7 +136,7 @@ internal class ContactsOnlyChecker(
136136

137137
override suspend fun check(ctx: CheckContext): BlockResult? =
138138
if (!spamHeuristics.isInContacts(appContext, ctx.number)) {
139-
BlockResult.block("contacts_only", description = "Blocked — contacts-only mode is active")
139+
BlockResult.block("contacts_only", description = appContext.getString(R.string.block_reason_contacts_only))
140140
} else {
141141
null
142142
}
@@ -313,7 +313,7 @@ internal class StirShakenChecker : IChecker {
313313
BlockResult.block(
314314
matchSource = "stir_shaken_failed",
315315
type = "spoofed",
316-
description = "Carrier could not verify caller identity",
316+
description = ctx.appContext.getString(R.string.block_reason_carrier_unverified),
317317
)
318318
} else {
319319
null
@@ -358,7 +358,7 @@ internal class SystemBlockListChecker(
358358
BlockResult.block(
359359
matchSource = "system_block_list",
360360
type = "user_blocked",
361-
description = "Blocked via system block list",
361+
description = appContext.getString(R.string.block_reason_system_block_list),
362362
)
363363
} else {
364364
null
@@ -383,7 +383,7 @@ internal class UserBlocklistChecker(
383383
if (entry.expiresAt == null) {
384384
entry.description
385385
} else {
386-
entry.description.ifBlank { "Temporarily blocked" }
386+
entry.description.ifBlank { ctx.appContext.getString(R.string.block_reason_temporary_block) }
387387
}
388388
BlockResult.block(source, entry.type, description, ruleId = entry.id)
389389
} else {
@@ -425,7 +425,7 @@ internal class DbPrefixExpansionChecker(
425425
if (!repo.hasDbPrefixMatch(ctx.number)) return null
426426
return BlockResult.block(
427427
"db_prefix_expansion",
428-
description = "Number shares prefix with a known spam entry",
428+
description = ctx.appContext.getString(R.string.block_reason_db_prefix),
429429
confidence = 50,
430430
)
431431
}
@@ -816,7 +816,7 @@ internal class TimeBlockChecker : IChecker {
816816

817817
val now = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
818818
return if (isInQuietWindow(now, start, end)) {
819-
BlockResult.block("time_block", "unknown", "Blocked during quiet hours")
819+
BlockResult.block("time_block", "unknown", ctx.appContext.getString(R.string.block_reason_quiet_hours))
820820
} else {
821821
null
822822
}
@@ -866,7 +866,11 @@ internal class FrequencyEscalationChecker(
866866
val freq = repo.getCallFrequencySinceInternal(ctx.number, System.currentTimeMillis() - windowMs)
867867
val threshold = (ctx.prefs[SpamRepository.KEY_FREQ_THRESHOLD] ?: 3).coerceAtLeast(2)
868868
return if (freq >= threshold) {
869-
BlockResult.block("frequency", "repeat_caller", "Called $freq times in 7 days - auto-blocked")
869+
BlockResult.block(
870+
"frequency",
871+
"repeat_caller",
872+
ctx.appContext.resources.getQuantityString(R.plurals.block_reason_frequency, freq, freq),
873+
)
870874
} else {
871875
null
872876
}
@@ -1008,20 +1012,39 @@ internal class CampaignBurstChecker(
10081012
campaignDetector.getCampaignEvidence(ctx.number)
10091013
} ?: return null
10101014
if (!evidence.isActive) return null
1015+
val resources = ctx.appContext.resources
10111016
val details =
10121017
buildList {
1013-
add("${evidence.distinctNumberCount} neighbor numbers in ${evidence.observationCount} calls")
1018+
add(
1019+
resources.getString(
1020+
R.string.block_reason_campaign_neighbors,
1021+
evidence.distinctNumberCount,
1022+
evidence.observationCount,
1023+
),
1024+
)
10141025
if (evidence.repeatedNumberCount > 0) {
1015-
add("${evidence.repeatedNumberCount} repeated number(s) suggest callback reuse")
1026+
add(
1027+
resources.getQuantityString(
1028+
R.plurals.block_reason_campaign_repeats,
1029+
evidence.repeatedNumberCount,
1030+
evidence.repeatedNumberCount,
1031+
),
1032+
)
10161033
}
10171034
if (evidence.sourceAgreementCount > 0) {
1018-
add("${evidence.sourceAgreementCount} source signal(s) agree")
1035+
add(
1036+
resources.getQuantityString(
1037+
R.plurals.block_reason_campaign_sources,
1038+
evidence.sourceAgreementCount,
1039+
evidence.sourceAgreementCount,
1040+
),
1041+
)
10191042
}
1020-
}.joinToString("; ")
1043+
}.joinToString(resources.getString(R.string.block_reason_detail_separator))
10211044
return BlockResult.block(
10221045
matchSource = "campaign_burst",
10231046
type = "robocall",
1024-
description = "Active campaign: $details",
1047+
description = resources.getString(R.string.block_reason_campaign, details),
10251048
confidence = 75,
10261049
)
10271050
}
@@ -1047,9 +1070,9 @@ internal class MlScorerChecker(
10471070
type = "robocall",
10481071
description =
10491072
listOfNotNull(
1050-
"ML model: ${verdict.confidence}% spam probability",
1073+
ctx.appContext.getString(R.string.block_reason_ml, verdict.confidence),
10511074
CallerIdentitySignals.describe(ctx.callerIdentity).takeIf { it.isNotBlank() },
1052-
).joinToString(". "),
1075+
).joinToString(ctx.appContext.getString(R.string.block_reason_sentence_separator)),
10531076
confidence = verdict.confidence,
10541077
)
10551078
} else {
@@ -1122,7 +1145,12 @@ internal class SmsKeywordChecker(
11221145
val now = java.util.Calendar.getInstance()
11231146
for (rule in rules) {
11241147
if (rule.matchesNow(body, now)) {
1125-
return BlockResult.block("keyword", "sms_spam", "Keyword: ${rule.keyword}", ruleId = rule.id)
1148+
return BlockResult.block(
1149+
"keyword",
1150+
"sms_spam",
1151+
ctx.appContext.getString(R.string.block_reason_keyword, rule.keyword),
1152+
ruleId = rule.id,
1153+
)
11261154
}
11271155
}
11281156
return null

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ class DigestWorker
5353
.eachCount()
5454
.entries
5555
.sortedByDescending { it.value }
56-
.joinToString(" · ") { "${it.value} ${it.key}" }
56+
.joinToString(applicationContext.getString(R.string.digest_source_separator)) {
57+
applicationContext.getString(bucketLabelRes(it.key), it.value)
58+
}
5759

5860
if (!CallShieldPermissions.hasNotificationPermission(applicationContext)) {
5961
// User has not granted POST_NOTIFICATIONS on API 33+; skip quietly.
@@ -123,6 +125,17 @@ class DigestWorker
123125
else -> "other"
124126
}
125127

128+
/** The digest's "count + source" label for a [matchReasonBucket] key. */
129+
internal fun bucketLabelRes(bucket: String): Int =
130+
when (bucket) {
131+
"database" -> R.string.digest_source_database
132+
"heuristic" -> R.string.digest_source_heuristic
133+
"ML" -> R.string.digest_source_ml
134+
"content" -> R.string.digest_source_content
135+
"RCS filter" -> R.string.digest_source_rcs_filter
136+
else -> R.string.digest_source_other
137+
}
138+
126139
fun schedule(context: Context) {
127140
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
128141
WORK_NAME,

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.sysadmindoc.callshield.data.PhoneFormatter
1919
import com.sysadmindoc.callshield.data.SmsContentAnalyzer
2020
import com.sysadmindoc.callshield.domain.model.BlockReasonCode
2121
import com.sysadmindoc.callshield.permissions.CallShieldPermissions
22+
import com.sysadmindoc.callshield.ui.ACTION_OPEN_BLOCKED_LOG
2223
import com.sysadmindoc.callshield.ui.MainActivity
2324
import com.sysadmindoc.callshield.util.filterAsciiDigits
2425
import com.sysadmindoc.callshield.util.filterAsciiDigitsLast
@@ -499,6 +500,7 @@ object NotificationHelper {
499500
.setGroup(GROUP_BLOCKED)
500501
.setGroupSummary(true)
501502
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
503+
.setContentIntent(blockedLogIntent(context))
502504
.setAutoCancel(true)
503505
// Zero the per-process counter when the user dismisses the group,
504506
// so the next block starts counting from that block instead of
@@ -507,6 +509,18 @@ object NotificationHelper {
507509
safeNotify(context, SUMMARY_ID, summary)
508510
}
509511

512+
/** The summary counts blocks, so tapping it opens the Blocked log that lists them. */
513+
private fun blockedLogIntent(context: Context): PendingIntent =
514+
PendingIntent.getActivity(
515+
context,
516+
SUMMARY_ID,
517+
Intent(context, MainActivity::class.java).apply {
518+
action = ACTION_OPEN_BLOCKED_LOG
519+
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
520+
},
521+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
522+
)
523+
510524
private fun summaryDeleteIntent(context: Context): PendingIntent =
511525
PendingIntent.getBroadcast(
512526
context,

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class SpamActionReceiver : BroadcastReceiver() {
3131
notificationManager.cancel(NotificationHelper.feedbackNotificationId(number))
3232
Toast.makeText(appContext, appContext.getString(R.string.feedback_blocked), Toast.LENGTH_SHORT).show()
3333
suspend {
34-
repo.blockNumber(number, "spam", "Blocked from after-call feedback")
34+
repo.blockNumber(number, "spam", appContext.getString(R.string.desc_blocked_from_after_call_feedback))
3535
CommunityContributor.contribute(appContext, repo.normalizeNumber(number), "spam")
3636
}
3737
}
@@ -41,7 +41,7 @@ class SpamActionReceiver : BroadcastReceiver() {
4141
notificationManager.cancel(NotificationHelper.feedbackNotificationId(number))
4242
Toast.makeText(appContext, appContext.getString(R.string.feedback_whitelisted), Toast.LENGTH_SHORT).show()
4343
suspend {
44-
repo.addToWhitelist(number, "Marked safe from after-call feedback")
44+
repo.addToWhitelist(number, appContext.getString(R.string.desc_marked_safe_from_after_call_feedback))
4545
CommunityContributor.reportNotSpam(appContext, repo.normalizeNumber(number))
4646
}
4747
}
@@ -55,7 +55,7 @@ class SpamActionReceiver : BroadcastReceiver() {
5555
notificationManager.cancel(notifId)
5656
}
5757
suspend {
58-
repo.blockNumber(number, reportType, "Blocked from notification")
58+
repo.blockNumber(number, reportType, appContext.getString(R.string.desc_blocked_from_notification))
5959
CommunityContributor.contribute(appContext, repo.normalizeNumber(number), reportType, smsIndicators)
6060
}
6161
}
@@ -88,7 +88,7 @@ class SpamActionReceiver : BroadcastReceiver() {
8888
notificationManager.cancel(notifId)
8989
}
9090
suspend {
91-
repo.addToWhitelist(number, "Reported as not spam from notification")
91+
repo.addToWhitelist(number, appContext.getString(R.string.desc_reported_not_spam_from_notification))
9292
CommunityContributor.reportNotSpam(appContext, repo.normalizeNumber(number))
9393
}
9494
}

‎app/src/main/java/com/sysadmindoc/callshield/ui/MainActivity.kt‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,16 +135,16 @@ fun CallShieldRoot(
135135
// from the request — computing it inside the effect raced the child's
136136
// LaunchedEffect(tabRequestId), which captured the pre-update startTab and
137137
// never re-ran, so the Lookup shortcut landed on Home.
138-
val initialTab =
139-
when (launchRequest.shortcutAction) {
140-
"com.sysadmindoc.callshield.LOOKUP" -> 2
141-
else -> 0
142-
}
138+
val initialTab = launchTab(launchRequest.shortcutAction)
143139
LaunchedEffect(launchRequest.id) {
144140
launchRequest.deepLinkNumber?.let { viewModel.openNumberDetail(it) }
145141
when (launchRequest.shortcutAction) {
146142
"com.sysadmindoc.callshield.SCAN" -> viewModel.scanCallLog()
143+
147144
"com.sysadmindoc.callshield.SCAN_SMS" -> viewModel.scanSmsInbox()
145+
146+
// An open number detail replaces the tab shell, which would hide the log.
147+
ACTION_OPEN_BLOCKED_LOG -> viewModel.closeNumberDetail()
148148
}
149149
}
150150

@@ -184,6 +184,7 @@ fun CallShieldRoot(
184184
viewModel = viewModel,
185185
startTab = initialTab,
186186
tabRequestId = launchRequest.id.takeIf { launchRequest.shortcutAction != null },
187+
blockedLogRequestId = launchRequest.blockedLogRequestId(),
187188
)
188189
}
189190
}
@@ -198,6 +199,7 @@ fun CallShieldApp(
198199
viewModel: MainViewModel,
199200
startTab: Int = 0,
200201
tabRequestId: Int? = null,
202+
blockedLogRequestId: Int? = null,
201203
) {
202204
var selectedTab by rememberSaveable { mutableIntStateOf(startTab) }
203205
var showSearch by rememberSaveable { mutableStateOf(false) }
@@ -357,7 +359,7 @@ fun CallShieldApp(
357359
}
358360

359361
1 -> {
360-
ActivityScreen(viewModel)
362+
ActivityScreen(viewModel, blockedLogRequestId = blockedLogRequestId)
361363
}
362364

363365
2 -> {
@@ -843,11 +845,26 @@ internal fun Intent?.toLaunchRequest(nextId: Int): LaunchRequest {
843845
)
844846
}
845847

848+
/** Opens Activity on its Blocked tab. The blocked-summary notification sends it. */
849+
internal const val ACTION_OPEN_BLOCKED_LOG = "com.sysadmindoc.callshield.BLOCKED_LOG"
850+
851+
/** The bottom-bar tab a launch opens on: Lookup, Activity for the blocked log, else Home. */
852+
internal fun launchTab(shortcutAction: String?): Int =
853+
when (shortcutAction) {
854+
"com.sysadmindoc.callshield.LOOKUP" -> 2
855+
ACTION_OPEN_BLOCKED_LOG -> 1
856+
else -> 0
857+
}
858+
859+
/** This request's id when it asks for the blocked log, so Activity can switch to it once. */
860+
internal fun LaunchRequest.blockedLogRequestId(): Int? = id.takeIf { shortcutAction == ACTION_OPEN_BLOCKED_LOG }
861+
846862
private val KNOWN_SHORTCUT_ACTIONS =
847863
setOf(
848864
"com.sysadmindoc.callshield.LOOKUP",
849865
"com.sysadmindoc.callshield.SCAN",
850866
"com.sysadmindoc.callshield.SCAN_SMS",
867+
ACTION_OPEN_BLOCKED_LOG,
851868
)
852869

853870
// Stable SaveableStateHolder keys for the two top-level branches. They must not

0 commit comments

Comments
 (0)