diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-08-22 01:13:30 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-08-22 01:13:30 +0000 |
| commit | 55cdfc8fab6ddd1052593f8fd53dbb5bf730cc63 (patch) | |
| tree | 2c44a9e63e89dd925c98b0606c5c1e6e29f48d10 /app/src/main/java/org/terst/feedreader/data | |
| parent | a5f728648dda46e84959a4568a3f04d5cc7bb11f (diff) | |
Keep a just-read article visible (and Previous-reachable) until the next fetch
The Unread/Older views are live Room Flow queries filtered on
isRead = 0, so marking an article read (which only happens on
Next/Previous, not on open) makes it vanish from the list the instant
Room re-emits -- mid-scroll, or from underneath the reader's own
Previous button, which re-queries this same flow to find its
neighbors. Concretely: Next from C to D marks C read and prunes it
from the live list; Previous from D then can't find C anymore, and
jumps past it to whatever was unread before C. A misclick or the
in-flight prune could cost you the article you meant to keep reading.
Adds ArticleEntity.readAt (set whenever isRead flips true, cleared on
explicit "mark unread"); observeUnread/observeOlderUnread now match
"isRead = 0 OR readAt >= sinceFetch", where sinceFetch is
Settings.lastFetchAt -- the same durable signal the new-articles
snackbar already uses. A read article now lingers (shown dimmed via
the existing showReadState styling) until the next real fetch
actually completes, not the next DB write, and Previous/Next read from
the same still-inclusive query so stepping back to what you just read
finds it right where it was.
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/data')
3 files changed, 39 insertions, 15 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 7f33439..2fd5816 100644 --- a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt @@ -12,7 +12,7 @@ private const val TAG = "FeedMigration" @Database( entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class], - version = 9, + version = 10, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -258,6 +258,16 @@ abstract class AppDatabase : RoomDatabase() { } } + // v10: added ArticleEntity.readAt (epoch millis, null while unread). Plain nullable + // ADD COLUMN is safe here the way v2/v3's were -- no column is being removed or + // renamed, so there's nothing for Room's exact-match post-migration validation to + // trip on (see v4's comment above for what DOES require a full rebuild). + private val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE articles ADD COLUMN readAt INTEGER") + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, @@ -265,7 +275,7 @@ abstract class AppDatabase : RoomDatabase() { "feedreader.db" ).addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, - MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9 + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10 ).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 ecaac43..20632f8 100644 --- a/app/src/main/java/org/terst/feedreader/data/Daos.kt +++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt @@ -107,21 +107,30 @@ interface ArticleDao { // MainActivity) so a backlog of hundreds of older unread items doesn't bury // what's actually new. Undated articles (publishedAt failed to parse) are kept // here rather than silently dropped from both views. + // + // "isRead = 0 OR readAt >= sinceFetch" (not just "isRead = 0"): a just-read article + // stays in this view until the NEXT fetch completes, not the instant it's marked + // read. Without this, Room's live Flow re-emits the moment isRead flips, so (a) a + // read row vanishes out from under you mid-scroll, and (b) the reader's Previous + // button -- which re-queries this same flow to find its neighbors -- can no longer + // find the article you just came from, since Next already marked it read a moment + // earlier. sinceFetch is Settings.lastFetchAt, the same durable "when did a fetch + // last finish" signal the new-articles snackbar already uses. @Query( "SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM " + - "WHERE articles.isRead = 0 AND (articles.publishedAt IS NULL OR articles.publishedAt >= :cutoff) " + + "WHERE (articles.isRead = 0 OR articles.readAt >= :sinceFetch) AND (articles.publishedAt IS NULL OR articles.publishedAt >= :cutoff) " + "ORDER BY articles.publishedAt DESC" ) - fun observeUnread(cutoff: Long): Flow<List<ArticleListItem>> + fun observeUnread(cutoff: Long, sinceFetch: Long): Flow<List<ArticleListItem>> // The rest of the unread backlog, older than the cutoff -- its own view rather - // than mixed into the main one. + // than mixed into the main one. See observeUnread for why sinceFetch is here. @Query( "SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM " + - "WHERE articles.isRead = 0 AND articles.publishedAt IS NOT NULL AND articles.publishedAt < :cutoff " + + "WHERE (articles.isRead = 0 OR articles.readAt >= :sinceFetch) AND articles.publishedAt IS NOT NULL AND articles.publishedAt < :cutoff " + "ORDER BY articles.publishedAt DESC" ) - fun observeOlderUnread(cutoff: Long): Flow<List<ArticleListItem>> + fun observeOlderUnread(cutoff: Long, sinceFetch: Long): Flow<List<ArticleListItem>> @Query("SELECT COUNT(*) FROM articles WHERE isRead = 0 AND (publishedAt IS NULL OR publishedAt >= :cutoff)") fun observeUnreadCount(cutoff: Long): Flow<Int> @@ -157,16 +166,20 @@ interface ArticleDao { @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) + // readAt is caller-supplied (not CURRENT_TIMESTAMP in SQL) so it's directly comparable + // to Settings.lastFetchAt, which is also a Kotlin System.currentTimeMillis() epoch + // value, not a SQLite datetime string. Pass null when read=false (explicit "mark + // unread") so a stale readAt can't linger on a row that's unread again. + @Query("UPDATE articles SET isRead = :read, readAt = :readAt WHERE id = :id") + suspend fun setRead(id: String, read: Boolean, readAt: Long?) // Explicit bulk action ("mark read from here") -- batched per confirmed tap, // never called implicitly (e.g. by scrolling). - @Query("UPDATE articles SET isRead = 1 WHERE id IN (:ids)") - suspend fun markReadBatch(ids: List<String>) + @Query("UPDATE articles SET isRead = 1, readAt = :readAt WHERE id IN (:ids)") + suspend fun markReadBatch(ids: List<String>, readAt: Long) - @Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId") - suspend fun markAllReadForFeed(feedId: String) + @Query("UPDATE articles SET isRead = 1, readAt = :readAt WHERE feedId = :feedId") + suspend fun markAllReadForFeed(feedId: String, readAt: Long) // Run when unsubscribing from a feed. Saved articles are deliberately kept // regardless (isSaved is a separate, durable "keep forever" concept, @@ -196,8 +209,8 @@ interface ArticleDao { // and SQLite has a hard per-statement limit on bound variables (historically 999 on many // Android SQLite builds). Chunking well under any realistic limit means this can't throw // "too many SQL variables" and crash on an otherwise routine bulk mark-read tap. -suspend fun ArticleDao.markReadBatchChunked(ids: List<String>) { - ids.chunked(500).forEach { chunk -> markReadBatch(chunk) } +suspend fun ArticleDao.markReadBatchChunked(ids: List<String>, readAt: Long = System.currentTimeMillis()) { + ids.chunked(500).forEach { chunk -> markReadBatch(chunk, readAt) } } @Dao 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 5f6a28c..58c9f79 100644 --- a/app/src/main/java/org/terst/feedreader/data/Entities.kt +++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt @@ -31,6 +31,7 @@ data class ArticleEntity( 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 readAt: Long? = null, // when isRead flipped true; null while unread or after an explicit "mark unread". Lets a just-read article linger in the Unread view until the next fetch instead of vanishing the instant it's read -- see observeUnread/observeOlderUnread. 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 |
