diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 08:08:58 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 08:08:58 +0000 |
| commit | d8ce4b3c0f383e844b125d2d3abd618715bb3c89 (patch) | |
| tree | b586ff9b42392f2c44645773c2a2f2aadb9db406 /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.kt | 166 |
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() |
