Skip to content

Commit d3f207e

Browse files
committed
fix(sync): refuse a release notice older than the stored one
app_release.json is signed, but a mirror can still serve an older signed copy, and that replaced the stored notice, hiding a newer release from Home. saveAppReleaseNotice now compares the stored version code inside the same DataStore edit as the write and keeps the stored notice when it is for a later release. An equal code still replaces it, so a release can be republished. refreshAppReleaseNotice returns whether the notice was kept; its callers ignore the result. AppReleaseNoticeRepositoryTest gains a rollback test, which fails with the check removed. The existing "a current build shows none" test was wrong for this behavior: it served a current-build notice after a newer one and expected the newer one to vanish, which is the rollback this change refuses. It now serves the current-build notice first on an empty store and keeps all three of its assertions.
1 parent 0f765b8 commit d3f207e

5 files changed

Lines changed: 38 additions & 10 deletions

File tree

‎CHANGELOG.md‎

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

55
## Unreleased
66

7+
- The update notice on Home can't be swapped back to an older release anymore. A mirror serving an older signed notice used to replace the newer one, which could hide an update.
78
- Regex rules shaped like a long run of optional digits, or a repeated group that ends in an optional part, are now turned down when you add them, since they could hold up call screening. One saved earlier in such a shape no longer runs.
89
- Turning the phone while Not spam waits out its Undo, or while Report is still going out, no longer loses the message that follows. Report's Undo for the block stays too.
910
- A number you block, unblock or report while the spam database is updating keeps that change when the update finishes. A change made just before the update wrote its rows could be put back the way it was.

‎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%2C403-f38ba8?style=flat-square" alt="51,403 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2046-94e2d5?style=flat-square" alt="2046 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2047-94e2d5?style=flat-square" alt="2047 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">
@@ -592,12 +592,12 @@ RELEASE_KEY_PASSWORD=...
592592
## Testing
593593

594594
```bash
595-
./gradlew testDebugUnitTest # 2046 tests
595+
./gradlew testDebugUnitTest # 2047 tests
596596
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
597597
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
598598
```
599599

600-
The suite is **2046 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
600+
The suite is **2047 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
601601

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

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,22 @@ class SettingsRepository(
276276
notice.takeIf { it.shouldShow(installedCode, prefs[SpamRepository.KEY_RELEASE_NOTICE_DISMISSED] ?: 0) }
277277
}.distinctUntilChanged()
278278

279-
suspend fun saveAppReleaseNotice(notice: AppReleaseNotice) {
279+
/**
280+
* Keeps [notice] unless the stored one is for a later release, so a mirror
281+
* serving an older signed file can't hide a newer release. Returns whether
282+
* it was kept.
283+
*/
284+
suspend fun saveAppReleaseNotice(notice: AppReleaseNotice): Boolean {
285+
var kept = false
280286
dataStore.edit {
287+
if ((it[SpamRepository.KEY_RELEASE_NOTICE_CODE] ?: 0) > notice.versionCode) return@edit
281288
it[SpamRepository.KEY_RELEASE_NOTICE_CODE] = notice.versionCode
282289
it[SpamRepository.KEY_RELEASE_NOTICE_NAME] = notice.versionName
283290
it[SpamRepository.KEY_RELEASE_NOTICE_URL] = notice.releaseUrl
284291
it[SpamRepository.KEY_RELEASE_NOTICE_SHA256] = notice.apkSha256
292+
kept = true
285293
}
294+
return kept
286295
}
287296

288297
suspend fun dismissAppReleaseNotice(versionCode: Int) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,13 @@ class SyncRepository(
293293

294294
/**
295295
* Fetches the signed release notice and keeps it for Home. A missing,
296-
* unsigned or refused file changes nothing, so the last good notice stays.
296+
* unsigned or refused file changes nothing, so the last good notice stays,
297+
* and so does a stored notice for a later release than the one served.
297298
*/
298299
suspend fun refreshAppReleaseNotice(): Boolean =
299300
withContext(Dispatchers.IO) {
300301
val notice = remote.fetchAppReleaseNotice().getOrNull() ?: return@withContext false
301302
settingsRepository.saveAppReleaseNotice(notice)
302-
true
303303
}
304304

305305
private val bundledListCatalog: ParsedListCatalog? by lazy {

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,35 @@ class AppReleaseNoticeRepositoryTest {
6767
fun `a refused or missing file keeps the last good notice, and a current build shows none`() =
6868
runBlocking {
6969
val repo = fixture.repository
70+
served = Result.success(notice(BuildConfig.VERSION_CODE))
71+
repo.refreshAppReleaseNotice()
72+
assertNull(repo.appReleaseNotice.first())
73+
7074
val newer = notice(BuildConfig.VERSION_CODE + 1)
7175
served = Result.success(newer)
7276
repo.refreshAppReleaseNotice()
7377

7478
served = Result.failure(IOException("signature doesn't verify"))
7579
assertFalse(repo.refreshAppReleaseNotice())
7680
assertEquals(newer, repo.appReleaseNotice.first())
81+
}
7782

78-
served = Result.success(notice(BuildConfig.VERSION_CODE))
79-
repo.refreshAppReleaseNotice()
80-
assertNull(repo.appReleaseNotice.first())
83+
@Test
84+
fun `an older notice than the stored one is refused, and the same release can be republished`() =
85+
runBlocking {
86+
val repo = fixture.repository
87+
val newer = notice(BuildConfig.VERSION_CODE + 2)
88+
served = Result.success(newer)
89+
assertTrue(repo.refreshAppReleaseNotice())
90+
91+
served = Result.success(notice(BuildConfig.VERSION_CODE + 1))
92+
assertFalse(repo.refreshAppReleaseNotice())
93+
assertEquals(newer, repo.appReleaseNotice.first())
94+
95+
val republished = newer.copy(apkSha256 = "cd".repeat(32))
96+
served = Result.success(republished)
97+
assertTrue(repo.refreshAppReleaseNotice())
98+
assertEquals(republished, repo.appReleaseNotice.first())
8199
}
82100

83101
private fun notice(code: Int) =

0 commit comments

Comments
 (0)