summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-22 16:54:03 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 16:54:03 +0000
commit39cca1cb3650edb066f57d0891b1873b767b1c43 (patch)
tree0a966be07f4d4440cfad0f4eeb8bffd1617f3ea0 /app
parent52d7877fccd60a1dae33d877ba6aa765dc098a52 (diff)
Fix Unread/startup crash; add reader-mode toggle and swipe navigation
- Root cause of the reported crashes: observeUnread/observeByFeed/ observeSaved used SELECT *, pulling feedContentHtml/extractedContent (potentially large per-article blobs) just to render a list row. With 107 feeds worth of real articles this blows past Android's CursorWindow/memory limits, scaling with item count -- and since Unread is now the landing screen, it crashed at startup too. Added an ArticleListItem projection excluding the heavy content columns; list queries now only select what a row actually renders. - FilterRuleEngine now takes a small Titled interface (title/feedId) instead of the full entity, so it works with both ArticleEntity and the new lighter projection. - Reader-mode toggle: a button in the reader to manually switch between feed-provided content and Readability-extracted full text, matching Inoreader's full-text-view toggle (previously only an automatic fallback when feed content was thin). - Swipe between articles: ReaderScreen now wraps a HorizontalPager over a snapshot of the current list's article-id order, so swiping moves to the next/previous article. Snapshotted rather than live, since marking an article read would otherwise drop it from the live Unread query mid-swipe and shift every later page underneath the user. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt35
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Entities.kt6
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt12
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt4
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt16
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt126
6 files changed, 161 insertions, 38 deletions
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 2240ff4..f80cce3 100644
--- a/app/src/main/java/org/terst/feedreader/data/Daos.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt
@@ -31,20 +31,43 @@ interface FeedDao {
data class FeedUnreadCount(val feedId: String, val count: Int)
+// A projection excluding feedContentHtml/extractedContent -- the potentially
+// large per-article content blobs that list views (row title/date/thumbnail)
+// never need. Selecting them for every row of a big result set is what was
+// crashing the Unread view once real feed volume (107 feeds worth of
+// articles) hit it: Android's per-row/CursorWindow limits are sized for
+// "many small rows," not "many rows each carrying a full article's HTML."
+data class ArticleListItem(
+ val id: String,
+ override val feedId: String?,
+ override val title: String,
+ val link: String,
+ val author: String?,
+ val publishedAt: Long?,
+ val imageUrl: String?,
+ val isRead: Boolean,
+ val isSaved: Boolean,
+ val stashArtifactId: String?,
+ val createdAt: Long
+) : Titled
+
+private const val ARTICLE_LIST_COLUMNS =
+ "id, feedId, title, link, author, publishedAt, imageUrl, isRead, isSaved, stashArtifactId, createdAt"
+
@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 $ARTICLE_LIST_COLUMNS FROM articles WHERE isRead = 0 ORDER BY publishedAt DESC")
+ fun observeUnread(): Flow<List<ArticleListItem>>
@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 $ARTICLE_LIST_COLUMNS FROM articles WHERE feedId = :feedId ORDER BY publishedAt DESC")
+ fun observeByFeed(feedId: String): Flow<List<ArticleListItem>>
- @Query("SELECT * FROM articles WHERE isSaved = 1 ORDER BY createdAt DESC")
- fun observeSaved(): Flow<List<ArticleEntity>>
+ @Query("SELECT $ARTICLE_LIST_COLUMNS FROM articles WHERE isSaved = 1 ORDER BY createdAt DESC")
+ fun observeSaved(): Flow<List<ArticleListItem>>
@Query("SELECT * FROM articles WHERE id = :id")
suspend fun getById(id: String): ArticleEntity?
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 190340c..29d4fe4 100644
--- a/app/src/main/java/org/terst/feedreader/data/Entities.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt
@@ -18,8 +18,8 @@ data class FeedEntity(
@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,
+ @ColumnInfo(index = true) override val feedId: String?, // null for a standalone article saved via the share-sheet target, not tied to any feed
+ override val title: String,
val link: String,
val author: String? = null,
val publishedAt: Long? = null,
@@ -30,7 +30,7 @@ data class ArticleEntity(
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
-)
+) : Titled
enum class FilterAction { MUTE, HIGHLIGHT }
diff --git a/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt
index c32e8e9..f095322 100644
--- a/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt
+++ b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt
@@ -4,6 +4,16 @@ package org.terst.feedreader.data
enum class FilterResult { NORMAL, HIGHLIGHTED, MUTED }
/**
+ * The two fields filter rules actually need -- implemented by both
+ * ArticleEntity (full row) and ArticleListItem (list-view projection) so
+ * FilterRuleEngine works with either without depending on the heavy type.
+ */
+interface Titled {
+ val title: String
+ val feedId: String?
+}
+
+/**
* 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
@@ -11,7 +21,7 @@ enum class FilterResult { NORMAL, HIGHLIGHTED, MUTED }
* over drawing attention).
*/
object FilterRuleEngine {
- fun evaluate(article: ArticleEntity, rules: List<FilterRuleEntity>): FilterResult {
+ fun evaluate(article: Titled, rules: List<FilterRuleEntity>): FilterResult {
val applicable = rules.filter { it.feedId == null || it.feedId == article.feedId }
val title = article.title.lowercase()
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 22130f9..62b90c9 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
@@ -34,7 +34,7 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import kotlinx.coroutines.flow.combine
import org.terst.feedreader.data.AppDatabase
-import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.ArticleListItem
import org.terst.feedreader.data.FilterResult
import org.terst.feedreader.data.FilterRuleEngine
import java.text.DateFormat
@@ -107,7 +107,7 @@ fun ArticleListScreen(
}
@Composable
-private fun ArticleRow(article: ArticleEntity, highlighted: Boolean, onClick: () -> Unit) {
+private fun ArticleRow(article: ArticleListItem, highlighted: Boolean, onClick: () -> Unit) {
val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) }
ListItem(
// Passed as null (not an empty/conditional lambda) when there's no image, so
diff --git a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
index c080d89..e1300f0 100644
--- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
@@ -63,17 +63,25 @@ fun FeedReaderApp() {
val feedId = decodeNavArg(backStackEntry.arguments?.getString("feedId") ?: UNREAD_FEED_ID)
ArticleListScreen(
feedId = feedId,
- onOpenArticle = { articleId -> navController.navigate("reader/${encodeNavArg(articleId)}") },
+ onOpenArticle = { articleId ->
+ navController.navigate("reader/${encodeNavArg(feedId)}/${encodeNavArg(articleId)}")
+ },
onBack = { navController.popBackStack() },
onOpenFeedList = { navController.navigate("feeds") }
)
}
composable(
- "reader/{articleId}",
- arguments = listOf(navArgument("articleId") { type = NavType.StringType })
+ "reader/{feedId}/{articleId}",
+ arguments = listOf(
+ navArgument("feedId") { type = NavType.StringType },
+ navArgument("articleId") { type = NavType.StringType }
+ )
) { backStackEntry ->
+ val feedId = decodeNavArg(backStackEntry.arguments?.getString("feedId") ?: UNREAD_FEED_ID)
val articleId = decodeNavArg(backStackEntry.arguments?.getString("articleId") ?: "")
- ReaderScreen(articleId = articleId, onBack = { navController.popBackStack() })
+ // feedId here is the list the article was opened from (Unread/Saved/a specific
+ // feed) -- ReaderScreen uses it to build the swipe queue of sibling articles.
+ ReaderScreen(feedId = feedId, initialArticleId = articleId, onBack = { navController.popBackStack() })
}
composable("addFeed") {
AddFeedScreen(onDone = { navController.popBackStack() })
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 8b320eb..692c198 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -3,14 +3,19 @@ package org.terst.feedreader.ui
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material.icons.outlined.Article
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -31,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.ArticleEntity
@@ -41,44 +47,109 @@ import org.terst.feedreader.stash.StashClient
import org.terst.feedreader.stash.StashSaveResult
// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary,
-// not the real article body, and fall back to on-device Readability extraction.
+// not the real article body, and auto-fall-back to on-device Readability extraction.
private const val THIN_CONTENT_THRESHOLD = 500
+/**
+ * Swipeable wrapper: snapshots the article-id order of the list this reader
+ * was opened from (Unread/Saved/a specific feed) ONCE at entry, then pages
+ * across that fixed queue. Deliberately a snapshot, not a live Flow -- opening
+ * an article marks it read, which would otherwise drop it out of the live
+ * Unread query mid-swipe and shift every subsequent page underneath the user.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) {
+ val context = LocalContext.current
+ val db = remember { AppDatabase.get(context) }
+ var articleIds by remember(feedId) { mutableStateOf<List<String>?>(null) }
+
+ LaunchedEffect(feedId) {
+ val flow = when (feedId) {
+ UNREAD_FEED_ID -> db.articleDao().observeUnread()
+ SAVED_FEED_ID -> db.articleDao().observeSaved()
+ else -> db.articleDao().observeByFeed(feedId)
+ }
+ articleIds = flow.first().map { it.id }
+ }
+
+ val ids = articleIds
+ if (ids == null) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ return
+ }
+ // The article may have been opened from somewhere not in this queue (shouldn't normally
+ // happen, but fall back to a single-item queue rather than crashing on indexOfFirst = -1).
+ val safeIds = if (ids.contains(initialArticleId)) ids else listOf(initialArticleId)
+ val initialIndex = safeIds.indexOf(initialArticleId).coerceAtLeast(0)
+ val pagerState = rememberPagerState(initialPage = initialIndex) { safeIds.size }
+
+ HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
+ ReaderContent(articleId = safeIds[page], onBack = onBack)
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun ReaderScreen(articleId: String, onBack: () -> Unit) {
+private fun ReaderContent(articleId: String, onBack: () -> Unit) {
val context = LocalContext.current
val db = remember { AppDatabase.get(context) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
- var article by remember { mutableStateOf<ArticleEntity?>(null) }
- var displayContent by remember { mutableStateOf<String?>(null) }
- var isExtracting by remember { mutableStateOf(false) }
- var isSaving by remember { mutableStateOf(false) }
+ var article by remember(articleId) { mutableStateOf<ArticleEntity?>(null) }
+ var feedContent by remember(articleId) { mutableStateOf<String?>(null) }
+ var readerContent by remember(articleId) { mutableStateOf<String?>(null) }
+ var isReaderMode by remember(articleId) { mutableStateOf(false) }
+ var isExtracting by remember(articleId) { mutableStateOf(false) }
+ var isSaving by remember(articleId) { mutableStateOf(false) }
LaunchedEffect(articleId) {
val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
article = loaded
db.articleDao().setRead(articleId, true)
- val feedContent = loaded.feedContentHtml
+ feedContent = loaded.feedContentHtml
+ readerContent = loaded.extractedContent
val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0
- displayContent = when {
- loaded.extractedContent != null -> loaded.extractedContent
- plainLength >= THIN_CONTENT_THRESHOLD -> feedContent
- else -> {
- isExtracting = true
- val extracted = ReaderExtractor.extract(context, loaded.link)
- isExtracting = false
- val content = extracted?.content
- if (content != null) {
- db.articleDao().setExtractedContent(articleId, content)
- content
- } else {
- feedContent ?: "<p>Couldn't load article content. Try opening in browser.</p>"
- }
+ if (readerContent == null && plainLength < THIN_CONTENT_THRESHOLD) {
+ // Feed content is too thin to be the real article -- auto-extract, since in this
+ // case reader mode isn't really an alternative view, it's the only real content.
+ isExtracting = true
+ val extracted = ReaderExtractor.extract(context, loaded.link)
+ isExtracting = false
+ extracted?.content?.let {
+ readerContent = it
+ db.articleDao().setExtractedContent(articleId, it)
+ }
+ isReaderMode = true
+ }
+ }
+
+ fun toggleReaderMode() {
+ val a = article ?: return
+ if (isReaderMode) {
+ isReaderMode = false
+ return
+ }
+ if (readerContent != null) {
+ isReaderMode = true
+ return
+ }
+ scope.launch {
+ isExtracting = true
+ val extracted = ReaderExtractor.extract(context, a.link)
+ isExtracting = false
+ val content = extracted?.content
+ if (content != null) {
+ readerContent = content
+ db.articleDao().setExtractedContent(articleId, content)
+ isReaderMode = true
+ } else {
+ snackbarHostState.showSnackbar("Couldn't extract a reader view for this article")
}
}
}
@@ -94,6 +165,17 @@ fun ReaderScreen(articleId: String, onBack: () -> Unit) {
},
actions = {
val currentArticle = article
+ // Only offer the toggle when there's an actual feed-provided alternative to
+ // switch back to -- if we already had to auto-extract (thin feed content),
+ // reader mode IS the only content, so there's nothing to toggle between.
+ if (feedContent != null) {
+ IconButton(enabled = !isExtracting, onClick = { toggleReaderMode() }) {
+ Icon(
+ if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article,
+ contentDescription = "Toggle reader view"
+ )
+ }
+ }
IconButton(onClick = {
val a = currentArticle ?: return@IconButton
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link))
@@ -138,7 +220,7 @@ fun ReaderScreen(articleId: String, onBack: () -> Unit) {
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
- val content = displayContent
+ val content = if (isReaderMode) readerContent else (feedContent ?: readerContent)
val baseUrl = article?.link
if (content != null) {
AndroidView(