Skip to content

Commit 8754ccb

Browse files
committed
feat: announce new releases through a signed file in the synced feed
Adoption lags every release because the in-app update check is off by default. Phones already fetch signed files from data/ on every sync, so the release step now writes data/app_release.json (version code, name, release page URL, APK SHA-256), signs it with the feed key, and a phone on a lower version code shows a Home card with a Get it button that opens the release page. Dismissing hides that version only; the next release shows again. - GitHubDataSource refuses a notice whose URL isn't exactly this repo's tag page for that version, a malformed name or hash, or a missing or bad signature. A refused or missing file keeps the last good notice. - No ETag: the file and its signature are about 400 bytes and are fetched as a pair, so a conditional request would save nothing. - scripts/write_app_release.py reads the version from the Gradle file, hashes the APK (or takes --sha256) and signs through feed_signing. - app_release.json joins SIGNED_FEEDS and SIGNED_FEED_PATHS. - New settings keys are classified in BackupSettingsCoverageTest (added cases only, no existing assertion changed).
1 parent c2c802b commit 8754ccb

24 files changed

Lines changed: 619 additions & 11 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ All notable changes to CallShield will be documented in this file.
3535
before it would have failed on October 12. None of the 93 reports reached the
3636
publishing threshold, so the database (version 52) gained no new numbers and
3737
366 numbers wait for more reporters.
38+
- Home shows a card when a newer CallShield release is out, with a link to its
39+
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.
3843

3944
## v1.10.0 (2026-09-28)
4045

‎README.md‎

Lines changed: 6 additions & 6 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-1654-94e2d5?style=flat-square" alt="1654 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-1661-94e2d5?style=flat-square" alt="1661 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">
@@ -283,7 +283,7 @@ by locale and message type without shipping personal data:
283283
- Protection test validates all layers and permissions, including checker errors and deadline cutoffs
284284
- Rules surface priority conflicts after sync and edits, with the winning rule and a review path
285285
- Home leads with localized blocked-call/text outcomes and collapses completed setup into a review row
286-
- Optional weekly GitHub Releases update checks are off by default and only offer release/SHA256 links
286+
- Home shows a card when a newer release is out, read from a signed file that syncs with the spam database, so no extra host is contacted. Optional weekly GitHub Releases update checks are off by default and only offer release/SHA256 links
287287
- Maintainer data regeneration uses the gated `scripts/import_all_sources.py` pipeline. Legacy direct writers aren't supported
288288
- Onboarding wizard with permission requests
289289

@@ -380,7 +380,7 @@ scrape Nomorobo's restricted carrier feed.
380380
## Privacy
381381

382382
Call and message decisions run on the phone. No personal data is collected. CallShield makes these network requests:
383-
- The spam database and trending feeds, from this public repository on GitHub, or from the feed mirror if you set one
383+
- The spam database, trending feeds and the new-release notice, from this public repository on GitHub, or from the feed mirror if you set one
384384
- Live caller enrichment, which is off by default and runs only for locally suspicious calls. The setting names every destination host before a number is shared
385385
- Community reports to the Cloudflare Worker. Each report is committed as a public JSON file to this repository's `data/reports` folder, and the next merge folds it into the database. It holds the reported number, the report type and time, the report's random id, and two reporter IDs that change every day (keyed HMACs of your network's /48 and /64, so the same network can't be linked across days or turned back into an address). An SMS report adds the linked domains and link labels, never message text. The report never holds your IP address. To stop floods and repeat reports, the Worker's short-lived store keys a duplicate check on your IPv4 address or IPv6 /64 for five minutes, and when Cloudflare's rate limiter is unavailable its fallback counter does the same for one minute. Both expire on their own
386386
- Local spam-domain checks don't disclose SMS or RCS links. Optional PhishTank lookups send only the site's base domain, and the OpenPhish feed is downloaded for local matching. Neither receives message text
@@ -509,12 +509,12 @@ RELEASE_KEY_PASSWORD=...
509509
## Testing
510510

511511
```bash
512-
./gradlew testDebugUnitTest # 1654 tests
512+
./gradlew testDebugUnitTest # 1661 tests
513513
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
514514
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
515515
```
516516

517-
The suite is **1654 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
517+
The suite is **1661 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
518518

