diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 18:26:14 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 18:26:14 +0000 |
| commit | 91edd1f5f29a3336fa9e3bd1762c70ea74e3e48d (patch) | |
| tree | 6ebe8615b61d174348a8467aa1fd7dbbfdb03a0a /app/src/main/java | |
| parent | 9da3fbcdef71e90d52045531c7c18faff02f2ff6 (diff) | |
Split save/Stash actions, follow links in-app via reader pipeline, tune scroll, fix title entities
- ReaderScreen: bookmark icon is now a purely local save (just flips isSaved,
no network) -- Stash archiving is a separate explicit action (new cloud
icon), since it's a heavier networked action the user should trigger on
purpose rather than have bundled into "save." Verified via direct DB
queries and (once I stopped misreading small screenshots) the icon
updates immediately in-session for both actions.
- New ReaderWebView.kt: shared WebView factory for the dark reader palette
and link handling, used by both ReaderScreen and the new LinkReaderScreen.
Links tapped inside an article's WebView now open in a lightweight
in-app reader (extracted via the same Readability pipeline, no DB
persistence) instead of falling through to the system browser --
recursive, so a chain of links stays in-app. Verified live: tapping a
link mid-article navigated to a real extracted page with a working back
stack.
- WebView scroll tuning: overscroll glow and scrollbars off, on both the
article reader and the new link reader -- aimed at the reported "still a
little choppy" scrolling on top of the previous reload-on-recompose fix.
- FeedParser: some feeds (kottke.org among them) double-encode titles --
the XML parser resolves outer XML escaping, but leftover HTML entities
like "“" were showing up literally in the title text. Titles are
now run through a second HTML-entity decode pass. Verified: a kottke
title that previously showed a literal “ now renders the actual
curly quote.
All changes verified live on the emulator against the real 107-feed dataset.
Diffstat (limited to 'app/src/main/java')
5 files changed, 211 insertions, 29 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt index 735a707..629afe6 100644 --- a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt +++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt @@ -1,5 +1,6 @@ package org.terst.feedreader.data +import android.text.Html import android.util.Xml import org.xmlpull.v1.XmlPullParser import java.io.InputStream @@ -33,6 +34,15 @@ data class ParsedFeed( */ object FeedParser { + // Some feeds (kottke.org among them) double-encode titles: the XML pull parser already + // resolves the outer XML escaping (& -> &), but what's left can itself still be + // HTML-entity-encoded text (e.g. a literal "“" or "’") that the feed author + // encoded as if for direct HTML embedding. Titles are shown as plain text (Compose Text, + // not a WebView), so that residual entity needs a second, explicit decode pass or it + // shows up on-screen literally as "“" instead of the curly quote it's meant to be. + private fun decodeTitleEntities(text: String): String = + Html.fromHtml(text, Html.FROM_HTML_MODE_LEGACY).toString() + fun parse(input: InputStream): ParsedFeed { val parser = Xml.newPullParser() parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) @@ -79,7 +89,7 @@ object FeedParser { itemDate = null; itemContent = null; itemDescription = null; itemImageUrl = null } "title" -> { - val text = parser.nextText() + val text = decodeTitleEntities(parser.nextText()) if (inItem) itemTitle = text else feedTitle = text } "link" -> { @@ -154,7 +164,7 @@ object FeedParser { entryDate = null; entryContent = null; entrySummary = null; entryImageUrl = null } "title" -> { - val text = parser.nextText() + val text = decodeTitleEntities(parser.nextText()) if (inEntry) entryTitle = text else feedTitle = text } "link" -> { diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt new file mode 100644 index 0000000..ff764c4 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt @@ -0,0 +1,112 @@ +package org.terst.feedreader.ui + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import org.terst.feedreader.readability.ReaderExtractor + +/** + * A lightweight, non-persisted reader view for links tapped inside an article's + * WebView -- extracts the linked page with the same Readability pipeline as the + * main reader, but doesn't touch the DB (it's not a subscribed article), and + * recursively re-opens itself for any further links tapped inside, so link-hopping + * stays inside the app's reader instead of dropping into a raw WebView or the + * system browser. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Unit) { + val context = LocalContext.current + var title by remember(url) { mutableStateOf<String?>(null) } + var content by remember(url) { mutableStateOf<String?>(null) } + var failed by remember(url) { mutableStateOf(false) } + + LaunchedEffect(url) { + val extracted = ReaderExtractor.extract(context, url) + val extractedContent = extracted?.content + if (extractedContent != null) { + title = extracted.title + content = extractedContent + } else { + failed = true + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text(title ?: Uri.parse(url).host ?: "Article", maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }) { + Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") + } + } + ) + } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + val currentContent = content + when { + currentContent != null -> { + val loadedContent = remember(url) { mutableStateOf<String?>(null) } + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + newReaderWebView(ctx, onOpenLink).apply { + loadDataWithBaseURL(url, wrapReaderHtml(currentContent), "text/html", "UTF-8", null) + loadedContent.value = currentContent + } + }, + update = { webView -> + if (loadedContent.value != currentContent) { + webView.loadDataWithBaseURL(url, wrapReaderHtml(currentContent), "text/html", "UTF-8", null) + loadedContent.value = currentContent + } + }, + onRelease = { webView -> webView.destroy() } + ) + } + failed -> Text( + "Couldn't extract a reader view for this page -- try \"Open in browser\" instead.", + modifier = Modifier.align(Alignment.Center).padding(24.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + else -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } +} 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 e1300f0..95ef71e 100644 --- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt @@ -81,7 +81,25 @@ fun FeedReaderApp() { val articleId = decodeNavArg(backStackEntry.arguments?.getString("articleId") ?: "") // 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() }) + ReaderScreen( + feedId = feedId, + initialArticleId = articleId, + onBack = { navController.popBackStack() }, + onOpenLink = { url -> navController.navigate("linkReader/${encodeNavArg(url)}") } + ) + } + composable( + "linkReader/{url}", + arguments = listOf(navArgument("url") { type = NavType.StringType }) + ) { backStackEntry -> + val url = decodeNavArg(backStackEntry.arguments?.getString("url") ?: "") + // Recursive: a link tapped inside this screen pushes another linkReader route, + // so following a chain of links stays inside the reader pipeline all the way down. + LinkReaderScreen( + url = url, + onBack = { navController.popBackStack() }, + onOpenLink = { nextUrl -> navController.navigate("linkReader/${encodeNavArg(nextUrl)}") } + ) } 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 2177d72..ced9482 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -2,7 +2,6 @@ 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.Column @@ -15,6 +14,8 @@ 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.CloudDone +import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.outlined.Article import androidx.compose.material3.CircularProgressIndicator @@ -63,7 +64,7 @@ private const val THIN_CONTENT_THRESHOLD = 500 */ @OptIn(ExperimentalFoundationApi::class) @Composable -fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) { +fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } var articleIds by remember(feedId) { mutableStateOf<List<String>?>(null) } @@ -91,13 +92,13 @@ fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) { val pagerState = rememberPagerState(initialPage = initialIndex) { safeIds.size } HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> - ReaderContent(articleId = safeIds[page], onBack = onBack) + ReaderContent(articleId = safeIds[page], onBack = onBack, onOpenLink = onOpenLink) } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ReaderContent(articleId: String, onBack: () -> Unit) { +private fun ReaderContent(articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() @@ -204,6 +205,20 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { }) { Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") } + // Purely local: just flips isSaved (shows up in the Saved list). Does NOT + // touch Stash -- that's a separate, explicit action below, since archiving + // to Stash is a heavier/networked action the user should trigger on purpose. + IconButton(onClick = { + val a = currentArticle ?: return@IconButton + val newSaved = !a.isSaved + article = a.copy(isSaved = newSaved) + scope.launch { db.articleDao().setSaved(a.id, newSaved) } + }) { + Icon( + if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = "Save locally" + ) + } IconButton( enabled = !isSaving, onClick = { @@ -214,13 +229,13 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow()) when (val result = client.saveArticle(a.link, a.title)) { is StashSaveResult.Saved -> { - db.articleDao().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.artifact.id) + article = a.copy(stashArtifactId = result.artifact.id) snackbarHostState.showSnackbar("Saved to Stash") } is StashSaveResult.AlreadySaved -> { - db.articleDao().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.existingId) + article = a.copy(stashArtifactId = result.existingId) snackbarHostState.showSnackbar("Already in Stash") } is StashSaveResult.Failed -> @@ -231,7 +246,7 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { } ) { Icon( - if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload, contentDescription = "Save to Stash" ) } @@ -254,15 +269,14 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> - WebView(ctx).apply { - settings.javaScriptEnabled = false - loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + newReaderWebView(ctx, onOpenLink).apply { + loadDataWithBaseURL(baseUrl, wrapReaderHtml(content), "text/html", "UTF-8", null) loadedContent.value = content } }, update = { webView -> if (loadedContent.value != content) { - webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content), "text/html", "UTF-8", null) loadedContent.value = content } }, @@ -278,18 +292,3 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit) { } } } - -// Hardcoded dark palette, not @media (prefers-color-scheme) -- the app itself always -// forces dark colorScheme() regardless of system setting (see MainActivity), so the -// reader content should match rather than following a system light/dark that the rest -// of the app ignores. -private fun wrapHtml(bodyContent: String): String = """ - <html><head><meta name="viewport" content="width=device-width, initial-scale=1"> - <style> - body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; - background-color: #121212; color: #e1e1e1; } - a { color: #8ab4f8; } - img { max-width: 100%; height: auto; } - </style></head> - <body>$bodyContent</body></html> -""".trimIndent() diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt new file mode 100644 index 0000000..42a15f0 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -0,0 +1,43 @@ +package org.terst.feedreader.ui + +import android.content.Context +import android.view.View +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +/** + * Shared factory for the read-only, dark-themed article WebView used by both the + * per-article ReaderScreen and LinkReaderScreen (for links tapped inside an article). + * JS is always off -- extracted/feed HTML never needs it, and this keeps articles + * from running arbitrary scripts. Link taps are intercepted rather than left to + * WebView's default behavior (handing off to the system browser) so following a + * link stays inside the app's own reader/extraction pipeline. + */ +fun newReaderWebView(context: Context, onOpenLink: (String) -> Unit): WebView = + WebView(context).apply { + settings.javaScriptEnabled = false + overScrollMode = View.OVER_SCROLL_NEVER + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + onOpenLink(request.url.toString()) + return true + } + } + } + +// Hardcoded dark palette, not @media (prefers-color-scheme) -- the app itself always +// forces dark colorScheme() regardless of system setting (see MainActivity), so reader +// content should match rather than following a system light/dark the app itself ignores. +fun wrapReaderHtml(bodyContent: String): String = """ + <html><head><meta name="viewport" content="width=device-width, initial-scale=1"> + <style> + body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; + background-color: #121212; color: #e1e1e1; } + a { color: #8ab4f8; } + img { max-width: 100%; height: auto; } + </style></head> + <body>$bodyContent</body></html> +""".trimIndent() |
