Skip to content

Commit d92b278

Browse files
committed
fix: close the gaps a review of the release notice, Settings and Undo found
Release notice (8754ccb) - Sync database now refreshes the notice as the six-hour worker does; a manual sync is a sync. - Dismissing one release hides any older one a stale mirror serves later (versionCode > dismissed, not !=). The stored notice still follows the signed file, so a maintainer can take one back. - R8: AppReleaseJson names its fields on constructor parameters, which the @JSON field rule doesn't match, and its adapter is built when GitHubDataSource loads. It and the latest-release payloads get explicit keep rules. - README's release section gains the write_app_release.py step. Settings (792d815) - The acceptance names Basic's cards: Blocking, Safety, Notifications and Appearance. When a call is blocked and Quiet hours move to the head of Advanced. SettingsRenderCoverageTest's placement test encoded the earlier placement on Basic, which was my misreading of that criterion, so it now checks Basic's four cards and the card's place on Advanced; the eight moved keys change tab in its table. - New test opens the region and caller name sheet and checks a field for each of its four keys; the row label alone couldn't catch a lost one. Undo (e120f25) - A Replace's Undo expires in the view model after 30 s. It lived as long as the view model, so leaving Settings and coming back hours later offered an old snapshot that would wipe everything since. - The Replace snapshot skips the import sanitizer, which dropped hidden callers' log rows and short numbers the user blocked. - The popup's report timer starts with the Undo strip, not before the main thread gets to show it. - Repeat taps keep the first Undo: a second area code block of an already-blocking rule writes nothing and leaves the first snackbar; a second Clear log with nothing left leaves it too; a second temporary decision for the same number undoes to the state before the first. - The two area code buttons share blockAreaCodeWithUndo.
1 parent 5605356 commit d92b278

20 files changed

Lines changed: 295 additions & 90 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,26 @@ All notable changes to CallShield will be documented in this file.
3737
366 numbers wait for more reporters.
3838
- Home shows a card when a newer CallShield release is out, with a link to its
3939
release page. The notice is a signed file that syncs with the spam database
40-
every six hours, so it needs no extra server and nothing unsigned can fake
41-
it. Hide it and it stays hidden until the next release. The optional weekly
42-
update check in Settings works as before.
43-
- Settings has a When a call is blocked card on Basic. Silent voicemail,
40+
every six hours and when you tap Sync database, so it needs no extra server
41+
and nothing unsigned can fake it. Hide it and it stays hidden until the next
42+
release, and an older copy served late can't bring back a card you hid. The
43+
optional weekly update check in Settings works as before.
44+
- Settings opens on Blocking, Safety, Notifications and Appearance. Advanced
45+
now starts with a When a call is blocked card, where silent voicemail,
4446
muting low-confidence blocks, answer and hang up, and call handling by
45-
category sit together there. Most of them were among the detection engines
46-
on Advanced, where they read like more ways to catch spam. Appearance moved
47-
to the end of Basic.
47+
category sit together, then Quiet hours. Most of those were among the
48+
detection engines, where they read like more ways to catch spam.
4849
- Clearing the blocked log and blocking a whole area code happen straight
4950
away now, with Undo on the message that follows, instead of asking first.
5051
Undo puts back every log entry, or the area code rule you had before.
5152
Temporary allows and blocks from the log and Recent calls offer Undo too.
5253
Block on the caller ID popup shows Blocked with Undo for six seconds, and
5354
the community report waits until that's over, so an undone block is never
5455
reported. Restore with Replace keeps a copy of the sections it's about to
55-
clear, and Undo next to the result puts them back for 30 seconds.
56+
clear, and Undo next to the result puts them back for 30 seconds, hidden
57+
callers' log entries and short codes included. A second tap on Clear log
58+
or Block area code, or a second temporary decision for the same number,
59+
keeps the first Undo working.
5660
- Text rules see through disguised spam. Fullwidth and math-style letters,
5761
zero-width and other invisible characters, and Cyrillic or Greek letters
5862
that look Latin are undone before the SMS content checks and your keyword

