summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/work
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
commitd8ce4b3c0f383e844b125d2d3abd618715bb3c89 (patch)
treeb586ff9b42392f2c44645773c2a2f2aadb9db406 /app/src/main/java/org/terst/feedreader/work
Initial commit: Feeds, an Android RSS reader
Feed subscriptions (RSS 2.0 + Atom, hand-rolled parser), OPML import/export, WorkManager periodic fetch with conditional GET, Room-backed local storage (feeds/articles/filter rules), keyword mute/highlight filtering. Reader mode: on-device extraction via Mozilla's Readability.js (vendored from github.com/mozilla/readability, pinned to release 0.6.0) run in a hidden WebView, for feed articles whose provided content is too thin to be the real article body. Save: explicit action (from a feed article, or the Android share-sheet target for any URL) archives to Stash via POST /artifacts?sync=true, while a local on-device copy (Readability-extracted) is always kept so saved articles read offline regardless of Stash's reachability. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/work')
-rw-r--r--app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt123
1 files changed, 123 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt
new file mode 100644
index 0000000..560b3d8
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt
@@ -0,0 +1,123 @@
+package org.terst.feedreader.work
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+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 okhttp3.Request
+import java.security.MessageDigest
+import java.util.concurrent.TimeUnit
+
+/**
+ * Fetches every subscribed feed on a schedule, parses new items, and
+ * inserts them (existing articles are left untouched -- see ArticleDao.insertNew's
+ * IGNORE conflict strategy). Uses conditional GET (ETag / Last-Modified) so
+ * an unchanged feed costs a 304 instead of a full re-download and re-parse.
+ */
+class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ private val client = Net.client
+
+ override suspend fun doWork(): Result {
+ val db = AppDatabase.get(applicationContext)
+ val feeds = db.feedDao().getAll()
+
+ for (feed in feeds) {
+ fetchOne(feed, db)
+ }
+ return Result.success()
+ }
+
+ private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase) {
+ 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 ->
+ val now = System.currentTimeMillis()
+ if (response.code == 304) {
+ db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = null))
+ return
+ }
+ if (!response.isSuccessful) {
+ db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = "HTTP ${response.code}"))
+ return
+ }
+
+ val body = response.body ?: return
+ val parsed = FeedParser.parse(body.byteStream())
+
+ val articles = parsed.items.map { item ->
+ val id = item.guid ?: sha256(item.link)
+ ArticleEntity(
+ id = id,
+ feedId = feed.id,
+ title = item.title,
+ link = item.link,
+ author = item.author,
+ publishedAt = item.publishedAt,
+ feedContentHtml = item.contentHtml,
+ 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
+ )
+ )
+ }
+ } catch (e: Exception) {
+ db.feedDao().update(feed.copy(lastFetchedAt = System.currentTimeMillis(), lastError = e.message ?: "Fetch failed"))
+ }
+ }
+
+ private fun sha256(input: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray())
+ return digest.joinToString("") { "%02x".format(it) }
+ }
+
+ companion object {
+ private const val UNIQUE_WORK_NAME = "feed_fetch_periodic"
+
+ fun schedulePeriodic(context: Context) {
+ val request = PeriodicWorkRequestBuilder<FeedFetchWorker>(60, TimeUnit.MINUTES)
+ .setConstraints(
+ androidx.work.Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+ )
+ .build()
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ UNIQUE_WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ request
+ )
+ }
+
+ /** One-off immediate fetch, e.g. a manual pull-to-refresh. */
+ fun fetchNow(context: Context) {
+ val request = androidx.work.OneTimeWorkRequestBuilder<FeedFetchWorker>().build()
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ "feed_fetch_now",
+ androidx.work.ExistingWorkPolicy.KEEP,
+ request
+ )
+ }
+ }
+}