summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst/feedreader')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/AppDatabase.kt29
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt93
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Entities.kt43
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FeedParser.kt207
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt24
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Net.kt8
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Settings.kt34
-rw-r--r--app/src/main/java/org/terst/feedreader/opml/Opml.kt71
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt95
-rw-r--r--app/src/main/java/org/terst/feedreader/stash/StashClient.kt82
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt152
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt109
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt123
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt79
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt166
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt162
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt113
-rw-r--r--app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt123
18 files changed, 1713 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
new file mode 100644
index 0000000..8579341
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt
@@ -0,0 +1,29 @@
+package org.terst.feedreader.data
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun feedDao(): FeedDao
+ abstract fun articleDao(): ArticleDao
+ abstract fun filterRuleDao(): FilterRuleDao
+
+ companion object {
+ @Volatile private var instance: AppDatabase? = null
+
+ fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
+ instance ?: Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ "feedreader.db"
+ ).build().also { instance = it }
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt
new file mode 100644
index 0000000..0a41f0c
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt
@@ -0,0 +1,93 @@
+package org.terst.feedreader.data
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface FeedDao {
+ @Query("SELECT * FROM feeds ORDER BY title ASC")
+ fun observeAll(): Flow<List<FeedEntity>>
+
+ @Query("SELECT * FROM feeds ORDER BY title ASC")
+ suspend fun getAll(): List<FeedEntity>
+
+ @Query("SELECT * FROM feeds WHERE id = :id")
+ suspend fun getById(id: String): FeedEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(feed: FeedEntity)
+
+ @Update
+ suspend fun update(feed: FeedEntity)
+
+ @Query("DELETE FROM feeds WHERE id = :id")
+ suspend fun delete(id: String)
+}
+
+data class FeedUnreadCount(val feedId: String, val count: Int)
+
+@Dao
+interface ArticleDao {
+ // Unread-first, newest-first -- the default "what's new" view across every feed.
+ @Query("SELECT * FROM articles WHERE isRead = 0 ORDER BY publishedAt DESC")
+ fun observeUnread(): Flow<List<ArticleEntity>>
+
+ @Query("SELECT feedId, COUNT(*) as count FROM articles WHERE isRead = 0 AND feedId IS NOT NULL GROUP BY feedId")
+ fun observeUnreadCounts(): Flow<List<FeedUnreadCount>>
+
+ @Query("SELECT * FROM articles WHERE feedId = :feedId ORDER BY publishedAt DESC")
+ fun observeByFeed(feedId: String): Flow<List<ArticleEntity>>
+
+ @Query("SELECT * FROM articles WHERE isSaved = 1 ORDER BY createdAt DESC")
+ fun observeSaved(): Flow<List<ArticleEntity>>
+
+ @Query("SELECT * FROM articles WHERE id = :id")
+ suspend fun getById(id: String): ArticleEntity?
+
+ // IGNORE, not REPLACE: a re-fetch of a feed re-parses items we already have:
+ // this keeps the existing row (read state, extracted content, saved state)
+ // untouched instead of clobbering it with the freshly-parsed feed data.
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertNew(articles: List<ArticleEntity>): List<Long>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(article: ArticleEntity)
+
+ @Query("UPDATE articles SET isRead = :read WHERE id = :id")
+ suspend fun setRead(id: String, read: Boolean)
+
+ @Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId")
+ suspend fun markAllReadForFeed(feedId: String)
+
+ @Query("UPDATE articles SET extractedContent = :content WHERE id = :id")
+ suspend fun setExtractedContent(id: String, content: String)
+
+ @Query("UPDATE articles SET isSaved = :saved WHERE id = :id")
+ suspend fun setSaved(id: String, saved: Boolean)
+
+ @Query("UPDATE articles SET stashArtifactId = :artifactId WHERE id = :id")
+ suspend fun setStashArtifactId(id: String, artifactId: String)
+
+ @Delete
+ suspend fun delete(article: ArticleEntity)
+}
+
+@Dao
+interface FilterRuleDao {
+ @Query("SELECT * FROM filter_rules ORDER BY createdAt DESC")
+ fun observeAll(): Flow<List<FilterRuleEntity>>
+
+ @Query("SELECT * FROM filter_rules")
+ suspend fun getAll(): List<FilterRuleEntity>
+
+ @Insert
+ suspend fun insert(rule: FilterRuleEntity): Long
+
+ @Query("DELETE FROM filter_rules WHERE id = :id")
+ suspend fun delete(id: Long)
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Entities.kt b/app/src/main/java/org/terst/feedreader/data/Entities.kt
new file mode 100644
index 0000000..d71e1eb
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt
@@ -0,0 +1,43 @@
+package org.terst.feedreader.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "feeds")
+data class FeedEntity(
+ @PrimaryKey val id: String, // the feed URL itself -- naturally unique, no need for a separate id scheme
+ val title: String,
+ val siteUrl: String? = null, // the human-facing site link, if the feed provides one (distinct from the feed XML URL)
+ val lastFetchedAt: Long? = null,
+ val etag: String? = null, // conditional-GET caching (If-None-Match) to avoid re-downloading unchanged feeds
+ val lastModified: String? = null, // conditional-GET caching (If-Modified-Since)
+ val lastError: String? = null // set when a fetch fails; cleared on the next successful fetch
+)
+
+@Entity(tableName = "articles")
+data class ArticleEntity(
+ @PrimaryKey val id: String, // guid if the feed provides one, else a hash of the link
+ @ColumnInfo(index = true) val feedId: String?, // null for a standalone article saved via the share-sheet target, not tied to any feed
+ val title: String,
+ val link: String,
+ val author: String? = null,
+ val publishedAt: Long? = null,
+ val feedContentHtml: String? = null, // whatever content the feed itself provided (may be a summary, may be full text)
+ val extractedContent: String? = null, // Readability-extracted full text, filled in on first read if feedContentHtml was thin
+ val isRead: Boolean = false,
+ val isSaved: Boolean = false, // explicitly kept (independent of read state) -- triggers a Stash save
+ val stashArtifactId: String? = null, // set once successfully archived to Stash; null means not yet saved or save failed
+ val createdAt: Long
+)
+
+enum class FilterAction { MUTE, HIGHLIGHT }
+
+@Entity(tableName = "filter_rules")
+data class FilterRuleEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val pattern: String, // plain substring match, case-insensitive -- see FilterRuleEngine
+ val action: FilterAction,
+ val feedId: String? = null, // null applies globally across all feeds
+ val createdAt: Long
+)
diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt
new file mode 100644
index 0000000..cd337d6
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt
@@ -0,0 +1,207 @@
+package org.terst.feedreader.data
+
+import android.util.Xml
+import org.xmlpull.v1.XmlPullParser
+import java.io.InputStream
+import java.time.OffsetDateTime
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+import java.time.temporal.ChronoField
+import java.util.Locale
+
+data class ParsedItem(
+ val guid: String?,
+ val title: String,
+ val link: String,
+ val author: String?,
+ val publishedAt: Long?,
+ val contentHtml: String?
+)
+
+data class ParsedFeed(
+ val title: String,
+ val siteUrl: String?,
+ val items: List<ParsedItem>
+)
+
+/**
+ * Hand-rolled RSS 2.0 + Atom parser -- deliberately not a third-party library
+ * (rome, etc.), per the "simple design" scope: only the fields this app
+ * actually uses (title/link/guid/author/date/content), nothing else.
+ */
+object FeedParser {
+
+ fun parse(input: InputStream): ParsedFeed {
+ val parser = Xml.newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(input, null)
+
+ // Peek the root element to decide which dialect we're in.
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.START_TAG) {
+ eventType = parser.next()
+ }
+ return when (parser.name.lowercase()) {
+ "rss" -> parseRss(parser)
+ "feed" -> parseAtom(parser)
+ else -> throw IllegalArgumentException("Unrecognized feed root element: ${parser.name}")
+ }
+ }
+
+ private fun parseRss(parser: XmlPullParser): ParsedFeed {
+ var feedTitle = ""
+ var siteUrl: String? = null
+ val items = mutableListOf<ParsedItem>()
+
+ var eventType = parser.next()
+ var depth = 1
+ // Track whether we're directly inside <channel> (title/link there) vs inside an <item>.
+ var inItem = false
+ var itemGuid: String? = null
+ var itemTitle = ""
+ var itemLink = ""
+ var itemAuthor: String? = null
+ var itemDate: Long? = null
+ var itemContent: String? = null
+ var itemDescription: String? = null
+
+ while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ depth++
+ when (parser.name.lowercase()) {
+ "item" -> {
+ inItem = true
+ itemGuid = null; itemTitle = ""; itemLink = ""; itemAuthor = null
+ itemDate = null; itemContent = null; itemDescription = null
+ }
+ "title" -> {
+ val text = parser.nextText()
+ if (inItem) itemTitle = text else feedTitle = text
+ }
+ "link" -> {
+ val text = parser.nextText()
+ if (inItem) itemLink = text else if (siteUrl == null) siteUrl = text
+ }
+ "guid" -> if (inItem) itemGuid = parser.nextText()
+ "author" -> if (inItem) itemAuthor = parser.nextText()
+ "dc:creator" -> if (inItem) itemAuthor = parser.nextText()
+ "pubdate" -> if (inItem) itemDate = parseRfc822Date(parser.nextText())
+ "content:encoded" -> if (inItem) itemContent = parser.nextText()
+ "description" -> if (inItem) itemDescription = parser.nextText()
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ depth--
+ if (parser.name.lowercase() == "item") {
+ inItem = false
+ items += ParsedItem(
+ guid = itemGuid ?: itemLink.ifBlank { null },
+ title = itemTitle,
+ link = itemLink,
+ author = itemAuthor,
+ publishedAt = itemDate,
+ contentHtml = itemContent ?: itemDescription
+ )
+ }
+ }
+ }
+ if (depth > 0) eventType = parser.next()
+ }
+ return ParsedFeed(feedTitle, siteUrl, items)
+ }
+
+ private fun parseAtom(parser: XmlPullParser): ParsedFeed {
+ var feedTitle = ""
+ var siteUrl: String? = null
+ val items = mutableListOf<ParsedItem>()
+
+ var eventType = parser.next()
+ var depth = 1
+ var inEntry = false
+ var entryId: String? = null
+ var entryTitle = ""
+ var entryLink = ""
+ var entryAuthor: String? = null
+ var entryDate: Long? = null
+ var entryContent: String? = null
+ var entrySummary: String? = null
+
+ while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ depth++
+ when (parser.name.lowercase()) {
+ "entry" -> {
+ inEntry = true
+ entryId = null; entryTitle = ""; entryLink = ""; entryAuthor = null
+ entryDate = null; entryContent = null; entrySummary = null
+ }
+ "title" -> {
+ val text = parser.nextText()
+ if (inEntry) entryTitle = text else feedTitle = text
+ }
+ "link" -> {
+ val rel = parser.getAttributeValue(null, "rel")
+ val href = parser.getAttributeValue(null, "href")
+ if (href != null && (rel == null || rel == "alternate")) {
+ if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href
+ }
+ }
+ "id" -> if (inEntry) entryId = parser.nextText()
+ "name" -> if (inEntry && entryAuthor == null) entryAuthor = parser.nextText()
+ "updated" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText())
+ "published" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) ?: entryDate
+ "content" -> if (inEntry) entryContent = parser.nextText()
+ "summary" -> if (inEntry) entrySummary = parser.nextText()
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ depth--
+ if (parser.name.lowercase() == "entry") {
+ inEntry = false
+ items += ParsedItem(
+ guid = entryId ?: entryLink.ifBlank { null },
+ title = entryTitle,
+ link = entryLink,
+ author = entryAuthor,
+ publishedAt = entryDate,
+ contentHtml = entryContent ?: entrySummary
+ )
+ }
+ }
+ }
+ if (depth > 0) eventType = parser.next()
+ }
+ return ParsedFeed(feedTitle, siteUrl, items)
+ }
+
+ private val rfc822Formatters = listOf(
+ DateTimeFormatter.RFC_1123_DATE_TIME,
+ DateTimeFormatterBuilder()
+ .appendPattern("EEE, d MMM yyyy HH:mm:ss ")
+ .optionalStart().appendPattern("zzz").optionalEnd()
+ .optionalStart().appendOffset("+HHMM", "+0000").optionalEnd()
+ .parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
+ .toFormatter(Locale.US)
+ )
+
+ private fun parseRfc822Date(raw: String): Long? {
+ val trimmed = raw.trim()
+ for (formatter in rfc822Formatters) {
+ try {
+ return ZonedDateTime.parse(trimmed, formatter).toInstant().toEpochMilli()
+ } catch (_: Exception) {
+ // try the next formatter
+ }
+ }
+ return null
+ }
+
+ private fun parseIso8601Date(raw: String): Long? = try {
+ OffsetDateTime.parse(raw.trim()).toInstant().toEpochMilli()
+ } catch (_: Exception) {
+ null
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt
new file mode 100644
index 0000000..c32e8e9
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt
@@ -0,0 +1,24 @@
+package org.terst.feedreader.data
+
+/** How a single article is affected by the current set of filter rules. */
+enum class FilterResult { NORMAL, HIGHLIGHTED, MUTED }
+
+/**
+ * Plain case-insensitive substring matching against the article title --
+ * deliberately not regex, per the "simple design" scope. A rule with a
+ * non-null feedId only applies to that feed; a null feedId applies
+ * globally. MUTE wins over HIGHLIGHT if both match (hiding takes priority
+ * over drawing attention).
+ */
+object FilterRuleEngine {
+ fun evaluate(article: ArticleEntity, rules: List<FilterRuleEntity>): FilterResult {
+ val applicable = rules.filter { it.feedId == null || it.feedId == article.feedId }
+ val title = article.title.lowercase()
+
+ val muted = applicable.any { it.action == FilterAction.MUTE && title.contains(it.pattern.lowercase()) }
+ if (muted) return FilterResult.MUTED
+
+ val highlighted = applicable.any { it.action == FilterAction.HIGHLIGHT && title.contains(it.pattern.lowercase()) }
+ return if (highlighted) FilterResult.HIGHLIGHTED else FilterResult.NORMAL
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Net.kt b/app/src/main/java/org/terst/feedreader/data/Net.kt
new file mode 100644
index 0000000..d3720a0
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Net.kt
@@ -0,0 +1,8 @@
+package org.terst.feedreader.data
+
+import okhttp3.OkHttpClient
+
+/** Shared OkHttp client -- one connection pool for the whole app. */
+object Net {
+ val client: OkHttpClient by lazy { OkHttpClient() }
+}
diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt
new file mode 100644
index 0000000..b4af70e
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt
@@ -0,0 +1,34 @@
+package org.terst.feedreader.data
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+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")
+}
+
+/** Stash connection settings, configured on the Settings screen. Both blank until set. */
+class Settings(private val context: Context) {
+ val stashBaseUrl: Flow<String> =
+ context.settingsDataStore.data.map { it[SettingsKeys.STASH_BASE_URL] ?: "" }
+ val stashApiKey: Flow<String> =
+ context.settingsDataStore.data.map { it[SettingsKeys.STASH_API_KEY] ?: "" }
+
+ suspend fun stashBaseUrlNow(): String = stashBaseUrl.first()
+ suspend fun stashApiKeyNow(): String = stashApiKey.first()
+
+ suspend fun setStash(baseUrl: String, apiKey: String) {
+ context.settingsDataStore.edit { prefs ->
+ prefs[SettingsKeys.STASH_BASE_URL] = baseUrl.trim().trimEnd('/')
+ prefs[SettingsKeys.STASH_API_KEY] = apiKey.trim()
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/opml/Opml.kt b/app/src/main/java/org/terst/feedreader/opml/Opml.kt
new file mode 100644
index 0000000..65510fb
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/opml/Opml.kt
@@ -0,0 +1,71 @@
+package org.terst.feedreader.opml
+
+import android.util.Xml
+import org.terst.feedreader.data.FeedEntity
+import org.xmlpull.v1.XmlPullParser
+import java.io.InputStream
+import java.io.OutputStream
+
+data class OpmlFeed(val title: String, val xmlUrl: String, val htmlUrl: String?)
+
+/**
+ * OPML import/export. Any folder nesting (<outline> containing other
+ * <outline> elements instead of an xmlUrl) is flattened -- this app has no
+ * folder concept, per the "simple design" scope, so a folder's contents
+ * are just imported as a flat list of feeds.
+ */
+object Opml {
+
+ fun parse(input: InputStream): List<OpmlFeed> {
+ val parser = Xml.newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(input, null)
+
+ val feeds = mutableListOf<OpmlFeed>()
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG && parser.name.equals("outline", ignoreCase = true)) {
+ val xmlUrl = parser.getAttributeValue(null, "xmlUrl")
+ if (xmlUrl != null) {
+ val title = parser.getAttributeValue(null, "title")
+ ?: parser.getAttributeValue(null, "text")
+ ?: xmlUrl
+ val htmlUrl = parser.getAttributeValue(null, "htmlUrl")
+ feeds += OpmlFeed(title, xmlUrl, htmlUrl)
+ }
+ }
+ eventType = parser.next()
+ }
+ return feeds
+ }
+
+ fun write(feeds: List<FeedEntity>, output: OutputStream) {
+ val serializer = Xml.newSerializer()
+ serializer.setOutput(output, "UTF-8")
+ serializer.startDocument("UTF-8", true)
+ serializer.startTag(null, "opml")
+ serializer.attribute(null, "version", "2.0")
+
+ serializer.startTag(null, "head")
+ serializer.startTag(null, "title")
+ serializer.text("Feeds subscriptions")
+ serializer.endTag(null, "title")
+ serializer.endTag(null, "head")
+
+ serializer.startTag(null, "body")
+ for (feed in feeds) {
+ serializer.startTag(null, "outline")
+ serializer.attribute(null, "text", feed.title)
+ serializer.attribute(null, "title", feed.title)
+ serializer.attribute(null, "type", "rss")
+ serializer.attribute(null, "xmlUrl", feed.id)
+ feed.siteUrl?.let { serializer.attribute(null, "htmlUrl", it) }
+ serializer.endTag(null, "outline")
+ }
+ serializer.endTag(null, "body")
+
+ serializer.endTag(null, "opml")
+ serializer.endDocument()
+ serializer.flush()
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
new file mode 100644
index 0000000..9be08b0
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
@@ -0,0 +1,95 @@
+package org.terst.feedreader.readability
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.coroutines.resume
+
+@Serializable
+data class ExtractedArticle(
+ val title: String? = null,
+ val content: String? = null, // HTML
+ val textContent: String? = null,
+ val excerpt: String? = null,
+ val error: String? = null
+)
+
+/**
+ * Runs Mozilla's Readability.js (vendored in assets/Readability.js, pinned
+ * to release 0.6.0) against a page loaded in a hidden WebView, for feed
+ * articles whose feedContentHtml is missing or too short to be the real
+ * article body. WebView APIs are main-thread-only, so this hops to
+ * Dispatchers.Main for the load+eval and suspends the calling coroutine
+ * until the JS callback fires (or the timeout elapses).
+ */
+object ReaderExtractor {
+ private const val TIMEOUT_MS = 15_000L
+ private val json = Json { ignoreUnknownKeys = true }
+
+ suspend fun extract(context: Context, url: String): ExtractedArticle? =
+ withTimeoutOrNull(TIMEOUT_MS) {
+ withContext(Dispatchers.Main) {
+ extractOnMainThread(context, url)
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private suspend fun extractOnMainThread(context: Context, url: String): ExtractedArticle? =
+ suspendCancellableCoroutine { continuation ->
+ val webView = WebView(context)
+ webView.settings.javaScriptEnabled = true
+
+ fun finish(result: ExtractedArticle?) {
+ if (continuation.isActive) continuation.resume(result)
+ webView.stopLoading()
+ webView.destroy()
+ }
+
+ webView.webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView, finishedUrl: String) {
+ val readabilityJs = context.assets.open("Readability.js").bufferedReader().use { it.readText() }
+ val script = """
+ (function() {
+ try {
+ $readabilityJs
+ var article = new Readability(document.cloneNode(true)).parse();
+ return JSON.stringify(article);
+ } catch (e) {
+ return JSON.stringify({error: e.toString()});
+ }
+ })();
+ """.trimIndent()
+
+ view.evaluateJavascript(script) { rawResult ->
+ // evaluateJavascript's callback value is itself JSON-encoded (our
+ // JSON.stringify(...) string comes back wrapped in an extra layer
+ // of JSON string-quoting), so this needs decoding twice.
+ val unwrapped = runCatching { json.decodeFromString<String>(rawResult) }.getOrNull()
+ val article = unwrapped?.let {
+ runCatching { json.decodeFromString<ExtractedArticle>(it) }.getOrNull()
+ }
+ finish(article)
+ }
+ }
+
+ override fun onReceivedError(
+ view: WebView,
+ errorCode: Int,
+ description: String?,
+ failingUrl: String?
+ ) {
+ finish(ExtractedArticle(error = description ?: "Failed to load $failingUrl"))
+ }
+ }
+
+ continuation.invokeOnCancellation { webView.destroy() }
+ webView.loadUrl(url)
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/stash/StashClient.kt b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
new file mode 100644
index 0000000..28cc2b3
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt
@@ -0,0 +1,82 @@
+package org.terst.feedreader.stash
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+
+private val json = Json { ignoreUnknownKeys = true }
+
+@Serializable
+private data class CreateArtifactRequest(
+ val type: String = "url",
+ @SerialName("source_url") val sourceUrl: String,
+ val title: String? = null
+)
+
+@Serializable
+data class ArtifactResponse(
+ val id: String,
+ val title: String? = null,
+ @SerialName("content_md") val contentMd: String? = null,
+ @SerialName("processing_steps") val processingSteps: List<String> = emptyList()
+)
+
+@Serializable
+private data class DuplicateErrorResponse(@SerialName("existing_id") val existingId: String)
+
+sealed class StashSaveResult {
+ data class Saved(val artifact: ArtifactResponse) : StashSaveResult()
+ data class AlreadySaved(val existingId: String) : StashSaveResult()
+ data class Failed(val message: String) : StashSaveResult()
+}
+
+/**
+ * Client for the parts of Stash's API this app needs: POST /artifacts to
+ * archive an article. Uses sync=true so content_md comes back in the same
+ * response (server runs extract+summary_lite inline, up to ~30s) --
+ * tags/full-summary/embeddings still fill in async server-side afterward,
+ * but this app doesn't need to wait on or poll for those.
+ */
+class StashClient(
+ private val client: OkHttpClient,
+ private val baseUrl: String,
+ private val apiKey: String
+) {
+ suspend fun saveArticle(url: String, title: String?): StashSaveResult = withContext(Dispatchers.IO) {
+ if (baseUrl.isBlank() || apiKey.isBlank()) {
+ return@withContext StashSaveResult.Failed("Stash isn't configured (set base URL and API key in Settings)")
+ }
+
+ val body = json.encodeToString(
+ CreateArtifactRequest.serializer(),
+ CreateArtifactRequest(sourceUrl = url, title = title)
+ ).toRequestBody("application/json".toMediaType())
+
+ val request = Request.Builder()
+ .url("$baseUrl/artifacts?sync=true")
+ .header("X-API-Key", apiKey)
+ .post(body)
+ .build()
+
+ runCatching {
+ client.newCall(request).execute().use { response ->
+ val responseBody = response.body?.string().orEmpty()
+ when {
+ response.code == 201 || response.code == 200 ->
+ StashSaveResult.Saved(json.decodeFromString(ArtifactResponse.serializer(), responseBody))
+ response.code == 409 ->
+ StashSaveResult.AlreadySaved(
+ json.decodeFromString(DuplicateErrorResponse.serializer(), responseBody).existingId
+ )
+ else -> StashSaveResult.Failed("HTTP ${response.code}")
+ }
+ }
+ }.getOrElse { StashSaveResult.Failed(it.message ?: "Network error") }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
new file mode 100644
index 0000000..1e053af
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
@@ -0,0 +1,152 @@
+package org.terst.feedreader.ui
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.Request
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.FeedEntity
+import org.terst.feedreader.data.FeedParser
+import org.terst.feedreader.data.Net
+import org.terst.feedreader.opml.Opml
+import org.terst.feedreader.work.FeedFetchWorker
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddFeedScreen(onDone: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ val scope = rememberCoroutineScope()
+
+ var url by remember { mutableStateOf("") }
+ var status by remember { mutableStateOf<String?>(null) }
+ var isBusy by remember { mutableStateOf(false) }
+
+ val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ isBusy = true
+ scope.launch {
+ val imported = withContext(Dispatchers.IO) {
+ context.contentResolver.openInputStream(uri)?.use { stream -> Opml.parse(stream) } ?: emptyList()
+ }
+ for (feed in imported) {
+ db.feedDao().upsert(FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl))
+ }
+ FeedFetchWorker.fetchNow(context)
+ status = "Imported ${imported.size} feed(s)"
+ isBusy = false
+ }
+ }
+
+ val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/x-opml")) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ scope.launch {
+ withContext(Dispatchers.IO) {
+ val feeds = db.feedDao().getAll()
+ context.contentResolver.openOutputStream(uri)?.use { out -> Opml.write(feeds, out) }
+ }
+ status = "Exported subscriptions"
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Add Feed") },
+ navigationIcon = {
+ IconButton(onClick = onDone) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) {
+ item {
+ Column(modifier = Modifier.padding(16.dp)) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Feed URL") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Button(
+ enabled = url.isNotBlank() && !isBusy,
+ onClick = {
+ isBusy = true
+ val feedUrl = url.trim()
+ scope.launch {
+ status = addFeedByUrl(context, db, feedUrl)
+ if (status?.startsWith("Added") == true) url = ""
+ isBusy = false
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Add") }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedButton(onClick = { importLauncher.launch("*/*") }, modifier = Modifier.fillMaxWidth()) {
+ Text("Import OPML")
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ OutlinedButton(onClick = { exportLauncher.launch("subscriptions.opml") }, modifier = Modifier.fillMaxWidth()) {
+ Text("Export OPML")
+ }
+
+ status?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(it)
+ }
+ }
+ }
+ }
+ }
+}
+
+private suspend fun addFeedByUrl(context: android.content.Context, db: AppDatabase, feedUrl: String): String =
+ withContext(Dispatchers.IO) {
+ try {
+ val response = Net.client.newCall(Request.Builder().url(feedUrl).build()).execute()
+ response.use {
+ if (!it.isSuccessful) return@withContext "Couldn't fetch feed: HTTP ${it.code}"
+ val body = it.body ?: return@withContext "Empty response"
+ val parsed = FeedParser.parse(body.byteStream())
+ db.feedDao().upsert(
+ FeedEntity(id = feedUrl, title = parsed.title.ifBlank { feedUrl }, siteUrl = parsed.siteUrl)
+ )
+ }
+ FeedFetchWorker.fetchNow(context)
+ "Added"
+ } catch (e: Exception) {
+ "Couldn't add feed: ${e.message}"
+ }
+ }
diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
new file mode 100644
index 0000000..d9f3d8a
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
@@ -0,0 +1,109 @@
+package org.terst.feedreader.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextDecoration
+import kotlinx.coroutines.flow.combine
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.FilterResult
+import org.terst.feedreader.data.FilterRuleEngine
+import java.text.DateFormat
+import java.util.Date
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ArticleListScreen(
+ feedId: String,
+ onOpenArticle: (String) -> Unit,
+ onBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+
+ val articleFlow = remember(feedId) {
+ when (feedId) {
+ UNREAD_FEED_ID -> db.articleDao().observeUnread()
+ SAVED_FEED_ID -> db.articleDao().observeSaved()
+ else -> db.articleDao().observeByFeed(feedId)
+ }
+ }
+ val visibleArticles by remember(feedId) {
+ combine(articleFlow, db.filterRuleDao().observeAll()) { articles, rules ->
+ articles.map { it to FilterRuleEngine.evaluate(it, rules) }
+ .filter { (_, result) -> result != FilterResult.MUTED }
+ }
+ }.collectAsState(initial = emptyList())
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ when (feedId) {
+ UNREAD_FEED_ID -> "Unread"
+ SAVED_FEED_ID -> "Saved"
+ else -> "Articles"
+ }
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
+ items(visibleArticles, key = { it.first.id }) { (article, result) ->
+ ArticleRow(
+ article = article,
+ highlighted = result == FilterResult.HIGHLIGHTED,
+ onClick = { onOpenArticle(article.id) }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+private fun ArticleRow(article: ArticleEntity, highlighted: Boolean, onClick: () -> Unit) {
+ val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) }
+ ListItem(
+ headlineContent = {
+ Text(
+ article.title,
+ textDecoration = if (article.isRead) TextDecoration.LineThrough else null
+ )
+ },
+ supportingContent = { if (dateText != null) Text(dateText) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
+ .clickable { onClick() }
+ )
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
new file mode 100644
index 0000000..85c8d3c
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
@@ -0,0 +1,123 @@
+package org.terst.feedreader.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.flow.combine
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.FeedEntity
+import org.terst.feedreader.work.FeedFetchWorker
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FeedListScreen(
+ onOpenFeed: (String) -> Unit,
+ onOpenUnread: () -> Unit,
+ onOpenSaved: () -> Unit,
+ onAddFeed: () -> Unit,
+ onSettings: () -> Unit
+) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+
+ val feeds by remember {
+ combine(db.feedDao().observeAll(), db.articleDao().observeUnreadCounts()) { feeds, counts ->
+ val countsByFeed = counts.associate { it.feedId to it.count }
+ feeds.map { it to (countsByFeed[it.id] ?: 0) }
+ }
+ }.collectAsState(initial = emptyList())
+
+ val totalUnread = feeds.sumOf { it.second }
+ var menuExpanded by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Feeds") },
+ actions = {
+ IconButton(onClick = { FeedFetchWorker.fetchNow(context) }) {
+ Icon(Icons.Default.Refresh, contentDescription = "Refresh all")
+ }
+ IconButton(onClick = onSettings) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(Icons.Default.MoreVert, contentDescription = "More")
+ }
+ DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Import OPML") },
+ onClick = { menuExpanded = false; onAddFeed() }
+ )
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = onAddFeed) {
+ Icon(Icons.Default.Add, contentDescription = "Add feed")
+ }
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) {
+ item {
+ ListItem(
+ headlineContent = { Text("Unread") },
+ trailingContent = { Text(totalUnread.toString()) },
+ modifier = Modifier.fillMaxWidth().clickable { onOpenUnread() }
+ )
+ HorizontalDivider()
+ ListItem(
+ headlineContent = { Text("Saved") },
+ modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() }
+ )
+ HorizontalDivider()
+ }
+ items(feeds, key = { it.first.id }) { (feed, unreadCount) ->
+ FeedRow(feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) })
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) {
+ ListItem(
+ headlineContent = { Text(feed.title.ifBlank { feed.id }) },
+ supportingContent = {
+ if (feed.lastError != null) {
+ Text("Last fetch failed: ${feed.lastError}")
+ }
+ },
+ trailingContent = { if (unreadCount > 0) Text(unreadCount.toString()) },
+ modifier = Modifier.fillMaxWidth().clickable { onClick() }
+ )
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
new file mode 100644
index 0000000..345bede
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
@@ -0,0 +1,79 @@
+package org.terst.feedreader.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import org.terst.feedreader.work.FeedFetchWorker
+import java.net.URLDecoder
+import java.net.URLEncoder
+
+const val UNREAD_FEED_ID = "__unread__"
+const val SAVED_FEED_ID = "__saved__"
+
+fun encodeNavArg(value: String): String = URLEncoder.encode(value, "UTF-8")
+fun decodeNavArg(value: String): String = URLDecoder.decode(value, "UTF-8")
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ FeedFetchWorker.schedulePeriodic(applicationContext)
+
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ FeedReaderApp()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FeedReaderApp() {
+ val navController = rememberNavController()
+ NavHost(navController = navController, startDestination = "feeds") {
+ composable("feeds") {
+ FeedListScreen(
+ onOpenFeed = { feedId -> navController.navigate("articles/${encodeNavArg(feedId)}") },
+ onOpenUnread = { navController.navigate("articles/${encodeNavArg(UNREAD_FEED_ID)}") },
+ onOpenSaved = { navController.navigate("articles/${encodeNavArg(SAVED_FEED_ID)}") },
+ onAddFeed = { navController.navigate("addFeed") },
+ onSettings = { navController.navigate("settings") }
+ )
+ }
+ composable(
+ "articles/{feedId}",
+ arguments = listOf(navArgument("feedId") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val feedId = decodeNavArg(backStackEntry.arguments?.getString("feedId") ?: UNREAD_FEED_ID)
+ ArticleListScreen(
+ feedId = feedId,
+ onOpenArticle = { articleId -> navController.navigate("reader/${encodeNavArg(articleId)}") },
+ onBack = { navController.popBackStack() }
+ )
+ }
+ composable(
+ "reader/{articleId}",
+ arguments = listOf(navArgument("articleId") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val articleId = decodeNavArg(backStackEntry.arguments?.getString("articleId") ?: "")
+ ReaderScreen(articleId = articleId, onBack = { navController.popBackStack() })
+ }
+ composable("addFeed") {
+ AddFeedScreen(onDone = { navController.popBackStack() })
+ }
+ composable("settings") {
+ SettingsScreen(onBack = { navController.popBackStack() })
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
new file mode 100644
index 0000000..8b320eb
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -0,0 +1,166 @@
+package org.terst.feedreader.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.webkit.WebView
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.BookmarkBorder
+import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.launch
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.Net
+import org.terst.feedreader.data.Settings
+import org.terst.feedreader.readability.ReaderExtractor
+import org.terst.feedreader.stash.StashClient
+import org.terst.feedreader.stash.StashSaveResult
+
+// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary,
+// not the real article body, and fall back to on-device Readability extraction.
+private const val THIN_CONTENT_THRESHOLD = 500
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReaderScreen(articleId: String, onBack: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ var article by remember { mutableStateOf<ArticleEntity?>(null) }
+ var displayContent by remember { mutableStateOf<String?>(null) }
+ var isExtracting by remember { mutableStateOf(false) }
+ var isSaving by remember { mutableStateOf(false) }
+
+ LaunchedEffect(articleId) {
+ val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
+ article = loaded
+ db.articleDao().setRead(articleId, true)
+
+ val feedContent = loaded.feedContentHtml
+ val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0
+
+ displayContent = when {
+ loaded.extractedContent != null -> loaded.extractedContent
+ plainLength >= THIN_CONTENT_THRESHOLD -> feedContent
+ else -> {
+ isExtracting = true
+ val extracted = ReaderExtractor.extract(context, loaded.link)
+ isExtracting = false
+ val content = extracted?.content
+ if (content != null) {
+ db.articleDao().setExtractedContent(articleId, content)
+ content
+ } else {
+ feedContent ?: "<p>Couldn't load article content. Try opening in browser.</p>"
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(article?.title.orEmpty(), maxLines = 1) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ val currentArticle = article
+ IconButton(onClick = {
+ val a = currentArticle ?: return@IconButton
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link))
+ context.startActivity(browserIntent)
+ }) {
+ Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
+ }
+ IconButton(
+ enabled = !isSaving,
+ onClick = {
+ val a = currentArticle ?: return@IconButton
+ isSaving = true
+ scope.launch {
+ val settings = Settings(context)
+ val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow())
+ when (val result = client.saveArticle(a.link, a.title)) {
+ is StashSaveResult.Saved -> {
+ db.articleDao().setSaved(a.id, true)
+ db.articleDao().setStashArtifactId(a.id, result.artifact.id)
+ snackbarHostState.showSnackbar("Saved to Stash")
+ }
+ is StashSaveResult.AlreadySaved -> {
+ db.articleDao().setSaved(a.id, true)
+ db.articleDao().setStashArtifactId(a.id, result.existingId)
+ snackbarHostState.showSnackbar("Already in Stash")
+ }
+ is StashSaveResult.Failed ->
+ snackbarHostState.showSnackbar("Save failed: ${result.message}")
+ }
+ isSaving = false
+ }
+ }
+ ) {
+ Icon(
+ if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
+ contentDescription = "Save to Stash"
+ )
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { padding ->
+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
+ val content = displayContent
+ val baseUrl = article?.link
+ if (content != null) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ WebView(ctx).apply {
+ settings.javaScriptEnabled = false
+ loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null)
+ }
+ },
+ update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) }
+ )
+ } else if (isExtracting) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ }
+ }
+}
+
+private fun wrapHtml(bodyContent: String): String = """
+ <html><head><meta name="viewport" content="width=device-width, initial-scale=1">
+ <style>body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; }
+ img { max-width: 100%; height: auto; }</style></head>
+ <body>$bodyContent</body></html>
+""".trimIndent()
diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
new file mode 100644
index 0000000..a5d3f9c
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt
@@ -0,0 +1,162 @@
+package org.terst.feedreader.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.FilterAction
+import org.terst.feedreader.data.FilterRuleEntity
+import org.terst.feedreader.data.Settings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ val settings = remember { Settings(context) }
+ val scope = rememberCoroutineScope()
+
+ var stashUrl by remember { mutableStateOf("") }
+ var stashKey by remember { mutableStateOf("") }
+ LaunchedEffect(Unit) {
+ stashUrl = settings.stashBaseUrlNow()
+ stashKey = settings.stashApiKeyNow()
+ }
+
+ var newRulePattern by remember { mutableStateOf("") }
+ var newRuleAction by remember { mutableStateOf(FilterAction.MUTE) }
+ var actionMenuExpanded by remember { mutableStateOf(false) }
+ val rules by db.filterRuleDao().observeAll().collectAsState(initial = emptyList())
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Settings") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) {
+ item {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Stash", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = stashUrl,
+ onValueChange = { stashUrl = it },
+ label = { Text("Base URL") },
+ placeholder = { Text("https://stash.example.com") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = stashKey,
+ onValueChange = { stashKey = it },
+ label = { Text("API key") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = { scope.launch { settings.setStash(stashUrl, stashKey) } },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Save") }
+ }
+ HorizontalDivider()
+ }
+
+ item {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text("Filter rules", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(modifier = Modifier.fillMaxWidth()) {
+ OutlinedTextField(
+ value = newRulePattern,
+ onValueChange = { newRulePattern = it },
+ label = { Text("Keyword") },
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Box {
+ Button(onClick = { actionMenuExpanded = true }) {
+ Text(if (newRuleAction == FilterAction.MUTE) "Mute" else "Highlight")
+ }
+ DropdownMenu(expanded = actionMenuExpanded, onDismissRequest = { actionMenuExpanded = false }) {
+ DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false })
+ DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false })
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ enabled = newRulePattern.isNotBlank(),
+ onClick = {
+ scope.launch {
+ db.filterRuleDao().insert(
+ FilterRuleEntity(
+ pattern = newRulePattern.trim(),
+ action = newRuleAction,
+ createdAt = System.currentTimeMillis()
+ )
+ )
+ newRulePattern = ""
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) { Text("Add rule") }
+ }
+ }
+
+ items(rules, key = { it.id }) { rule ->
+ ListItem(
+ headlineContent = { Text(rule.pattern) },
+ supportingContent = { Text(if (rule.action == FilterAction.MUTE) "Mute" else "Highlight") },
+ trailingContent = {
+ IconButton(onClick = { scope.launch { db.filterRuleDao().delete(rule.id) } }) {
+ Icon(Icons.Default.Delete, contentDescription = "Delete rule")
+ }
+ }
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt
new file mode 100644
index 0000000..656bebc
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt
@@ -0,0 +1,113 @@
+package org.terst.feedreader.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.Net
+import org.terst.feedreader.data.Settings
+import org.terst.feedreader.readability.ReaderExtractor
+import org.terst.feedreader.stash.StashClient
+import org.terst.feedreader.stash.StashSaveResult
+import java.security.MessageDigest
+
+private val URL_REGEX = Regex("""https?://\S+""")
+
+/**
+ * Share-sheet target: a URL shared from any other app lands here. Extracts
+ * the article on-device (so it's readable offline immediately, independent
+ * of Stash), stores it locally as a standalone saved article (feedId=null,
+ * not tied to any subscription), and best-effort archives it to Stash.
+ */
+class ShareReceiverActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val sharedText = intent?.getStringExtra(android.content.Intent.EXTRA_TEXT).orEmpty()
+ val url = URL_REGEX.find(sharedText)?.value
+
+ setContent {
+ MaterialTheme {
+ ShareReceiverContent(url = url, onDone = { finish() })
+ }
+ }
+ }
+}
+
+@Composable
+private fun ShareReceiverContent(url: String?, onDone: () -> Unit) {
+ val context = LocalContext.current
+ var status by remember { mutableStateOf(if (url == null) "No link found in shared content" else "Saving…") }
+ var done by remember { mutableStateOf(url == null) }
+
+ LaunchedEffect(url) {
+ if (url == null) return@LaunchedEffect
+ val db = AppDatabase.get(context)
+
+ val extracted = ReaderExtractor.extract(context, url)
+ val id = MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) }
+ db.articleDao().upsert(
+ ArticleEntity(
+ id = id,
+ feedId = null,
+ title = extracted?.title ?: url,
+ link = url,
+ extractedContent = extracted?.content,
+ isSaved = true,
+ createdAt = System.currentTimeMillis()
+ )
+ )
+
+ val settings = Settings(context)
+ val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow())
+ status = when (val result = client.saveArticle(url, extracted?.title)) {
+ is StashSaveResult.Saved -> {
+ db.articleDao().setStashArtifactId(id, result.artifact.id)
+ "Saved for offline reading and archived to Stash"
+ }
+ is StashSaveResult.AlreadySaved -> {
+ db.articleDao().setStashArtifactId(id, result.existingId)
+ "Saved for offline reading (already in Stash)"
+ }
+ is StashSaveResult.Failed ->
+ "Saved for offline reading. Stash archive failed: ${result.message}"
+ }
+ done = true
+ }
+
+ Column(modifier = Modifier.padding(24.dp)) {
+ Card(modifier = Modifier.padding(8.dp)) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text("Feeds", style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(status)
+ if (!done) {
+ Spacer(modifier = Modifier.height(8.dp))
+ CircularProgressIndicator()
+ } else {
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = onDone) { Text("Done") }
+ }
+ }
+ }
+ }
+}
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
+ )
+ }
+ }
+}