@@ -118,15 +118,16 @@ internal object HotDataSync {
118118
119119 /* *
120120 * Fills the stores a fresh process starts without. Returns true when one
121- * that a network copy filled before is empty now and wants a refresh
122- * soon, rather than at the next periodic run.
121+ * that a network copy filled before is empty now, with no kept copy to
122+ * start from, and wants a refresh soon rather than at the next periodic run.
123123 */
124124 suspend fun primeBundled (
125125 context : Context ,
126126 source : HotFeedDataSource = GitHubDataSource (),
127127 repo : SpamRepository = SpamRepository .getInstance(context.applicationContext),
128128 dao : SpamDao = AppDatabase .getInstance(context.applicationContext).spamDao(),
129129 dependencies : CheckerDependencies = CheckerDependencies (),
130+ kept : PersistedHotFeeds = PersistedHotFeeds (context),
130131 ): Boolean =
131132 withContext(Dispatchers .IO ) {
132133 val appContext = context.applicationContext
@@ -137,41 +138,60 @@ internal object HotDataSync {
137138 // so every process starts with them empty. Once a network copy was
138139 // read, the build-time copy must not stand in for it: whatever the
139140 // publisher cleared since the build would block or label again.
140- // Those stores wait for a refresh instead, asked for right away.
141+ // Those stores start from the copy kept at the last refresh, and
142+ // a phone with none (updated from a build that kept no copy, or a
143+ // file that didn't survive) waits for a refresh, asked for right away.
141144 fun bootstrap (
142145 feed : String ,
143146 hasData : Boolean ,
144- apply : () -> Unit ,
147+ applyKept : (List <String >) -> Unit ,
148+ applyBundled : () -> Unit ,
145149 ) {
146150 if (hasData) return
147151 if (health.feedGeneratedAt[feed] == null ) {
148- apply ()
149- } else if (feed !in health.clearedFeeds) {
150- wantsRefresh = true
152+ applyBundled()
153+ return
154+ }
155+ val lines = kept.read(feed)
156+ when {
157+ lines != null -> applyKept(lines)
158+ feed !in health.clearedFeeds -> wantsRefresh = true
151159 }
152160 }
153161
154162 // Gate every store on emptiness, not just the hot list below. WorkManager
155163 // can run an overdue HotListSyncWorker in-process right after startup, so
156164 // an ungated write here can land after its fresh data and replace it with
157165 // the build-time snapshot until the next 30-minute cycle.
158- bootstrap(HOT_RANGES_FEED , dependencies.spamHeuristics.hasHotRanges()) {
166+ bootstrap(
167+ HOT_RANGES_FEED ,
168+ dependencies.spamHeuristics.hasHotRanges(),
169+ applyKept = { dependencies.spamHeuristics.updateHotRanges(sanitizeHotRanges(it)) },
170+ ) {
159171 val bundledRanges = loadBundledHotRanges(appContext, source)
160172 val ranges = sanitizeHotRanges(bundledRanges.data)
161173 if (bundledRanges.resolved && shouldApplyFeed(ranges, bundledRanges.explicitlyCleared)) {
162174 dependencies.spamHeuristics.updateHotRanges(ranges)
163175 }
164176 }
165177
166- bootstrap(SPAM_DOMAINS_FEED , dependencies.smsContentAnalyzer.hasSpamDomains()) {
178+ bootstrap(
179+ SPAM_DOMAINS_FEED ,
180+ dependencies.smsContentAnalyzer.hasSpamDomains(),
181+ applyKept = { dependencies.smsContentAnalyzer.updateSpamDomains(sanitizeSpamDomains(it)) },
182+ ) {
167183 val bundledDomains = loadBundledSpamDomains(appContext, source)
168184 val domains = sanitizeSpamDomains(bundledDomains.data)
169185 if (bundledDomains.resolved && shouldApplyFeed(domains, bundledDomains.explicitlyCleared)) {
170186 dependencies.smsContentAnalyzer.updateSpamDomains(domains)
171187 }
172188 }
173189
174- bootstrap(COMMUNITY_WATCH_FEED , dependencies.spamHeuristics.hasCommunityWatch()) {
190+ bootstrap(
191+ COMMUNITY_WATCH_FEED ,
192+ dependencies.spamHeuristics.hasCommunityWatch(),
193+ applyKept = { dependencies.spamHeuristics.updateCommunityWatch(sanitizeCommunityWatch(decodeCommunityWatch(it))) },
194+ ) {
175195 val bundledWatch = loadBundledCommunityWatch(appContext, source)
176196 val watchNumbers = sanitizeCommunityWatch(bundledWatch.data)
177197 if (bundledWatch.resolved && shouldApplyFeed(watchNumbers, bundledWatch.explicitlyCleared)) {
@@ -213,6 +233,7 @@ internal object HotDataSync {
213233 repo : SpamRepository ,
214234 dao : SpamDao ,
215235 dependencies : CheckerDependencies = CheckerDependencies (),
236+ kept : PersistedHotFeeds = PersistedHotFeeds (context),
216237 ): RefreshOutcome =
217238 withContext(Dispatchers .IO ) {
218239 val appContext = context.applicationContext
@@ -244,6 +265,8 @@ internal object HotDataSync {
244265 val hotRangesApplied = ! hotRangesReplay && shouldApplyFeed(ranges, hotRanges.explicitlyCleared)
245266 if (hotRanges.resolved && hotRangesApplied) {
246267 dependencies.spamHeuristics.updateHotRanges(ranges)
268+ // Only a network copy is kept for the next start; the bundled fallback carries a failure.
269+ if (hotRanges.failure == null ) kept.write(HOT_RANGES_FEED , ranges)
247270 }
248271
249272 val spamDomains =
@@ -253,6 +276,7 @@ internal object HotDataSync {
253276 val spamDomainsApplied = ! spamDomainsReplay && shouldApplyFeed(domains, spamDomains.explicitlyCleared)
254277 if (spamDomains.resolved && spamDomainsApplied) {
255278 dependencies.smsContentAnalyzer.updateSpamDomains(domains)
279+ if (spamDomains.failure == null ) kept.write(SPAM_DOMAINS_FEED , domains)
256280 }
257281
258282 val communityWatch =
@@ -267,6 +291,7 @@ internal object HotDataSync {
267291 ! communityWatchReplay && shouldApplyFeed(watchNumbers, communityWatch.explicitlyCleared)
268292 if (communityWatch.resolved && communityWatchApplied) {
269293 dependencies.spamHeuristics.updateCommunityWatch(watchNumbers)
294+ if (communityWatch.failure == null ) kept.write(COMMUNITY_WATCH_FEED , encodeCommunityWatch(watchNumbers))
270295 }
271296
272297 val update =
@@ -589,6 +614,15 @@ internal object HotDataSync {
589614 .distinctBy { it.number }
590615 .toList()
591616
617+ /* * One kept line per watch entry: the number, a tab, the reporter count. */
618+ internal fun encodeCommunityWatch (numbers : List <CommunityWatchNumber >): List <String > = numbers.map { " ${it.number} \t ${it.reporterCount} " }
619+
620+ internal fun decodeCommunityWatch (lines : List <String >): List <CommunityWatchNumber > =
621+ lines.mapNotNull { line ->
622+ val count = line.substringAfter(' \t ' , " " ).trim().toIntOrNull() ? : return @mapNotNull null
623+ CommunityWatchNumber (line.substringBefore(' \t ' ), count)
624+ }
625+
592626 private fun canonicalNumberKey (number : String ): String = number.trim()
593627
594628 internal const val HOT_LIST_FEED = " hot_list"
0 commit comments