From e804e7a77a6cac048d5f6edeac0a339ca89e4d40 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 22 Jul 2026 18:35:06 +0000 Subject: 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. --- .../java/org/terst/feedreader/ui/BrowserScreen.kt | 110 +++++++++++++++++++++ .../java/org/terst/feedreader/ui/MainActivity.kt | 14 ++- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 51 +++++++++- 3 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/terst/feedreader/ui/BrowserScreen.kt 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(null) } + var webViewRef by remember(startUrl) { mutableStateOf(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?>(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) + } + ) + } } ) }, -- cgit v1.2.3