summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
commitd8ce4b3c0f383e844b125d2d3abd618715bb3c89 (patch)
treeb586ff9b42392f2c44645773c2a2f2aadb9db406 /app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
Initial commit: Feeds, an Android RSS reader
Feed subscriptions (RSS 2.0 + Atom, hand-rolled parser), OPML import/export, WorkManager periodic fetch with conditional GET, Room-backed local storage (feeds/articles/filter rules), keyword mute/highlight filtering. Reader mode: on-device extraction via Mozilla's Readability.js (vendored from github.com/mozilla/readability, pinned to release 0.6.0) run in a hidden WebView, for feed articles whose provided content is too thin to be the real article body. Save: explicit action (from a feed article, or the Android share-sheet target for any URL) archives to Stash via POST /artifacts?sync=true, while a local on-device copy (Readability-extracted) is always kept so saved articles read offline regardless of Stash's reachability. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt166
1 files changed, 166 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
new file mode 100644
index 0000000..8b320eb
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -0,0 +1,166 @@
+package org.terst.feedreader.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.webkit.WebView
+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.Bookmark
+import androidx.compose.material.icons.filled.BookmarkBorder
+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.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+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.rememberCoroutineScope
+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.viewinterop.AndroidView
+import kotlinx.coroutines.launch
+import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.ArticleEntity
+import org.terst.feedreader.data.Net
+import org.terst.feedreader.data.Settings
+import org.terst.feedreader.readability.ReaderExtractor
+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.
+private const val THIN_CONTENT_THRESHOLD = 500
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ReaderScreen(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) }
+
+ LaunchedEffect(articleId) {
+ val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
+ article = loaded
+ db.articleDao().setRead(articleId, true)
+
+ val feedContent = loaded.feedContentHtml
+ 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>"
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(article?.title.orEmpty(), maxLines = 1) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ val currentArticle = article
+ IconButton(onClick = {
+ val a = currentArticle ?: return@IconButton
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link))
+ context.startActivity(browserIntent)
+ }) {
+ Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
+ }
+ IconButton(
+ enabled = !isSaving,
+ onClick = {
+ val a = currentArticle ?: return@IconButton
+ isSaving = true
+ scope.launch {
+ val settings = Settings(context)
+ 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)
+ snackbarHostState.showSnackbar("Saved to Stash")
+ }
+ is StashSaveResult.AlreadySaved -> {
+ db.articleDao().setSaved(a.id, true)
+ db.articleDao().setStashArtifactId(a.id, result.existingId)
+ snackbarHostState.showSnackbar("Already in Stash")
+ }
+ is StashSaveResult.Failed ->
+ snackbarHostState.showSnackbar("Save failed: ${result.message}")
+ }
+ isSaving = false
+ }
+ }
+ ) {
+ Icon(
+ if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
+ contentDescription = "Save to Stash"
+ )
+ }
+ }
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) }
+ ) { padding ->
+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
+ val content = displayContent
+ val baseUrl = article?.link
+ if (content != null) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ WebView(ctx).apply {
+ settings.javaScriptEnabled = false
+ loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null)
+ }
+ },
+ update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) }
+ )
+ } else if (isExtracting) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+ }
+ }
+ }
+}
+
+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; }
+ img { max-width: 100%; height: auto; }</style></head>
+ <body>$bodyContent</body></html>
+""".trimIndent()