summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/data
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/data
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/data')
-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
7 files changed, 438 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()
+ }
+ }
+}