summaryrefslogtreecommitdiff
path: root/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FeedParser.kt14
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt112
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt20
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt51
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt43
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()