summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Settings.kt33
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt65
-rw-r--r--app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt136
3 files changed, 207 insertions, 27 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt
index b4af70e..ce05974 100644
--- a/app/src/main/java/org/terst/feedreader/data/Settings.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt
@@ -2,6 +2,8 @@ package org.terst.feedreader.data
import android.content.Context
import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
@@ -13,6 +15,9 @@ val Context.settingsDataStore by preferencesDataStore(name = "settings")
private object SettingsKeys {
val STASH_BASE_URL = stringPreferencesKey("stash_base_url")
val STASH_API_KEY = stringPreferencesKey("stash_api_key")
+ val LAST_FETCH_AT = longPreferencesKey("last_fetch_at")
+ val LAST_FETCH_NEW_ARTICLES = intPreferencesKey("last_fetch_new_articles")
+ val LAST_ACKNOWLEDGED_FETCH_AT = longPreferencesKey("last_acknowledged_fetch_at")
}
/** Stash connection settings, configured on the Settings screen. Both blank until set. */
@@ -31,4 +36,32 @@ class Settings(private val context: Context) {
prefs[SettingsKeys.STASH_API_KEY] = apiKey.trim()
}
}
+
+ // Durable record of "when did a feed fetch last finish, and with how many new
+ // articles" -- deliberately NOT derived from WorkManager's WorkInfo.State. A
+ // PeriodicWorkRequest's state cycles back to ENQUEUED after every successful run
+ // (it never settles on SUCCEEDED the way a one-time request does), so anything
+ // watching for SUCCEEDED only ever sees manual/alarm-triggered one-time fetches --
+ // the actual hourly background fetch is invisible to that approach. Writing the
+ // result here on every run (periodic or one-time alike) gives the UI one consistent
+ // signal regardless of which triggered it.
+ val lastFetchAt: Flow<Long> =
+ context.settingsDataStore.data.map { it[SettingsKeys.LAST_FETCH_AT] ?: 0L }
+ val lastFetchNewArticles: Flow<Int> =
+ context.settingsDataStore.data.map { it[SettingsKeys.LAST_FETCH_NEW_ARTICLES] ?: 0 }
+ val lastAcknowledgedFetchAt: Flow<Long> =
+ context.settingsDataStore.data.map { it[SettingsKeys.LAST_ACKNOWLEDGED_FETCH_AT] ?: 0L }
+
+ suspend fun recordFetchResult(newArticles: Int) {
+ context.settingsDataStore.edit { prefs ->
+ prefs[SettingsKeys.LAST_FETCH_AT] = System.currentTimeMillis()
+ prefs[SettingsKeys.LAST_FETCH_NEW_ARTICLES] = newArticles
+ }
+ }
+
+ // Persisted (not just in-memory Compose state) so that navigating away from a screen
+ // and back doesn't forget a fetch was already acknowledged and re-show its snackbar.
+ suspend fun acknowledgeFetch(at: Long) {
+ context.settingsDataStore.edit { prefs -> prefs[SettingsKeys.LAST_ACKNOWLEDGED_FETCH_AT] = at }
+ }
}
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) ->
diff --git a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt
index 5ec6bab..9c2962f 100644
--- a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt
+++ b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt
@@ -7,13 +7,24 @@ import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.ArticleEntity
import org.terst.feedreader.data.FeedEntity
import org.terst.feedreader.data.FeedParser
import org.terst.feedreader.data.Net
+import org.terst.feedreader.data.Settings
+import org.terst.feedreader.data.await
import okhttp3.Request
import java.security.MessageDigest
+import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.TimeUnit
/**
@@ -27,33 +38,80 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor
private val client = Net.client
override suspend fun doWork(): Result {
- val db = AppDatabase.get(applicationContext)
- val feeds = db.feedDao().getAll()
+ // WorkManager's "unique work name" dedup does NOT prevent this: schedulePeriodic
+ // and fetchNow use two different unique-work names (periodic and one-time requests
+ // can't share one), so an hourly periodic run and a pull-to-refresh/morning-alarm
+ // fetch firing at the same moment both start doWork() concurrently -- every feed
+ // gets fetched twice, and both instances race writes to the same feed rows'
+ // etag/lastModified/lastFetchedAt. This mutex is process-local, which is exactly
+ // right here: WorkManager runs this worker in the app's own process, never a
+ // separate one, so a single in-memory lock covers every real overlap case.
+ if (!fetchMutex.tryLock()) {
+ return Result.success()
+ }
+ try {
+ val db = AppDatabase.get(applicationContext)
+ val feeds = db.feedDao().getAll()
+ val completedCount = AtomicInteger(0)
- for (feed in feeds) {
- fetchOne(feed, db)
+ // Fetched with bounded concurrency, not one feed at a time: with a large
+ // subscription list (this app has seen 100+ feeds in the wild -- see
+ // AppDatabase's v9 migration comments), a strictly sequential loop means one
+ // slow/stalling server delays every feed queued behind it, and the whole run
+ // can take tens of seconds to minutes. Dispatchers.IO (not the CoroutineWorker
+ // default of Dispatchers.Default) because this is blocking network I/O, not
+ // CPU-bound work -- running it on Default would compete with the app's other
+ // CPU-bound coroutine work for that dispatcher's small, core-sized thread pool.
+ val newArticleCount = coroutineScope {
+ val semaphore = Semaphore(FETCH_CONCURRENCY)
+ feeds.map { feed ->
+ async(Dispatchers.IO) {
+ semaphore.withPermit {
+ val count = fetchOne(feed, db)
+ // KEY_CURRENT now means "how many feeds have finished," not
+ // "which one's currently being checked" -- there's no single
+ // "current" feed anymore under concurrent fetching, but a
+ // monotonically-advancing completed-count is just as good a
+ // progress signal for the UI.
+ setProgress(workDataOf(KEY_CURRENT to completedCount.incrementAndGet(), KEY_TOTAL to feeds.size))
+ count
+ }
+ }
+ }.awaitAll().sum()
+ }
+ // Durable, not WorkInfo-state-based -- see Settings.recordFetchResult's doc
+ // comment for why periodic work's own state can't be used for this.
+ Settings(applicationContext).recordFetchResult(newArticleCount)
+ return Result.success(workDataOf(KEY_TOTAL to feeds.size, KEY_NEW_ARTICLES to newArticleCount))
+ } finally {
+ fetchMutex.unlock()
}
- return Result.success()
}
- private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase) {
+ /** Returns how many articles from this feed were genuinely new (not already stored). */
+ private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase): Int {
val requestBuilder = Request.Builder().url(feed.id)
feed.etag?.let { requestBuilder.header("If-None-Match", it) }
feed.lastModified?.let { requestBuilder.header("If-Modified-Since", it) }
- try {
- client.newCall(requestBuilder.build()).execute().use { response ->
+ return try {
+ client.newCall(requestBuilder.build()).await().use { response ->
val now = System.currentTimeMillis()
if (response.code == 304) {
- db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = null))
- return
+ db.feedDao().updateFetchResult(
+ feed.id, feed.title, feed.siteUrl, now, feed.etag, feed.lastModified, lastError = null
+ )
+ return@use 0
}
if (!response.isSuccessful) {
- db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = "HTTP ${response.code}"))
- return
+ db.feedDao().updateFetchResult(
+ feed.id, feed.title, feed.siteUrl, now, feed.etag, feed.lastModified,
+ lastError = "HTTP ${response.code}"
+ )
+ return@use 0
}
- val body = response.body ?: return
+ val body = response.body ?: return@use 0
val parsed = FeedParser.parse(body.byteStream())
val articles = parsed.items.map { item ->
@@ -70,21 +128,28 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor
createdAt = now
)
}
- db.articleDao().insertNew(articles)
-
- db.feedDao().update(
- feed.copy(
- title = parsed.title.ifBlank { feed.title },
- siteUrl = parsed.siteUrl ?: feed.siteUrl,
- lastFetchedAt = now,
- etag = response.header("ETag") ?: feed.etag,
- lastModified = response.header("Last-Modified") ?: feed.lastModified,
- lastError = null
- )
+ // Room returns -1 for a row OnConflictStrategy.IGNORE actually skipped
+ // (already stored), and the real inserted rowid (>= 0) for a genuinely new
+ // one -- count only the latter so "N new articles" reflects reality.
+ val newCount = db.articleDao().insertNew(articles).count { it != -1L }
+
+ db.feedDao().updateFetchResult(
+ id = feed.id,
+ title = parsed.title.ifBlank { feed.title },
+ siteUrl = parsed.siteUrl ?: feed.siteUrl,
+ lastFetchedAt = now,
+ etag = response.header("ETag") ?: feed.etag,
+ lastModified = response.header("Last-Modified") ?: feed.lastModified,
+ lastError = null
)
+ newCount
}
} catch (e: Exception) {
- db.feedDao().update(feed.copy(lastFetchedAt = System.currentTimeMillis(), lastError = e.message ?: "Fetch failed"))
+ db.feedDao().updateFetchResult(
+ feed.id, feed.title, feed.siteUrl, System.currentTimeMillis(), feed.etag, feed.lastModified,
+ lastError = e.message ?: "Fetch failed"
+ )
+ 0
}
}
@@ -96,6 +161,22 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor
companion object {
private const val UNIQUE_WORK_NAME = "feed_fetch_periodic"
+ // Common tag on every enqueue (periodic AND one-off) so ArticleListScreen can
+ // observe "is a fetch happening right now" via a single getWorkInfosByTagFlow
+ // query, regardless of which of the two triggered it.
+ const val FETCH_WORK_TAG = "feed_fetch"
+ const val KEY_CURRENT = "current"
+ const val KEY_TOTAL = "total"
+ const val KEY_NEW_ARTICLES = "newArticles"
+
+ // Bounded, not unbounded (Dispatchers.IO can grow to 64+ threads on demand) --
+ // enough to meaningfully overlap slow feeds without opening 100+ simultaneous
+ // connections to 100+ different hosts at once on a fetch run with a large
+ // subscription list.
+ private const val FETCH_CONCURRENCY = 8
+
+ private val fetchMutex = Mutex()
+
fun schedulePeriodic(context: Context) {
val request = PeriodicWorkRequestBuilder<FeedFetchWorker>(60, TimeUnit.MINUTES)
.setConstraints(
@@ -103,6 +184,7 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
+ .addTag(FETCH_WORK_TAG)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
@@ -113,7 +195,9 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor
/** One-off immediate fetch, e.g. a manual pull-to-refresh. */
fun fetchNow(context: Context) {
- val request = androidx.work.OneTimeWorkRequestBuilder<FeedFetchWorker>().build()
+ val request = androidx.work.OneTimeWorkRequestBuilder<FeedFetchWorker>()
+ .addTag(FETCH_WORK_TAG)
+ .build()
WorkManager.getInstance(context).enqueueUniqueWork(
"feed_fetch_now",
androidx.work.ExistingWorkPolicy.KEEP,