diff options
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/data')
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() + } + } +} |