‎README.md‎

Lines changed: 11 additions & 5 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%2C363-f38ba8?style=flat-square" alt="51,363 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1688-94e2d5?style=flat-square" alt="1688 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1693-94e2d5?style=flat-square" alt="1693 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">
@@ -480,6 +480,12 @@ Generate the release hash sidecar with:
480480
.\scripts\write-release-sha256.ps1
481481
```
482482

483+
After `gh release create` has published the tag, write and sign the release notice that older builds show on Home, then commit `data/app_release.json` and its `.sig`:
484+
485+
```powershell
486+
python scripts/write_app_release.py
487+
```
488+
483489
`verifyReleaseSbom` also writes `<release-apk-stem>.cdx.json`,
484490
`<release-apk-stem>.provenance.json`, and `<release-apk-stem>.sha256` beside the
485491
APK. The SBOM contains the exact `releaseRuntimeClasspath` coordinates from
@@ -505,12 +511,12 @@ RELEASE_KEY_PASSWORD=...
505511
## Testing
506512

507513
```bash
508-
./gradlew testDebugUnitTest # 1688 tests
514+
./gradlew testDebugUnitTest # 1693 tests
509515
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
510516
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
511517
```
512518

513-
The suite is **1688 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
519+
The suite is **1693 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
514520

515521
Two GitHub workflows run without building the app. **Validation** runs the Worker and
516522
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -547,8 +553,8 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
547553
| Community API | Cloudflare Workers |
548554
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
549555
| Verification | Local Gradle, lint, and release-artifact checks |
550-
| Tests | 1688 JVM unit tests (JUnit) |
551-
| Strings | 1716 string resources and 37 plural groups (translation-ready) |
556+
| Tests | 1693 JVM unit tests (JUnit) |
557+
| Strings | 1717 string resources and 37 plural groups (translation-ready) |
552558
| Accessibility | 100+ content descriptions, 48dp touch targets |
553559
| Min SDK | 29 (Android 10) |
554560
| Target SDK | 36 |

‎app/proguard-rules.pro‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
-keep class com.sysadmindoc.callshield.data.remote.GitHubDataSource$HotRangesPayload { *; }
4040
-keep class com.sysadmindoc.callshield.data.remote.GitHubDataSource$HotRangeEntry { *; }
4141
-keep class com.sysadmindoc.callshield.data.remote.GitHubDataSource$SpamDomainsPayload { *; }
42+
# app_release.json and the opt-in latest-release check. AppReleaseJson's names
43+
# are on constructor parameters (@param:Json), which the field rule above
44+
# doesn't match, and its adapter is built when GitHubDataSource loads.
45+
-keep class com.sysadmindoc.callshield.data.remote.AppReleaseJson { *; }
46+
-keep class com.sysadmindoc.callshield.data.remote.GitHubDataSource$GitHubReleasePayload { *; }
47+
-keep class com.sysadmindoc.callshield.data.remote.GitHubDataSource$GitHubReleaseAsset { *; }
4248

4349
# Preserve Kotlin metadata + generic signatures so KotlinJsonAdapterFactory can
4450
# introspect data classes after R8 optimization.

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ object BackupRestore {
624624
// Uncapped: the snapshot never leaves the device, and a trimmed
625625
// log would lose rows on Undo.
626626
buildBackup(dao, repo, selectedSections, rowCap = Int.MAX_VALUE)
627-
.toRestorePayload(selectedSections)
627+
.toUndoPayload(selectedSections)
628628
} else {
629629
null
630630
}
@@ -985,6 +985,22 @@ object BackupRestore {
985985
sections: Set<BackupSection>,
986986
): RestorePayload = toRestorePayload(sections, System.currentTimeMillis())
987987

988+
/**
989+
* This device's own rows, as they are. The import checks would drop a
990+
* hidden caller's log entries and short numbers the user blocked, and an
991+
* Undo has to put those back too.
992+
*/
993+
private fun Backup.toUndoPayload(sections: Set<BackupSection>): RestorePayload =
994+
RestorePayload(
995+
blockedNumbers = if (BackupSection.BLOCKED_NUMBERS in sections) blockedNumbers else emptyList(),
996+
whitelistNumbers = if (BackupSection.WHITELIST in sections) whitelistNumbers else emptyList(),
997+
wildcardRules = if (BackupSection.WILDCARD_RULES in sections) wildcardRules else emptyList(),
998+
keywordRules = if (BackupSection.KEYWORD_RULES in sections) keywordRules else emptyList(),
999+
rangeRules = if (BackupSection.RANGE_RULES in sections) rangeRules else emptyList(),
1000+
settings = if (BackupSection.SETTINGS in sections) settings else null,
1001+
logs = if (BackupSection.LOGS in sections) logs else emptyList(),
1002+
)
1003+
9881004
private fun Backup.toRestorePayload(
9891005
sections: Set<BackupSection>,
9901006
nowMillis: Long,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ data class AppReleaseNotice(
1616
fun shouldShow(
1717
installedCode: Int,
1818
dismissedCode: Int,
19-
): Boolean = versionCode > installedCode && versionCode != dismissedCode
19+
): Boolean = versionCode > installedCode && versionCode > dismissedCode
2020
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ class BlocklistRepository(
334334
data class WildcardUndo(
335335
val pattern: String,
336336
val previous: WildcardRule?,
337+
/** False when an always-on rule for the pattern was already there; nothing was written. */
338+
val changed: Boolean = true,
337339
)
338340

339341
/** Add a rule the way [addWildcardRule] does and return what it replaced. */
@@ -346,8 +348,12 @@ class BlocklistRepository(
346348
if (trimmedPattern.isBlank()) return null
347349
var undo: WildcardUndo? = null
348350
runInTransaction {
349-
undo = WildcardUndo(trimmedPattern, dao.findWildcardRule(trimmedPattern))
350-
addWildcardRule(trimmedPattern, isRegex, description)
351+
val existing = dao.findWildcardRule(trimmedPattern)
352+
// A repeat tap keeps the first rule, so the first Undo still
353+
// removes it instead of putting back a copy of itself.
354+
val alreadyBlocks = existing != null && existing.enabled && existing.isRegex == isRegex && existing.scheduleDays == 0
355+
undo = WildcardUndo(trimmedPattern, existing, changed = !alreadyBlocks)
356+
if (!alreadyBlocks) addWildcardRule(trimmedPattern, isRegex, description)
351357
}
352358
return undo
353359
}

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,8 +1066,7 @@ class CallerIdOverlayService : Service() {
10661066
send()
10671067
handler.post { dismiss(sessionId) }
10681068
} else {
1069-
val report = DeferredReport(CallShieldApp.appScope, wait = { delay(REPORT_DELAY_MS) }, send = send)
1070-
handler.post { showBlockedStrip(sessionId, undo, report) }
1069+
handler.post { showBlockedStrip(sessionId, undo, send) }
10711070
}
10721071
} catch (e: Exception) {
10731072
android.util.Log.w("CallerIdOverlay", "Overlay block failed", e)
@@ -1132,9 +1131,16 @@ class CallerIdOverlayService : Service() {
11321131
private fun showBlockedStrip(
11331132
sessionId: Long,
11341133
undo: BlocklistRepository.BlockUndo,
1135-
report: DeferredReport,
1134+
send: suspend () -> Unit,
11361135
) {
1137-
if (!isCurrentSession(sessionId)) return
1136+
if (!isCurrentSession(sessionId)) {
1137+
// The popup closed before the block landed, so nothing can undo it.
1138+
CallShieldApp.appScope.launch { send() }
1139+
return
1140+
}
1141+
// Started with the strip, so the report always waits out the window
1142+
// however late this runs on the main thread.
1143+
val report = DeferredReport(CallShieldApp.appScope, wait = { delay(REPORT_DELAY_MS) }, send = send)
11381144
actionRow?.visibility = android.view.View.GONE
11391145
sitToneButton?.visibility = android.view.View.GONE
11401146
blockedStrip?.visibility = android.view.View.VISIBLE

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class MainViewModel
102102
) : ViewModel() {
103103
private companion object {
104104
const val TAG = "MainViewModel"
105+
106+
/** How long a Replace restore can be undone. */
107+
const val RESTORE_UNDO_WINDOW_MS = 30_000L
105108
const val KEY_DATABASE_TYPE = "database_type_filter"
106109
const val KEY_DATABASE_SOURCE = "database_source_filter"
107110
const val DATABASE_PAGE_SIZE = 50
@@ -602,6 +605,7 @@ class MainViewModel
602605
val restoreResult: StateFlow<StatusMessage?> = _restoreResult
603606

604607
private val _restoreUndo = MutableStateFlow<BackupRestore.RestoreUndo?>(null)
608+
private var restoreUndoExpiry: Job? = null
605609

606610
/** What the last Replace overwrote, while its result still offers to put it back. */
607611
val restoreUndo: StateFlow<BackupRestore.RestoreUndo?> = _restoreUndo
@@ -629,7 +633,24 @@ class MainViewModel
629633

630634
fun clearRestoreResult() {
631635
_restoreResult.value = null
632-
_restoreUndo.value = null
636+
setRestoreUndo(null)
637+
}
638+
639+
/**
640+
* Offers [undo] for [RESTORE_UNDO_WINDOW_MS] and no longer. The window
641+
* runs here, not on the screen, so leaving Settings can't keep an old
642+
* snapshot waiting to wipe everything added since.
643+
*/
644+
private fun setRestoreUndo(undo: BackupRestore.RestoreUndo?) {
645+
restoreUndoExpiry?.cancel()
646+
_restoreUndo.value = undo
647+
restoreUndoExpiry =
648+
undo?.let {
649+
viewModelScope.launch {
650+
delay(RESTORE_UNDO_WINDOW_MS)
651+
_restoreUndo.value = null
652+
}
653+
}
633654
}
634655

635656
fun clearRestorePreview() {
@@ -699,6 +720,17 @@ class MainViewModel
699720
if (showProgress) NotificationHelper.showSyncProgress(appContext)
700721
try {
701722
val result = syncDatabase(force = true)
723+
// Sync now also picks up a new release notice, as the
724+
// six-hour worker does.
725+
if (result.success) {
726+
try {
727+
repo.refreshAppReleaseNotice()
728+
} catch (e: CancellationException) {
729+
throw e
730+
} catch (e: Exception) {
731+
Log.w(TAG, "Release notice refresh failed", e)
732+
}
733+
}
702734
_syncState.value =
703735
if (result.success) {
704736
if (result.warning) {
@@ -1200,7 +1232,7 @@ class MainViewModel
12001232
) {
12011233
val ownedPassphrase = passphrase?.copyOf()
12021234
passphrase?.fill('\u0000')
1203-
_restoreUndo.value = null
1235+
setRestoreUndo(null)
12041236
viewModelScope.launch {
12051237
try {
12061238
val result = BackupRestore.previewRestoreFromUri(appContext, uri, sections, ownedPassphrase)
@@ -1226,7 +1258,7 @@ class MainViewModel
12261258
}
12271259
viewModelScope.launch {
12281260
val result = BackupRestore.restoreFromPreview(appContext, preview, mode)
1229-
_restoreUndo.value = result.undo
1261+
setRestoreUndo(result.undo)
12301262
_restoreResult.value = StatusMessage(result.message, result.success)
12311263
if (result.success) {
12321264
_restorePreview.value = null
@@ -1236,7 +1268,7 @@ class MainViewModel
12361268

12371269
fun undoRestore() {
12381270
val undo = _restoreUndo.value ?: return
1239-
_restoreUndo.value = null
1271+
setRestoreUndo(null)
12401272
viewModelScope.launch {
12411273
val result = BackupRestore.undoRestore(appContext, undo)
12421274
_restoreResult.value =

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.material3.MaterialTheme
1010
import androidx.compose.material3.SnackbarDuration
1111
import androidx.compose.material3.SnackbarHostState
1212
import androidx.compose.material3.SnackbarResult
13+
import androidx.compose.material3.SnackbarVisuals
1314
import androidx.compose.runtime.Composable
1415
import androidx.compose.runtime.getValue
1516
import androidx.compose.runtime.mutableStateOf
@@ -21,6 +22,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
2122
import androidx.compose.ui.res.stringResource
2223
import com.sysadmindoc.callshield.R
2324
import com.sysadmindoc.callshield.data.PhoneFormatter
25+
import com.sysadmindoc.callshield.data.repository.BlocklistRepository
2426
import com.sysadmindoc.callshield.ui.theme.PremiumCompactButton
2527

2628
data class TemporaryDecisionDuration(
@@ -52,13 +54,64 @@ suspend fun applyTemporaryDecision(
5254
allow -> resources.getString(R.string.temporary_decision_allowed, shown, duration.label)
5355
else -> resources.getString(R.string.temporary_decision_blocked, shown, duration.label)
5456
}
57+
// A repeat on the same number keeps the state from before the first
58+
// one, which the snackbar being replaced was holding.
59+
val earlier = (snackbar.currentSnackbarData?.visuals as? DecisionUndoVisuals)?.snapshot
5560
snackbar.currentSnackbarData?.dismiss()
5661
if (snapshot == null) {
5762
snackbar.showSnackbar(message)
5863
return
5964
}
60-
val choice = snackbar.showSnackbar(message, actionLabel = resources.getString(R.string.blocked_log_undo), duration = SnackbarDuration.Long)
61-
if (choice == SnackbarResult.ActionPerformed) viewModel.undoDecision(snapshot)
65+
val undoTo = earlier?.takeIf { it.number == snapshot.number } ?: snapshot
66+
val choice = snackbar.showSnackbar(DecisionUndoVisuals(message, resources.getString(R.string.blocked_log_undo), undoTo))
67+
if (choice == SnackbarResult.ActionPerformed) viewModel.undoDecision(undoTo)
68+
}
69+
70+
/** A temporary decision's snackbar, carrying what its Undo puts back. */
71+
private class DecisionUndoVisuals(
72+
override val message: String,
73+
override val actionLabel: String,
74+
val snapshot: BlocklistRepository.DecisionSnapshot,
75+
) : SnackbarVisuals {
76+
override val withDismissAction = false
77+
override val duration = SnackbarDuration.Long
78+
}
79+
80+
/**
81+
* Block a whole area code at once and offer Undo. A repeat tap while the
82+
* area code is already blocked leaves the first snackbar, and its Undo, alone.
83+
*/
84+
suspend fun blockAreaCodeWithUndo(
85+
viewModel: MainViewModel,
86+
snackbar: SnackbarHostState,
87+
resources: Resources,
88+
areaCode: String,
89+
description: String,
90+
) {
91+
val undo = viewModel.blockAreaCodeUndoable(areaCode, description).getOrNull()
92+
when {
93+
undo == null -> {
94+
snackbar.currentSnackbarData?.dismiss()
95+
snackbar.showSnackbar(resources.getString(R.string.dashboard_block_area_failed, areaCode))
96+
}
97+
98+
!undo.changed -> {
99+
if (snackbar.currentSnackbarData == null) {
100+
snackbar.showSnackbar(resources.getString(R.string.dashboard_block_area_already, areaCode))
101+
}
102+
}
103+
104+
else -> {
105+
snackbar.currentSnackbarData?.dismiss()
106+
val choice =
107+
snackbar.showSnackbar(
108+
resources.getString(R.string.dashboard_block_area_added, areaCode),
109+
actionLabel = resources.getString(R.string.blocked_log_undo),
110+
duration = SnackbarDuration.Long,
111+
)
112+
if (choice == SnackbarResult.ActionPerformed) viewModel.undoWildcardRule(undo)
113+
}
114+
}
62115
}
63116

64117
@Composable

0 commit comments

Comments
 (0)