summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-22 18:35:06 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 18:35:06 +0000
commite804e7a77a6cac048d5f6edeac0a339ca89e4d40 (patch)
tree41bc4c30b11105f5846a5fe2eaf13e2cede10adf /app
parent91edd1f5f29a3336fa9e3bd1762c70ea74e3e48d (diff)
Add site login and alternate-source search for paywalled articles
Deliberately not a paywall-bypass mechanism -- that space (archive.today-style proxies, "Bypass Paywalls" extensions) is legally grey, adversarial toward publishers, and constantly breaking. Instead: - BrowserScreen: a real interactive mini-browser (JS + DOM storage on, ordinary navigation, no Readability interception) for the two cases where the user needs to actually USE a page rather than read it. Reached from a new overflow menu on the article reader: - "Log in to this site" opens the article's own URL there so the user can sign into a site they already subscribe to. Needs no extra plumbing: Android's CookieManager is one process-wide store shared by every WebView in the app, so a session cookie set here is picked up automatically by the reader's (JS-off) extraction WebView on the next visit to that domain -- same as a normal browser remembering you're signed in. - "Find another source" runs a web search on the article's title in the same browser, so the user can pick a different, non-paywalled outlet covering the same story themselves -- plain search/discovery, not circumvention. Verified live: search opened a real, interactive Google results page with working title-bar sync; back navigation (via BackHandler, goBack() when the WebView has history, else pop the screen) returned cleanly to the article.
Diffstat (limited to 'app')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/BrowserScreen.kt110
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt14
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt51
3 files changed, 171 insertions, 4 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/BrowserScreen.kt b/app/src/main/java/org/terst/feedreader/ui/BrowserScreen.kt
new file mode 100644
index 0000000..6202016
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/BrowserScreen.kt
@@ -0,0 +1,110 @@
+package org.terst.feedreader.ui
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.net.Uri
+import android.webkit.CookieManager
+import android.webkit.WebChromeClient
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.BackHandler
+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.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.viewinterop.AndroidView
+
+/**
+ * A real, interactive mini-browser -- JS and DOM storage on, ordinary navigation, no
+ * Readability interception. Unlike LinkReaderScreen (always tries to extract a clean
+ * article view), this is for the two cases where the user needs to actually USE a
+ * page rather than just read it: browsing search results to find an alternate,
+ * non-paywalled source for a story, or logging into a site they already subscribe to.
+ *
+ * Login needs no extra plumbing beyond turning cookies on: Android's CookieManager is
+ * a single process-wide store shared by every WebView in the app, so once a session
+ * cookie is set here, the reader's (JS-off) extraction WebView picks it up automatically
+ * on the next visit to that domain -- same as a normal browser remembering you're signed in.
+ */
+@SuppressLint("SetJavaScriptEnabled")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BrowserScreen(startUrl: String, onBack: () -> Unit) {
+ val context = LocalContext.current
+ var title by remember(startUrl) { mutableStateOf<String?>(null) }
+ var webViewRef by remember(startUrl) { mutableStateOf<WebView?>(null) }
+
+ BackHandler {
+ val wv = webViewRef
+ if (wv != null && wv.canGoBack()) wv.goBack() else onBack()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(title ?: "Browser", maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = {
+ IconButton(onClick = {
+ val wv = webViewRef
+ if (wv != null && wv.canGoBack()) wv.goBack() else onBack()
+ }) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ IconButton(onClick = {
+ val url = webViewRef?.url ?: startUrl
+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
+ }) {
+ Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ WebView(ctx).apply {
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ CookieManager.getInstance().setAcceptCookie(true)
+ CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
+ webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView, url: String) {
+ title = view.title
+ }
+ }
+ webChromeClient = WebChromeClient()
+ loadUrl(startUrl)
+ webViewRef = this
+ }
+ },
+ onRelease = { webView ->
+ // Flush explicitly so a just-completed login survives even if the
+ // process is killed shortly after (modern WebView auto-persists, but
+ // this makes it immediate rather than relying on that timing).
+ CookieManager.getInstance().flush()
+ webView.destroy()
+ }
+ )
+ }
+ }
+}
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 95ef71e..bd09393 100644
--- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt
@@ -1,6 +1,7 @@
package org.terst.feedreader.ui
import android.os.Bundle
+import android.webkit.CookieManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,6 +29,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
FeedFetchWorker.schedulePeriodic(applicationContext)
+ // Explicit, even though it defaults on -- BrowserScreen relies on cookies set
+ // during a site login surviving into the reader's later extraction requests.
+ CookieManager.getInstance().setAcceptCookie(true)
setContent {
// Dark by default -- per user request, not following system light/dark for now.
@@ -85,7 +89,8 @@ fun FeedReaderApp() {
feedId = feedId,
initialArticleId = articleId,
onBack = { navController.popBackStack() },
- onOpenLink = { url -> navController.navigate("linkReader/${encodeNavArg(url)}") }
+ onOpenLink = { url -> navController.navigate("linkReader/${encodeNavArg(url)}") },
+ onOpenBrowser = { url -> navController.navigate("browser/${encodeNavArg(url)}") }
)
}
composable(
@@ -101,6 +106,13 @@ fun FeedReaderApp() {
onOpenLink = { nextUrl -> navController.navigate("linkReader/${encodeNavArg(nextUrl)}") }
)
}
+ composable(
+ "browser/{url}",
+ arguments = listOf(navArgument("url") { type = NavType.StringType })
+ ) { backStackEntry ->
+ val url = decodeNavArg(backStackEntry.arguments?.getString("url") ?: "")
+ BrowserScreen(startUrl = url, 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 ced9482..8c2f64e 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -16,9 +16,12 @@ 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.MoreVert
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -50,6 +53,7 @@ import org.terst.feedreader.data.Settings
import org.terst.feedreader.readability.ReaderExtractor
import org.terst.feedreader.stash.StashClient
import org.terst.feedreader.stash.StashSaveResult
+import java.net.URLEncoder
// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary,
// not the real article body, and auto-fall-back to on-device Readability extraction.
@@ -64,7 +68,13 @@ private const val THIN_CONTENT_THRESHOLD = 500
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit) {
+fun ReaderScreen(
+ feedId: String,
+ initialArticleId: String,
+ onBack: () -> Unit,
+ onOpenLink: (String) -> Unit,
+ onOpenBrowser: (String) -> Unit
+) {
val context = LocalContext.current
val db = remember { AppDatabase.get(context) }
var articleIds by remember(feedId) { mutableStateOf<List<String>?>(null) }
@@ -92,13 +102,23 @@ fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit, o
val pagerState = rememberPagerState(initialPage = initialIndex) { safeIds.size }
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
- ReaderContent(articleId = safeIds[page], onBack = onBack, onOpenLink = onOpenLink)
+ ReaderContent(
+ articleId = safeIds[page],
+ onBack = onBack,
+ onOpenLink = onOpenLink,
+ onOpenBrowser = onOpenBrowser
+ )
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun ReaderContent(articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit) {
+private fun ReaderContent(
+ articleId: String,
+ onBack: () -> Unit,
+ onOpenLink: (String) -> Unit,
+ onOpenBrowser: (String) -> Unit
+) {
val context = LocalContext.current
val db = remember { AppDatabase.get(context) }
val scope = rememberCoroutineScope()
@@ -111,6 +131,7 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit, onOpenLink: (St
var isReaderMode by remember(articleId) { mutableStateOf(false) }
var isExtracting by remember(articleId) { mutableStateOf(false) }
var isSaving by remember(articleId) { mutableStateOf(false) }
+ var moreMenuExpanded by remember(articleId) { mutableStateOf(false) }
LaunchedEffect(articleId) {
val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
@@ -250,6 +271,30 @@ private fun ReaderContent(articleId: String, onBack: () -> Unit, onOpenLink: (St
contentDescription = "Save to Stash"
)
}
+ IconButton(onClick = { moreMenuExpanded = true }) {
+ Icon(Icons.Default.MoreVert, contentDescription = "More")
+ }
+ DropdownMenu(expanded = moreMenuExpanded, onDismissRequest = { moreMenuExpanded = false }) {
+ // A real (JS-on) browser, not the extraction pipeline -- search results
+ // and login pages need actual interaction, not a Readability pass.
+ DropdownMenuItem(
+ text = { Text("Find another source") },
+ onClick = {
+ moreMenuExpanded = false
+ val a = currentArticle ?: return@DropdownMenuItem
+ val query = URLEncoder.encode(a.title, "UTF-8")
+ onOpenBrowser("https://www.google.com/search?q=$query")
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Log in to this site") },
+ onClick = {
+ moreMenuExpanded = false
+ val a = currentArticle ?: return@DropdownMenuItem
+ onOpenBrowser(a.link)
+ }
+ )
+ }
}
)
},