From 55cdfc8fab6ddd1052593f8fd53dbb5bf730cc63 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 22 Aug 2026 01:13:30 +0000 Subject: 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. --- .../java/org/terst/feedreader/data/AppDatabase.kt | 14 ++++++-- .../main/java/org/terst/feedreader/data/Daos.kt | 39 ++++++++++++++-------- .../java/org/terst/feedreader/data/Entities.kt | 1 + .../org/terst/feedreader/ui/ArticleListScreen.kt | 21 +++++++++--- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 15 ++++++--- 5 files changed, 65 insertions(+), 25 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> + fun observeUnread(cutoff: Long, sinceFetch: Long): Flow> // 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> + fun observeOlderUnread(cutoff: Long, sinceFetch: Long): Flow> @Query("SELECT COUNT(*) FROM articles WHERE isRead = 0 AND (publishedAt IS NULL OR publishedAt >= :cutoff)") fun observeUnreadCount(cutoff: Long): Flow @@ -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) + @Query("UPDATE articles SET isRead = 1, readAt = :readAt WHERE id IN (:ids)") + suspend fun markReadBatch(ids: List, 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) { - ids.chunked(500).forEach { chunk -> markReadBatch(chunk) } +suspend fun ArticleDao.markReadBatchChunked(ids: List, 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 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 diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt index 9d88374..a3edebe 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -43,8 +43,10 @@ import androidx.compose.ui.unit.dp import androidx.work.WorkInfo import androidx.work.WorkManager import coil.compose.AsyncImage +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleListItem @@ -58,7 +60,7 @@ import java.util.Date private const val TAG = "ArticleListScreen" -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class) @Composable fun ArticleListScreen( feedId: String, @@ -70,11 +72,16 @@ fun ArticleListScreen( val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() val cutoff = remember(feedId) { System.currentTimeMillis() - RECENT_WINDOW_MS } + val settings = remember { Settings(context) } + // Unread/Older re-derive their query every time lastFetchAt changes (flatMapLatest), + // not just once at screen entry -- a fetch completing while this screen is open should + // immediately drop whatever was read-but-lingering, same as if you'd navigated back in. + // See observeUnread's comment for why sinceFetch is here at all. val articleFlow = remember(feedId) { when (feedId) { - UNREAD_FEED_ID -> db.articleDao().observeUnread(cutoff) - OLDER_FEED_ID -> db.articleDao().observeOlderUnread(cutoff) + UNREAD_FEED_ID -> settings.lastFetchAt.flatMapLatest { since -> db.articleDao().observeUnread(cutoff, since) } + OLDER_FEED_ID -> settings.lastFetchAt.flatMapLatest { since -> db.articleDao().observeOlderUnread(cutoff, since) } SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } @@ -99,7 +106,6 @@ fun ArticleListScreen( val showFetchStatus = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID val snackbarHostState = remember { SnackbarHostState() } - val settings = remember { Settings(context) } val fetchWorkInfos by remember { WorkManager.getInstance(context).getWorkInfosByTagFlow(FeedFetchWorker.FETCH_WORK_TAG) // A pure status readout, not load-bearing for the article list itself -- an @@ -223,6 +229,11 @@ fun ArticleListScreen( } } +// Shared with ReaderWebView's wrapReaderHtml (the reader's own date header) so both +// places format a publishedAt timestamp identically. +fun absoluteDateText(timestamp: Long): String = + DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(timestamp)) + @OptIn(ExperimentalFoundationApi::class) @Composable private fun ArticleRow( @@ -233,7 +244,7 @@ private fun ArticleRow( onClick: () -> Unit, onMarkReadFrom: () -> Unit ) { - val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } + val dateText = article.publishedAt?.let { absoluteDateText(it) } val metaText = listOfNotNull(if (showFeedName) article.feedTitle else null, dateText) .joinToString(" ยท ") .ifEmpty { null } diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt index 4317073..4c74e5a 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -57,6 +57,7 @@ import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleEntity import org.terst.feedreader.data.FeedEntity +import org.terst.feedreader.data.Settings import org.terst.feedreader.data.ensureStandaloneArticle import org.terst.feedreader.data.saveArticleToStash import org.terst.feedreader.data.setReaderModeEnabled @@ -96,6 +97,7 @@ fun ReaderScreen( ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } + val settings = remember { Settings(context) } val scope = rememberCoroutineScope() var previousId by remember(articleId) { mutableStateOf(null) } @@ -104,11 +106,14 @@ fun ReaderScreen( // Looked up fresh every time articleId changes (i.e. every navigation), not cached -- // always reflects the CURRENT live Unread/Older/Saved/feed ordering, so there's no // stale-snapshot state to fall out of sync with anything, on rotation or otherwise. + // sinceFetch (Settings.lastFetchAt) is what keeps a just-read article from vanishing + // out of this same list before Previous/Next can find it -- see observeUnread's comment. LaunchedEffect(articleId) { val cutoff = System.currentTimeMillis() - RECENT_WINDOW_MS + val sinceFetch = settings.lastFetchAt.first() val flow = when (feedId) { - UNREAD_FEED_ID -> db.articleDao().observeUnread(cutoff) - OLDER_FEED_ID -> db.articleDao().observeOlderUnread(cutoff) + UNREAD_FEED_ID -> db.articleDao().observeUnread(cutoff, sinceFetch) + OLDER_FEED_ID -> db.articleDao().observeOlderUnread(cutoff, sinceFetch) SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } @@ -124,14 +129,14 @@ fun ReaderScreen( fun goToPreviousArticle() { val target = previousId ?: return scope.launch { - db.articleDao().setRead(articleId, true) + db.articleDao().setRead(articleId, true, System.currentTimeMillis()) onNavigateToArticle(target) } } fun goToNextArticle() { val target = nextId ?: return scope.launch { - db.articleDao().setRead(articleId, true) + db.articleDao().setRead(articleId, true, System.currentTimeMillis()) onNavigateToArticle(target) } } @@ -345,7 +350,7 @@ private fun ReaderContent( val a = currentArticle ?: return@DropdownMenuItem article = a.copy(isRead = false) scope.launch { - db.articleDao().setRead(a.id, false) + db.articleDao().setRead(a.id, false, null) snackbarHostState.showSnackbar("Marked unread") } } -- cgit v1.2.3