diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 16:26:45 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 16:26:45 +0000 |
| commit | 52d7877fccd60a1dae33d877ba6aa765dc098a52 (patch) | |
| tree | dd23bf9c42fd59dfb475759d8de969216e7b49e0 /app/src/main/java/org/terst/feedreader/data | |
| parent | 2c04d2dbef2e36025a4b795291117ad208fba411 (diff) | |
First round of testing feedback: images, unread landing, dark, delete
- Fix OPML import status message reporting the raw pre-dedup count
(144) instead of the actual unique feed count after Room's upsert
collapses duplicates (108) -- cosmetic bug, the DB was already
correct.
- Article thumbnails: extract media:thumbnail/enclosure or the first
<img> in feed content, show via Coil in the article list.
- App now lands on Unread instead of the feed list; a top-bar icon
gets from there to the feed list (forward nav, not back-stack pop,
since Unread is now the root).
- Dark color scheme by default.
- Delete-feed action (with confirmation) on each feed row -- was
entirely missing. Deletes its non-saved articles too, so they don't
linger as orphaned rows in the cross-feed Unread view; saved articles
are kept regardless.
- Room migration 1->2 for the new imageUrl column (real migration, not
destructive fallback, since there's now real on-device subscription
data from testing).
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')
4 files changed, 59 insertions, 7 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 index 8579341..e2f6c1a 100644 --- a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt @@ -4,10 +4,12 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.room.migration.Migration @Database( entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class], - version = 1, + version = 2, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -18,12 +20,23 @@ abstract class AppDatabase : RoomDatabase() { companion object { @Volatile private var instance: AppDatabase? = null + // v2: added ArticleEntity.imageUrl (thumbnail support). Real migrations, + // not fallbackToDestructiveMigration, since this app already has real + // subscriptions/read-state on-device during active development -- + // wiping the DB on every schema tweak would mean re-importing OPML + // and losing read history each time. + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE articles ADD COLUMN imageUrl TEXT") + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "feedreader.db" - ).build().also { instance = it } + ).addMigrations(MIGRATION_1_2).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 index 0a41f0c..2240ff4 100644 --- a/app/src/main/java/org/terst/feedreader/data/Daos.kt +++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt @@ -64,6 +64,13 @@ interface ArticleDao { @Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId") suspend fun markAllReadForFeed(feedId: String) + // Run when unsubscribing from a feed. Saved articles are deliberately kept + // regardless (isSaved is a separate, durable "keep forever" concept, + // independent of subscription) -- everything else is deleted so it doesn't + // linger forever as an orphaned row cluttering the cross-feed Unread view. + @Query("DELETE FROM articles WHERE feedId = :feedId AND isSaved = 0") + suspend fun deleteUnsavedByFeed(feedId: String) + @Query("UPDATE articles SET extractedContent = :content WHERE id = :id") suspend fun setExtractedContent(id: String, content: String) diff --git a/app/src/main/java/org/terst/feedreader/data/Entities.kt b/app/src/main/java/org/terst/feedreader/data/Entities.kt index d71e1eb..190340c 100644 --- a/app/src/main/java/org/terst/feedreader/data/Entities.kt +++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt @@ -25,6 +25,7 @@ data class ArticleEntity( 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 imageUrl: String? = null, // thumbnail, from media:thumbnail/enclosure or the first <img> in the feed content -- see FeedParser 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 diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt index cd337d6..735a707 100644 --- a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt +++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt @@ -16,7 +16,8 @@ data class ParsedItem( val link: String, val author: String?, val publishedAt: Long?, - val contentHtml: String? + val contentHtml: String?, + val imageUrl: String? = null ) data class ParsedFeed( @@ -65,6 +66,7 @@ object FeedParser { var itemDate: Long? = null var itemContent: String? = null var itemDescription: String? = null + var itemImageUrl: String? = null while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { when (eventType) { @@ -74,7 +76,7 @@ object FeedParser { "item" -> { inItem = true itemGuid = null; itemTitle = ""; itemLink = ""; itemAuthor = null - itemDate = null; itemContent = null; itemDescription = null + itemDate = null; itemContent = null; itemDescription = null; itemImageUrl = null } "title" -> { val text = parser.nextText() @@ -90,19 +92,31 @@ object FeedParser { "pubdate" -> if (inItem) itemDate = parseRfc822Date(parser.nextText()) "content:encoded" -> if (inItem) itemContent = parser.nextText() "description" -> if (inItem) itemDescription = parser.nextText() + "media:thumbnail", "media:content" -> if (inItem && itemImageUrl == null) { + val url = parser.getAttributeValue(null, "url") + val type = parser.getAttributeValue(null, "type") + if (url != null && (type == null || type.startsWith("image/"))) itemImageUrl = url + } + "enclosure" -> if (inItem && itemImageUrl == null) { + val url = parser.getAttributeValue(null, "url") + val type = parser.getAttributeValue(null, "type") + if (url != null && type?.startsWith("image/") == true) itemImageUrl = url + } } } XmlPullParser.END_TAG -> { depth-- if (parser.name.lowercase() == "item") { inItem = false + val content = itemContent ?: itemDescription items += ParsedItem( guid = itemGuid ?: itemLink.ifBlank { null }, title = itemTitle, link = itemLink, author = itemAuthor, publishedAt = itemDate, - contentHtml = itemContent ?: itemDescription + contentHtml = content, + imageUrl = itemImageUrl ?: extractFirstImageUrl(content) ) } } @@ -127,6 +141,7 @@ object FeedParser { var entryDate: Long? = null var entryContent: String? = null var entrySummary: String? = null + var entryImageUrl: String? = null while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { when (eventType) { @@ -136,7 +151,7 @@ object FeedParser { "entry" -> { inEntry = true entryId = null; entryTitle = ""; entryLink = ""; entryAuthor = null - entryDate = null; entryContent = null; entrySummary = null + entryDate = null; entryContent = null; entrySummary = null; entryImageUrl = null } "title" -> { val text = parser.nextText() @@ -145,9 +160,13 @@ object FeedParser { "link" -> { val rel = parser.getAttributeValue(null, "rel") val href = parser.getAttributeValue(null, "href") + val type = parser.getAttributeValue(null, "type") if (href != null && (rel == null || rel == "alternate")) { if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href } + if (inEntry && rel == "enclosure" && type?.startsWith("image/") == true && href != null) { + entryImageUrl = href + } } "id" -> if (inEntry) entryId = parser.nextText() "name" -> if (inEntry && entryAuthor == null) entryAuthor = parser.nextText() @@ -155,19 +174,24 @@ object FeedParser { "published" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) ?: entryDate "content" -> if (inEntry) entryContent = parser.nextText() "summary" -> if (inEntry) entrySummary = parser.nextText() + "media:thumbnail" -> if (inEntry && entryImageUrl == null) { + entryImageUrl = parser.getAttributeValue(null, "url") + } } } XmlPullParser.END_TAG -> { depth-- if (parser.name.lowercase() == "entry") { inEntry = false + val content = entryContent ?: entrySummary items += ParsedItem( guid = entryId ?: entryLink.ifBlank { null }, title = entryTitle, link = entryLink, author = entryAuthor, publishedAt = entryDate, - contentHtml = entryContent ?: entrySummary + contentHtml = content, + imageUrl = entryImageUrl ?: extractFirstImageUrl(content) ) } } @@ -177,6 +201,13 @@ object FeedParser { return ParsedFeed(feedTitle, siteUrl, items) } + private val imgTagRegex = Regex("""<img[^>]+src=["']([^"']+)["']""", RegexOption.IGNORE_CASE) + + // Fallback for feeds with no explicit media:thumbnail/enclosure image -- + // most blog feeds just embed the post's lead image directly in the content. + private fun extractFirstImageUrl(html: String?): String? = + html?.let { imgTagRegex.find(it)?.groupValues?.get(1) } + private val rfc822Formatters = listOf( DateTimeFormatter.RFC_1123_DATE_TIME, DateTimeFormatterBuilder() |
