diff options
Diffstat (limited to 'app/src')
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( |
