Skip to content

Commit f136f95

Browse files
committed
feat(blocklists): refresh external subscriptions on a schedule
A subscription was fetched only when it was added or switched back on, so a list that changes daily went stale on the device indefinitely. ExternalBlocklistRefreshWorker runs every six hours on a network and fetches each enabled list that is due. A list is due a day after its last successful sync, or on the interval it declares in a uBlock-style header ("# Expires: 12 hours", or a top-level "expires" in JSON), clamped to between six hours and a week. A failing list is retried no sooner than six hours after the last attempt. Nobody reads a preview in the background, so a download that comes back empty or under half the list's rows is not applied. The last good rows stay and the row says why, the way a collapsed database or hot-feed publish is refused. A person can still accept the smaller list by switching it off and on, which goes through the ordinary apply path. The settings row now shows when the list last updated. Tests: a WorkManager test (work-testing, test scope only) drives the real worker over a real repository through a refresh, a failed refresh, retry pacing and recovery, plus shrink, empty, Expires and disabled cases. Policy and parser cases are covered separately. Disabling the shrink guard or the retry pacing fails them. IsolatedRepositoryFixture takes an optional list source so the worker test can fake the list server, and WorkerScheduleTest pins the new request. No existing assertion changed. The three new strings ship with zh-rCN translations, and the zh coverage floor is ratcheted to 75.7.
1 parent f7c221b commit f136f95

24 files changed

Lines changed: 678 additions & 64 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ All notable changes to CallShield will be documented in this file.
2626
- A short but valid international number (the Faroes, Greenland, Andorra, some
2727
German landlines) is no longer counted as a malformed one by the heuristics.
2828

29+
- External blocklist subscriptions now stay current. A list used to be
30+
fetched only when you added it or switched it back on, so one that changes
31+
daily went stale on your phone indefinitely. Each list is now fetched again
32+
once a day, or on the interval it declares in an `Expires:` header line,
33+
never more often than every six hours. A download that comes back empty or
34+
with under half the list's numbers isn't applied in the background, because
35+
a broken export looks just like a list that cleaned itself up. The list
36+
keeps its last good copy and says why on its row, which also shows when it
37+
last updated.
38+
2939
### Live caller lookup
3040

3141
- Three of the four lookup services are gone, so the app no longer asks them.

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,7 @@ by locale and message type without shipping personal data:
553553
### Data & System
554554
- Selective backup/restore for rules, non-secret settings, and opt-in logs; CSV log export; auto-cleanup (7/14/30/90 days)
555555
- Weekly full sync + scheduled hot list refresh, daily digest notification
556+
- External blocklist subscriptions (Settings > External blocklists) take HTTPS CSV, TXT or JSON number lists of up to 1 MB and 20,000 rows. Each list is fetched again once a day, or on the interval it declares in its header (`# Expires: 12 hours`, or `"expires": "12h"` in JSON), never more often than every six hours and at least weekly. A download that comes back empty or with under half the list's numbers isn't applied in the background. The list keeps its last good copy and says why on its row
556557
- Quick Settings tile, app shortcuts, home screen widget
557558
- Protection test validates all layers and permissions, including checker errors and deadline cutoffs
558559
- Rules surface priority conflicts after sync and edits, with the winning rule and a review path
@@ -808,7 +809,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
808809
| URL Safety | Local spam-domain data; optional URLhaus (abuse.ch) |
809810
| Verification | Local Gradle, lint, and release-artifact checks |
810811
| Tests | 1100 JVM unit tests (JUnit) |
811-
| Strings | 1411 string resources and 33 plural groups (translation-ready) |
812+
| Strings | 1414 string resources and 33 plural groups (translation-ready) |
812813
| Accessibility | 100+ content descriptions, 48dp touch targets |
813814
| Min SDK | 29 (Android 10) |
814815
| Target SDK | 36 |

