From 429594e6bc1069f7e7141060df3a0fd8bf63749b Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 22 Aug 2026 01:06:35 +0000 Subject: Concurrent feed fetching with durable fetch-result tracking Feeds are fetched with bounded concurrency (semaphore of 8) instead of strictly sequentially -- with 100+ subscriptions, one slow/stalling server no longer delays every feed queued behind it. A process-local mutex prevents the periodic and manual/alarm-triggered one-time fetches (two different WorkManager unique-work names, so its own dedup doesn't cover this) from ever running concurrently and racing writes to the same feed rows. Settings.recordFetchResult persists lastFetchAt/lastFetchNewArticles on every run, not derived from WorkInfo.State -- a PeriodicWorkRequest cycles back to ENQUEUED after every successful run and never reaches SUCCEEDED, so anything watching for that only ever saw manual fetches. ArticleListScreen surfaces this as a "N new articles" snackbar (acknowledged durably, so returning to the screen doesn't re-show it) and computes live "Checking feed X of Y" progress from the worker's tagged WorkInfo, scoped to the Unread/Older views that actually aggregate every subscription. --- .../org/terst/feedreader/ui/ArticleListScreen.kt | 65 +++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) (limited to 'app/src/main/java/org/terst/feedreader/ui') diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt index cbad3e2..9d88374 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -1,5 +1,6 @@ package org.terst.feedreader.ui +import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -21,6 +22,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -37,17 +40,24 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.work.WorkInfo +import androidx.work.WorkManager import coil.compose.AsyncImage +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleListItem import org.terst.feedreader.data.FilterResult import org.terst.feedreader.data.FilterRuleEngine +import org.terst.feedreader.data.Settings import org.terst.feedreader.data.markReadBatchChunked +import org.terst.feedreader.work.FeedFetchWorker import java.text.DateFormat import java.util.Date +private const val TAG = "ArticleListScreen" + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ArticleListScreen( @@ -81,6 +91,58 @@ fun ArticleListScreen( // single feed's own list already says so in the top bar, so rows there don't repeat it. val showFeedName = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID || feedId == SAVED_FEED_ID + // A feed fetch always touches every subscription at once -- there's no such thing as + // "fetching just this one feed" or "fetching Saved." Showing "checking feed 7 of 18" + // or a new-articles snackbar on Saved (not feed-driven at all) or a single specific + // feed's screen would misleadingly imply the fetch is about whatever's being viewed. + // Scoped to the two views that actually aggregate across every subscription. + val showFetchStatus = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID + + val snackbarHostState = remember { SnackbarHostState() } + val settings = remember { Settings(context) } + val fetchWorkInfos by remember { + WorkManager.getInstance(context).getWorkInfosByTagFlow(FeedFetchWorker.FETCH_WORK_TAG) + // A pure status readout, not load-bearing for the article list itself -- an + // exception collected via collectAsState would otherwise propagate straight + // through Compose and crash the whole screen. Worst case here is just falling + // back to the plain "nothing's fetching" state instead of taking the app down. + .catch { e -> Log.e(TAG, "Fetch status flow failed", e); emit(emptyList()) } + }.collectAsState(initial = emptyList()) + // RUNNING only, deliberately not ENQUEUED: a periodic WorkRequest's state cycles + // straight back to ENQUEUED after every successful run rather than settling anywhere + // else, so it's ENQUEUED almost all the time between hourly runs -- treating that as + // "currently fetching" meant this permanently showed "Checking feeds…" instead of + // ever actually reaching "Up to date". + val runningFetch = fetchWorkInfos.firstOrNull { it.state == WorkInfo.State.RUNNING } + val isFetching = runningFetch != null + val fetchStatusText = runningFetch?.progress?.let { progress -> + val current = progress.getInt(FeedFetchWorker.KEY_CURRENT, -1) + val total = progress.getInt(FeedFetchWorker.KEY_TOTAL, -1) + if (current >= 0 && total > 0) "Checking feed ${current + 1} of $total…" else "Checking feeds…" + } ?: "Checking feeds…" + + // Driven by Settings' durable lastFetchAt/lastFetchNewArticles, NOT WorkInfo.SUCCEEDED + // -- a periodic fetch's WorkInfo never reaches SUCCEEDED (see above), so watching for + // that only ever caught manual/alarm-triggered one-time fetches, never the actual + // hourly background one. lastAcknowledgedFetchAt is persisted too, not just in-memory + // Compose state, so leaving and returning to this screen (a fresh composable instance) + // doesn't re-show a snackbar for a fetch that was already acknowledged elsewhere. + if (showFetchStatus) { + val lastFetchAt by settings.lastFetchAt.collectAsState(initial = 0L) + val lastFetchNewArticles by settings.lastFetchNewArticles.collectAsState(initial = 0) + val lastAcknowledgedFetchAt by settings.lastAcknowledgedFetchAt.collectAsState(initial = 0L) + LaunchedEffect(lastFetchAt) { + if (lastFetchAt > lastAcknowledgedFetchAt) { + if (lastFetchNewArticles > 0) { + snackbarHostState.showSnackbar( + if (lastFetchNewArticles == 1) "1 new article" else "$lastFetchNewArticles new articles" + ) + } + settings.acknowledgeFetch(lastFetchAt) + } + } + } + // Unread is the app's landing screen (root of the back stack) -- the system back // gesture/button here would otherwise exit the app entirely. Route it to the feed // list instead, matching the same forward affordance already in the top bar. @@ -140,7 +202,8 @@ fun ArticleListScreen( } } ) - } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { items(visibleArticles, key = { it.first.id }) { (article, result) -> -- cgit v1.2.3