519519
Two GitHub workflows run without building the app. **Validation** runs the Worker and
520520
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -551,7 +551,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
551551
| Community API | Cloudflare Workers |
552552
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
553553
| Verification | Local Gradle, lint, and release-artifact checks |
554-
| Tests | 1654 JVM unit tests (JUnit) |
554+
| Tests | 1661 JVM unit tests (JUnit) |
555555
| Strings | 1708 string resources and 38 plural groups (translation-ready) |
556556
| Accessibility | 100+ content descriptions, 48dp touch targets |
557557
| Min SDK | 29 (Android 10) |

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,13 @@ class SpamRepository(
296296
/** Set once stored rows carry the latest-evidence expiry; see SyncRepository.applyLatestEvidenceExpiryRule. */
297297
internal val KEY_EVIDENCE_EXPIRY_RULE_APPLIED = booleanPreferencesKey("evidence_expiry_rule_applied")
298298
val KEY_APP_UPDATE_CHECKS = booleanPreferencesKey("app_update_checks_enabled")
299+
300+
/** The newest release the signed feed announced, and the one Home's card was last dismissed for. */
301+
internal val KEY_RELEASE_NOTICE_CODE = intPreferencesKey("release_notice_version_code")
302+
internal val KEY_RELEASE_NOTICE_NAME = stringPreferencesKey("release_notice_version_name")
303+
internal val KEY_RELEASE_NOTICE_URL = stringPreferencesKey("release_notice_url")
304+
internal val KEY_RELEASE_NOTICE_SHA256 = stringPreferencesKey("release_notice_apk_sha256")
305+
internal val KEY_RELEASE_NOTICE_DISMISSED = intPreferencesKey("release_notice_dismissed_code")
299306
internal val KEY_APP_UPDATE_STATUS = stringPreferencesKey("app_update_status")
300307
internal val KEY_APP_UPDATE_TAG = stringPreferencesKey("app_update_latest_tag")
301308
internal val KEY_APP_UPDATE_RELEASE_URL = stringPreferencesKey("app_update_release_url")
@@ -552,6 +559,13 @@ class SpamRepository(
552559

553560
suspend fun clearEndedExpectingCall(now: Long) = settingsRepository.clearEndedExpectingCall(now)
554561

562+
/** The release Home offers, or null when this build is current or the user dismissed it. */
563+
val appReleaseNotice: Flow<AppReleaseNotice?> = settingsRepository.appReleaseNotice()
564+
565+
suspend fun refreshAppReleaseNotice(): Boolean = syncRepository.refreshAppReleaseNotice()
566+
567+
suspend fun dismissAppReleaseNotice(versionCode: Int) = settingsRepository.dismissAppReleaseNotice(versionCode)
568+
555569
suspend fun togglePushAlertPackage(
556570
pkg: String,
557571
allowed: Boolean,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.sysadmindoc.callshield.data.model
2+
3+
/**
4+
* The newest release, from the signed `data/app_release.json` that the
5+
* six-hour sync downloads with the other feeds. `scripts/write_app_release.py`
6+
* writes it at release time. Home offers it while it's newer than the
7+
* installed build and not dismissed.
8+
*/
9+
data class AppReleaseNotice(
10+
val versionCode: Int,
11+
val versionName: String,
12+
val releaseUrl: String,
13+
val apkSha256: String,
14+
) {
15+
/** Whether Home offers this release to a build of [installedCode] that last dismissed [dismissedCode]. */
16+
fun shouldShow(
17+
installedCode: Int,
18+
dismissedCode: Int,
19+
): Boolean = versionCode > installedCode && versionCode != dismissedCode
20+
}

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package com.sysadmindoc.callshield.data.remote
22

33
import android.content.Context
44
import com.squareup.moshi.Json
5+
import com.squareup.moshi.JsonDataException
56
import com.squareup.moshi.Moshi
67
import com.squareup.moshi.Types
78
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
89
import com.sysadmindoc.callshield.data.AppUpdateRelease
10+
import com.sysadmindoc.callshield.data.model.AppReleaseNotice
911
import com.sysadmindoc.callshield.data.model.HotNumber
1012
import com.sysadmindoc.callshield.data.model.SpamDatabase
1113
import com.sysadmindoc.callshield.data.model.SpamDatabaseShard
@@ -35,6 +37,14 @@ internal enum class GitHubFeedFailureReason {
3537
SIGNATURE,
3638
}
3739

40+
/** `data/app_release.json` as published; every field is checked before use. */
41+
internal data class AppReleaseJson(
42+
@param:Json(name = "version_code") val versionCode: Int? = null,
43+
@param:Json(name = "version_name") val versionName: String? = null,
44+
@param:Json(name = "release_url") val releaseUrl: String? = null,
45+
@param:Json(name = "apk_sha256") val apkSha256: String? = null,
46+
)
47+
3848
internal class GitHubFeedValidationException(
3949
val reason: GitHubFeedFailureReason,
4050
message: String,
@@ -177,6 +187,7 @@ class GitHubDataSource internal constructor(
177187
const val HOT_RANGES_PATH = "data/hot_ranges.json"
178188
const val SPAM_DOMAINS_PATH = "data/spam_domains.json"
179189
const val MODEL_WEIGHTS_PATH = "data/spam_model_weights.json"
190+
const val APP_RELEASE_PATH = "data/app_release.json"
180191

181192
const val BUNDLED_DATABASE_ASSET = "spam_numbers.json"
182193
const val BUNDLED_SHARD_MANIFEST_ASSET = "spam_numbers.manifest.json"
@@ -191,6 +202,7 @@ class GitHubDataSource internal constructor(
191202
internal const val MAX_HOT_LIST_BYTES = 1L * 1024L * 1024L
192203
internal const val MAX_HOT_RANGES_BYTES = 512L * 1024L
193204
internal const val MAX_SPAM_DOMAINS_BYTES = 2L * 1024L * 1024L
205+
internal const val MAX_APP_RELEASE_BYTES = 4L * 1024L
194206
internal const val MAX_MODEL_WEIGHTS_BYTES = 1L * 1024L * 1024L
195207

196208
internal const val MAX_SPAM_DATABASE_NUMBERS = 250_000
@@ -226,6 +238,7 @@ class GitHubDataSource internal constructor(
226238
HOT_RANGES_PATH to RawFeedSpec("hot ranges", MAX_HOT_RANGES_BYTES),
227239
SPAM_DOMAINS_PATH to RawFeedSpec("spam domains", MAX_SPAM_DOMAINS_BYTES),
228240
MODEL_WEIGHTS_PATH to RawFeedSpec("model weights", MAX_MODEL_WEIGHTS_BYTES),
241+
APP_RELEASE_PATH to RawFeedSpec("release notice", MAX_APP_RELEASE_BYTES),
229242
)
230243

231244
/**
@@ -234,7 +247,49 @@ class GitHubDataSource internal constructor(
234247
* scripts/feed_signing.py signs the same files; its test checks the lists agree.
235248
*/
236249
internal val SIGNED_FEED_PATHS =
237-
setOf(DATA_PATH, SHARD_MANIFEST_PATH, HOT_LIST_PATH, HOT_RANGES_PATH, SPAM_DOMAINS_PATH, MODEL_WEIGHTS_PATH)
250+
setOf(
251+
DATA_PATH,
252+
SHARD_MANIFEST_PATH,
253+
HOT_LIST_PATH,
254+
HOT_RANGES_PATH,
255+
SPAM_DOMAINS_PATH,
256+
MODEL_WEIGHTS_PATH,
257+
APP_RELEASE_PATH,
258+
)
259+
260+
private val VERSION_NAME_REGEX = Regex("\\d{1,4}\\.\\d{1,4}\\.\\d{1,4}")
261+
private val SHA256_REGEX = Regex("[0-9a-f]{64}")
262+
private val releaseNoticeAdapter =
263+
Moshi
264+
.Builder()
265+
.addLast(KotlinJsonAdapterFactory())
266+
.build()
267+
.adapter(AppReleaseJson::class.java)
268+
269+
/**
270+
* The release notice in [body], refused unless every field is usable and
271+
* the link is this repo's tag page for that version. The file is signed,
272+
* but a notice can only ever point at CallShield's own release page.
273+
*/
274+
internal fun parseAppReleaseNotice(body: String): AppReleaseNotice {
275+
val json =
276+
try {
277+
releaseNoticeAdapter.fromJson(body)
278+
} catch (e: IOException) {
279+
failFeedValidation(GitHubFeedFailureReason.INVALID_SCHEMA, "release notice isn't JSON: ${e.message}")
280+
} catch (e: JsonDataException) {
281+
failFeedValidation(GitHubFeedFailureReason.INVALID_SCHEMA, "release notice has the wrong shape: ${e.message}")
282+
}
283+
val versionCode = json?.versionCode ?: 0
284+
val versionName = json?.versionName.orEmpty()
285+
val apkSha256 = json?.apkSha256.orEmpty()
286+
requireFeed(versionCode > 0, GitHubFeedFailureReason.MISSING_SCHEMA_FIELD) { "release notice has no version_code" }
287+
requireFeed(VERSION_NAME_REGEX.matches(versionName), GitHubFeedFailureReason.INVALID_SCHEMA) { "release notice version_name is unusable" }
288+
requireFeed(SHA256_REGEX.matches(apkSha256), GitHubFeedFailureReason.INVALID_SCHEMA) { "release notice apk_sha256 is unusable" }
289+
val releaseUrl = "https://github.com/$DEFAULT_REPO_OWNER/$DEFAULT_REPO_NAME/releases/tag/v$versionName"
290+
requireFeed(json?.releaseUrl == releaseUrl, GitHubFeedFailureReason.INVALID_SCHEMA) { "release notice links somewhere other than its tag page" }
291+
return AppReleaseNotice(versionCode, versionName, releaseUrl, apkSha256)
292+
}
238293

239294
/** Throws unless a signed feed arrived with a signature the app accepts. */
240295
internal fun requireFeedSignature(
@@ -417,6 +472,22 @@ class GitHubDataSource internal constructor(
417472
}
418473
}
419474

475+
override suspend fun fetchAppReleaseNotice(
476+
owner: String,
477+
repo: String,
478+
): Result<AppReleaseNotice> =
479+
withContext(Dispatchers.IO) {
480+
val result = fetchRawText(APP_RELEASE_PATH, owner, repo)
481+
if (result.isFailure) {
482+
return@withContext Result.failure(result.exceptionOrNull()!!)
483+
}
484+
try {
485+
Result.success(parseAppReleaseNotice(result.getOrThrow()))
486+
} catch (refused: GitHubFeedValidationException) {
487+
Result.failure(refused)
488+
}
489+
}
490+
420491
suspend fun fetchModelWeightsJson(
421492
owner: String = DEFAULT_REPO_OWNER,
422493
repo: String = DEFAULT_REPO_NAME,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.sysadmindoc.callshield.data.remote
22

3+
import com.sysadmindoc.callshield.data.model.AppReleaseNotice
34
import com.sysadmindoc.callshield.data.model.SpamDatabase
45
import com.sysadmindoc.callshield.data.model.SpamDatabaseShard
56
import com.sysadmindoc.callshield.data.model.SpamShardManifest
@@ -33,6 +34,12 @@ interface SpamDataSource {
3334

3435
fun parseSpamShardJson(body: String): Result<SpamDatabaseShard> = Result.failure(UnsupportedOperationException("Content-addressed spam shards are unavailable"))
3536

37+
/** The signed release notice (`data/app_release.json`), fetched like the other feeds. */
38+
suspend fun fetchAppReleaseNotice(
39+
owner: String = GitHubDataSource.DEFAULT_REPO_OWNER,
40+
repo: String = GitHubDataSource.DEFAULT_REPO_NAME,
41+
): Result<AppReleaseNotice> = Result.failure(UnsupportedOperationException("Release notices are unavailable"))
42+
3643
/** True while GitHub itself fails certificate verification, even when a mirror is serving the feeds. */
3744
val gitHubTrustFailing: Boolean get() = false
3845

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.sysadmindoc.callshield.data.NotificationScreeningSources
2727
import com.sysadmindoc.callshield.data.RegionRules
2828
import com.sysadmindoc.callshield.data.RegulatoryPrefix
2929
import com.sysadmindoc.callshield.data.SpamRepository
30+
import com.sysadmindoc.callshield.data.model.AppReleaseNotice
3031
import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription
3132
import com.sysadmindoc.callshield.data.model.HotDataHealth
3233
import com.sysadmindoc.callshield.data.model.HotDataHealthUpdate
@@ -259,6 +260,34 @@ class SettingsRepository(
259260
val activeProfileName: Flow<String?> = dataStore.data.map { it[SpamRepository.KEY_ACTIVE_PROFILE] }
260261
val appTheme: Flow<String> = dataStore.data.map { sanitizeAppTheme(it[SpamRepository.KEY_APP_THEME]) }
261262
val appUpdateChecksEnabled: Flow<Boolean> = dataStore.data.map { it[SpamRepository.KEY_APP_UPDATE_CHECKS] ?: false }
263+
264+
/** The stored release notice while [installedCode] is older and the user hasn't dismissed that release. */
265+
fun appReleaseNotice(installedCode: Int = BuildConfig.VERSION_CODE): Flow<AppReleaseNotice?> =
266+
dataStore.data
267+
.map { prefs ->
268+
val notice =
269+
AppReleaseNotice(
270+
versionCode = prefs[SpamRepository.KEY_RELEASE_NOTICE_CODE] ?: return@map null,
271+
versionName = prefs[SpamRepository.KEY_RELEASE_NOTICE_NAME] ?: return@map null,
272+
releaseUrl = prefs[SpamRepository.KEY_RELEASE_NOTICE_URL] ?: return@map null,
273+
apkSha256 = prefs[SpamRepository.KEY_RELEASE_NOTICE_SHA256].orEmpty(),
274+
)
275+
notice.takeIf { it.shouldShow(installedCode, prefs[SpamRepository.KEY_RELEASE_NOTICE_DISMISSED] ?: 0) }
276+
}.distinctUntilChanged()
277+
278+
suspend fun saveAppReleaseNotice(notice: AppReleaseNotice) {
279+
dataStore.edit {
280+
it[SpamRepository.KEY_RELEASE_NOTICE_CODE] = notice.versionCode
281+
it[SpamRepository.KEY_RELEASE_NOTICE_NAME] = notice.versionName
282+
it[SpamRepository.KEY_RELEASE_NOTICE_URL] = notice.releaseUrl
283+
it[SpamRepository.KEY_RELEASE_NOTICE_SHA256] = notice.apkSha256
284+
}
285+
}
286+
287+
suspend fun dismissAppReleaseNotice(versionCode: Int) {
288+
dataStore.edit { it[SpamRepository.KEY_RELEASE_NOTICE_DISMISSED] = versionCode }
289+
}
290+
262291
val appUpdateState: Flow<AppUpdateState> =
263292
dataStore.data.map { prefs ->
264293
AppUpdateState(

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,17 @@ class SyncRepository(
260260
}
261261
}
262262

263+
/**
264+
* Fetches the signed release notice and keeps it for Home. A missing,
265+
* unsigned or refused file changes nothing, so the last good notice stays.
266+
*/
267+
suspend fun refreshAppReleaseNotice(): Boolean =
268+
withContext(Dispatchers.IO) {
269+
val notice = remote.fetchAppReleaseNotice().getOrNull() ?: return@withContext false
270+
settingsRepository.saveAppReleaseNotice(notice)
271+
true
272+
}
273+
263274
/** Saves [url] as the feed mirror once it serves this project's signed manifest. */
264275
suspend fun saveFeedMirrorUrl(url: String): FeedMirrorSave {
265276
val normalized = FeedMirror.normalize(url) ?: return FeedMirrorSave.INVALID

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ class SyncWorker
3636
// Also sync the ML model weights file — lightweight, same GitHub repo
3737
spamMLScorer.syncWeights(applicationContext)
3838

39+
// And the signed release notice Home offers to older builds.
40+
try {
41+
repo.refreshAppReleaseNotice()
42+
} catch (e: Exception) {
43+
android.util.Log.w("SyncWorker", "Release notice refresh failed", e)
44+
}
45+
3946
// A pin failure still reports success when older data is on the
4047
// device, so it is surfaced here rather than through the result.
4148
FeedTrustNotice.maybeNotify(applicationContext, repo)

0 commit comments

Comments
 (0)