‎app/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ dependencies {
279279
testImplementation(libs.kotlinx.serialization.json)
280280
testImplementation(libs.robolectric)
281281
testImplementation(libs.androidx.test.core)
282+
testImplementation(libs.androidx.work.testing)
282283

283284
// Instrumentation tests (emulator / device)
284285
androidTestImplementation(platform(libs.androidx.compose.bom))

‎app/gradle.lockfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ androidx.window:window-core:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeC
189189
androidx.window:window:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath
190190
androidx.work:work-runtime-ktx:2.11.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
191191
androidx.work:work-runtime:2.11.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
192+
androidx.work:work-testing:2.11.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
192193
ch.qos.logback:logback-classic:1.3.16=ktlint
193194
ch.qos.logback:logback-core:1.3.16=ktlint
194195
com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath

‎app/src/main/java/com/sysadmindoc/callshield/CallShieldApp.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.sysadmindoc.callshield.service.AppUpdateWorker
2323
import com.sysadmindoc.callshield.service.CrashReporter
2424
import com.sysadmindoc.callshield.service.DigestWorker
2525
import com.sysadmindoc.callshield.service.DirectBootScreeningStore
26+
import com.sysadmindoc.callshield.service.ExternalBlocklistRefreshWorker
2627
import com.sysadmindoc.callshield.service.HotDataSync
2728
import com.sysadmindoc.callshield.service.HotListSyncWorker
2829
import com.sysadmindoc.callshield.service.NotificationHelper
@@ -89,6 +90,7 @@ class CallShieldApp :
8990
SyncWorker.schedule(this)
9091
HotListSyncWorker.schedule(this)
9192
DigestWorker.schedule(this)
93+
ExternalBlocklistRefreshWorker.schedule(this)
9294
PendingBlockedCallLogWorker.schedule(this)
9395
ProtectionHealthWorker.schedule(this)
9496
ProtectionHealthWorker.checkNow(this)

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ internal data class ParsedExternalBlocklist(
2828
val format: String,
2929
val numbers: List<SpamNumber>,
3030
val skippedRows: Int,
31+
/** Hours between refreshes the list declares for itself, or 0 for none. */
32+
val declaredRefreshHours: Int = 0,
3133
)
3234

3335
private data class ExternalBlocklistRow(
@@ -53,6 +55,10 @@ internal object ExternalBlocklistParser {
5355
private val descriptionFields = setOf("description", "comment", "name", "reason")
5456
private val arrayFields = setOf("numbers", "blocklist", "entries", "data")
5557

58+
private const val EXPIRES_HEADER_LINES = 50
59+
private val expiresLine = Regex("""^expires\s*:\s*(.+)$""", RegexOption.IGNORE_CASE)
60+
private val expiresValue = Regex("""^(\d{1,5})(?!\d)\s*([a-z]*)""", RegexOption.IGNORE_CASE)
61+
5662
fun parse(
5763
rawUrl: String,
5864
rawLabel: String,
@@ -104,9 +110,63 @@ internal object ExternalBlocklistParser {
104110
format = format,
105111
numbers = numbers,
106112
skippedRows = skippedRows,
113+
declaredRefreshHours = declaredRefreshHours(format, body) ?: 0,
107114
)
108115
}
109116

117+
/**
118+
* The refresh interval, in hours, that a list declares for itself, or null
119+
* when it declares none. This is uBlock Origin's `Expires:` convention:
120+
* text and CSV lists put it in a header comment (`# Expires: 12 hours`),
121+
* JSON lists in a top-level `"expires"` value. A bare number means days.
122+
*/
123+
fun declaredRefreshHours(
124+
format: String,
125+
body: String,
126+
): Int? {
127+
val declared =
128+
if (format == "json") {
129+
jsonExpires(body)
130+
} else {
131+
body
132+
.lineSequence()
133+
.map { it.trim() }
134+
.filter { it.isNotEmpty() }
135+
.take(EXPIRES_HEADER_LINES)
136+
.takeWhile { it.startsWith("#") || it.startsWith("//") }
137+
.map { it.removePrefix("//").removePrefix("#").trim() }
138+
.firstNotNullOfOrNull { expiresLine.find(it)?.groupValues?.get(1) }
139+
}
140+
return declared?.let(::expiresHours)
141+
}
142+
143+
fun expiresHours(value: String): Int? {
144+
val match = expiresValue.find(value.trim()) ?: return null
145+
val amount = match.groupValues[1].toInt()
146+
if (amount <= 0) return null
147+
return when (match.groupValues[2].lowercase()) {
148+
"h", "hr", "hrs", "hour", "hours" -> amount
149+
"", "d", "day", "days" -> amount * 24
150+
else -> null
151+
}
152+
}
153+
154+
private fun jsonExpires(body: String): String? =
155+
try {
156+
val reader = JsonReader.of(Buffer().writeUtf8(body))
157+
var value: String? = null
158+
if (reader.peek() == JsonReader.Token.BEGIN_OBJECT) {
159+
reader.beginObject()
160+
while (value == null && reader.hasNext()) {
161+
if (reader.nextName() == "expires") value = reader.nextScalarString() else reader.skipValue()
162+
}
163+
}
164+
value
165+
} catch (e: Exception) {
166+
// The declaration is optional metadata; never fail a list over it.
167+
null
168+
}
169+
110170
fun validateHttpUrl(rawUrl: String): String {
111171
val url =
112172
rawUrl.trim().toHttpUrlOrNull()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.sysadmindoc.callshield.data
2+
3+
import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription
4+
import java.util.concurrent.TimeUnit
5+
6+
/**
7+
* When a subscribed list is fetched again in the background, and when a fetch
8+
* is too suspicious to apply without the preview a person would have seen.
9+
*/
10+
internal object ExternalBlocklistRefreshPolicy {
11+
/** A list that declares no `Expires:` interval is refreshed daily. */
12+
val DEFAULT_INTERVAL_MILLIS = TimeUnit.HOURS.toMillis(24)
13+
14+
/** Never sooner than this, whatever the list declares or however often it fails. */
15+
val MIN_INTERVAL_MILLIS = TimeUnit.HOURS.toMillis(6)
16+
17+
/** A list that declares a longer interval is still checked weekly. */
18+
val MAX_INTERVAL_MILLIS = TimeUnit.DAYS.toMillis(7)
19+
20+
fun intervalMillis(declaredHours: Int): Long =
21+
if (declaredHours <= 0) {
22+
DEFAULT_INTERVAL_MILLIS
23+
} else {
24+
TimeUnit.HOURS.toMillis(declaredHours.toLong()).coerceIn(MIN_INTERVAL_MILLIS, MAX_INTERVAL_MILLIS)
25+
}
26+
27+
fun isDue(
28+
subscription: ExternalBlocklistSubscription,
29+
now: Long,
30+
): Boolean {
31+
if (!subscription.enabled) return false
32+
// A stamp in the future means the clock went back. Waiting for it to
33+
// come round again would stall the list for as long as the jump.
34+
val sinceAttempt = now - subscription.lastAttemptAt
35+
if (subscription.lastAttemptAt > 0L && sinceAttempt in 0L until MIN_INTERVAL_MILLIS) return false
36+
val sinceSuccess = now - subscription.lastSyncedAt
37+
return subscription.lastSyncedAt <= 0L ||
38+
sinceSuccess < 0L ||
39+
sinceSuccess >= intervalMillis(subscription.declaredRefreshHours)
40+
}
41+
42+
/**
43+
* A download holding under half of what the list holds now is kept back,
44+
* as an empty one is. A publisher's broken export looks exactly like a
45+
* list that cleaned itself up, and only a person looking at the preview
46+
* can tell them apart.
47+
*/
48+
fun isSuspiciousShrink(
49+
currentRows: Int,
50+
nextRows: Int,
51+
): Boolean = currentRows > 0 && nextRows.toLong() * 2 < currentRows
52+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import com.sysadmindoc.callshield.data.checker.CheckerDependencies
2020
import com.sysadmindoc.callshield.data.local.AppDatabase
2121
import com.sysadmindoc.callshield.data.local.SpamDao
2222
import com.sysadmindoc.callshield.data.model.*
23+
import com.sysadmindoc.callshield.data.remote.ExternalBlocklistDataSource
2324
import com.sysadmindoc.callshield.data.remote.GitHubDataSource
25+
import com.sysadmindoc.callshield.data.remote.OkHttpExternalBlocklistDataSource
2426
import com.sysadmindoc.callshield.data.remote.SpamDataSource
2527
import com.sysadmindoc.callshield.data.repository.BlocklistRepository
2628
import com.sysadmindoc.callshield.data.repository.SettingsRepository
@@ -79,6 +81,7 @@ class SpamRepository(
7981
privateSettingsDataStore: DataStore<Preferences>? = null,
8082
private val phoneIdentityCanonicalizer: PhoneIdentityCanonicalizer =
8183
PhoneIdentityCanonicalizer.fromContext(context.applicationContext),
84+
externalBlocklistDataSource: ExternalBlocklistDataSource = OkHttpExternalBlocklistDataSource(),
8285
) {
8386
private val appContext: Context = context.applicationContext
8487
private val db: AppDatabase = database
@@ -107,6 +110,7 @@ class SpamRepository(
107110
settingsRepository = settingsRepository,
108111
normalizeNumber = phoneIdentityCanonicalizer::canonicalizePhone,
109112
invalidateAllCaches = spamRepositoryImpl::invalidateAllCaches,
113+
externalBlocklistDataSource = externalBlocklistDataSource,
110114
)
111115
private val blocklistRepository =
112116
BlocklistRepository(
@@ -646,6 +650,8 @@ class SpamRepository(
646650

647651
suspend fun removeExternalBlocklistSubscription(id: String) = syncRepository.removeExternalBlocklistSubscription(id)
648652

653+
suspend fun refreshDueExternalBlocklists(now: Long = System.currentTimeMillis()): List<ExternalBlocklistRefreshOutcome> = syncRepository.refreshDueExternalBlocklists(now)
654+
649655
// ── Blocklist management ───────────────────────────────────────────
650656
suspend fun blockNumber(
651657
number: String,

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ data class ExternalBlocklistSubscription(
1010
val lastAdded: Int = 0,
1111
val lastRemoved: Int = 0,
1212
val lastError: String = "",
13+
/** Hours between refreshes that the list declares in an `Expires:` line, or 0 for none. */
14+
val declaredRefreshHours: Int = 0,
15+
/** The last fetch, successful or not. Paces retries of a list that keeps failing. */
16+
val lastAttemptAt: Long = 0L,
1317
) {
1418
val source: String get() = sourceFor(id)
1519

@@ -34,6 +38,14 @@ data class ExternalBlocklistPreview(
3438
val blockedByOtherSources: Int,
3539
)
3640

41+
enum class ExternalBlocklistRefreshOutcome {
42+
REFRESHED,
43+
44+
/** The download was empty or under half the list's size, so the last good rows stay. */
45+
HELD,
46+
FAILED,
47+
}
48+
3749
data class ExternalBlocklistImportResult(
3850
val success: Boolean,
3951
val message: String,

0 commit comments

Comments
 (0)