summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-22 01:06:35 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-22 01:06:35 +0000
commit429594e6bc1069f7e7141060df3a0fd8bf63749b (patch)
treee9aa74d339a7f3fa06aa4cce70858fd0260f4177 /app/src/main/java/org/terst/feedreader/ui
parent2352cb39d9623074192a6a1a7fec47e22ab4606b (diff)
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.
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt65
1 files changed, 64 insertions, 1 deletions
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) ->