summaryrefslogtreecommitdiff
path: root/app/src/main
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-08-22 01:13:30 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-08-22 01:13:30 +0000
commit55cdfc8fab6ddd1052593f8fd53dbb5bf730cc63 (patch)
tree2c44a9e63e89dd925c98b0606c5c1e6e29f48d10 /app/src/main
parenta5f728648dda46e84959a4568a3f04d5cc7bb11f (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')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/AppDatabase.kt14
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt39
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Entities.kt1
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt21
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt15
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<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
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<String?>(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")
}
}