package org.terst.feedreader.ui import android.annotation.SuppressLint 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.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.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 import androidx.compose.material3.MaterialTheme 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState 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.unit.dp import androidx.compose.ui.text.style.TextOverflow 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.DEFAULT_READER_FONT_SIZE_PX import org.terst.feedreader.data.Settings import org.terst.feedreader.data.ensureStandaloneArticle import org.terst.feedreader.data.saveArticleToStash import org.terst.feedreader.data.loadCachedExtraction import org.terst.feedreader.data.urlToArticleId import org.terst.feedreader.readability.ReaderExtractor import java.net.URLEncoder /** * A reader view for links tapped inside an article's WebView -- extracts the linked * page with the same Readability pipeline as the main reader, and recursively re-opens * itself for further links, so link-hopping stays inside the app's reader instead of * dropping into the system browser. Brought to parity with the main article reader: * save locally / save to Stash (lazily creates a standalone ArticleEntity, feedId=null, * keyed by a hash of the URL -- same scheme ShareReceiverActivity uses, so sharing the * same link later lands on the same row instead of duplicating it), the saved reader font * size and its pinch-to-resize gesture (a link read here is the same reading surface as an * article, so it shouldn't ignore a size the article view respects), a reader-mode * toggle (falls back to the live page, JS on, when the user wants the original instead * of the extracted view or extraction failed), and the same login/search-alternate menu. */ @SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class) @Composable fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val settings = remember { Settings(context) } val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val articleId = remember(url) { urlToArticleId(url) } val fontSizePx by settings.readerFontSizePx.collectAsState(initial = DEFAULT_READER_FONT_SIZE_PX) var article by remember(url) { mutableStateOf(null) } var extractedTitle by remember(url) { mutableStateOf(null) } var extractedContent by remember(url) { mutableStateOf(null) } var extractionFailed by remember(url) { mutableStateOf(false) } var isReaderMode by remember(url) { mutableStateOf(true) } var isSaving by remember(url) { mutableStateOf(false) } var isReextracting by remember(url) { mutableStateOf(false) } var moreMenuExpanded by remember(url) { mutableStateOf(false) } var longPressedLink by remember(url) { mutableStateOf(null) } var youtubeVideoId by remember(url) { mutableStateOf(null) } var zoomedImageUrl by remember(url) { mutableStateOf(null) } LaunchedEffect(url) { val existing = db.articleDao().getById(articleId) article = existing // A cached extraction can be the site's cookie-consent notice rather than the // article (see CachedExtraction) -- drop it and extract again instead of showing // a cookie disclaimer as the page. val cached = loadCachedExtraction(existing?.extractedContent) { db.articleDao().clearExtractedContent(articleId) } if (cached.content != null) { extractedTitle = existing?.title extractedContent = cached.content } else { val extracted = ReaderExtractor.extract(context, url) val content = extracted?.content if (content != null) { extractedTitle = extracted.title extractedContent = content } else { extractionFailed = true isReaderMode = false } } } // Both save actions create the standalone row on first use (there's nothing to save // until the user asks) -- subsequent taps just update the existing one. suspend fun ensureArticleRow(): ArticleEntity { article?.let { return it } val a = ArticleEntity( id = articleId, feedId = null, title = extractedTitle ?: url, link = url, extractedContent = extractedContent, createdAt = System.currentTimeMillis() ) db.articleDao().upsert(a) article = a return a } Scaffold( topBar = { TopAppBar( title = { Text(extractedTitle ?: Uri.parse(url).host ?: "Article", maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } }, actions = { val currentArticle = article if (extractedContent != null) { IconButton(onClick = { isReaderMode = !isReaderMode }) { Icon( if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article, contentDescription = "Toggle reader view" ) } } IconButton(onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }) { Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") } IconButton(onClick = { scope.launch { val a = ensureArticleRow() val newSaved = !a.isSaved article = a.copy(isSaved = newSaved) 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 = { isSaving = true scope.launch { val a = ensureArticleRow() val outcome = saveArticleToStash(context, db, a.id, a.link, a.title) outcome.artifactId?.let { article = a.copy(stashArtifactId = it) } snackbarHostState.showSnackbar(outcome.message) isSaving = false } } ) { Icon( if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload, contentDescription = "Save to Stash" ) } IconButton(onClick = { moreMenuExpanded = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More") } DropdownMenu(expanded = moreMenuExpanded, onDismissRequest = { moreMenuExpanded = false }) { DropdownMenuItem( enabled = !isReextracting, text = { Text("Re-download and re-extract") }, onClick = { moreMenuExpanded = false scope.launch { isReextracting = true val extracted = ReaderExtractor.extract(context, url) isReextracting = false val content = extracted?.content if (content != null) { extractedTitle = extracted.title extractedContent = content extractionFailed = false isReaderMode = true db.articleDao().setExtractedContent(articleId, content) snackbarHostState.showSnackbar("Re-extracted") } else { snackbarHostState.showSnackbar("Re-extraction failed") } } } ) DropdownMenuItem( text = { Text("Find another source") }, onClick = { moreMenuExpanded = false val query = URLEncoder.encode(extractedTitle ?: url, "UTF-8") onOpenBrowser("https://www.google.com/search?q=$query") } ) DropdownMenuItem( text = { Text("Log in to this site") }, onClick = { moreMenuExpanded = false onOpenBrowser(url) } ) } } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { val currentExtractedContent = extractedContent when { isReaderMode && currentExtractedContent != null -> { // Keyed on font size as well as content, exactly like ReaderScreen: a link // opened in reader view is the same reading surface as an article, so it // honors the same saved size and the same pinch-to-resize gesture, and picks // up a size changed elsewhere while this screen stayed open. val loadedKey = remember(url) { mutableStateOf?>(null) } val latestFontSizePx by rememberUpdatedState(fontSizePx) AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> newReaderWebView( ctx, ReaderWebViewCallbacks( onOpenLink = onOpenLink, onLongPressLink = { longPressedLink = it }, onOpenYouTubeExternally = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(it))) }, onYouTubeVideo = { youtubeVideoId = it }, onImageTap = { zoomedImageUrl = it }, currentFontSizePx = { latestFontSizePx }, onFontSizeChanged = { newSizePx -> scope.launch { settings.setReaderFontSizePx(newSizePx) } } ) ).apply { val html = wrapReaderHtml(currentExtractedContent, extractedTitle, fontSizePx = fontSizePx, baseUrl = url) loadReaderHtml(url, html, fontSizePx) loadedKey.value = currentExtractedContent to fontSizePx } }, update = { webView -> if (loadedKey.value != currentExtractedContent to fontSizePx) { val html = wrapReaderHtml(currentExtractedContent, extractedTitle, fontSizePx = fontSizePx, baseUrl = url) webView.loadReaderHtml(url, html, fontSizePx) loadedKey.value = currentExtractedContent to fontSizePx } }, onRelease = { webView -> webView.destroy() } ) } // Reader mode off, or extraction failed -- fall back to the live page // itself (JS on) rather than leaving the screen blank. !isReaderMode || extractionFailed -> { if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) { // `url` isn't necessarily a link the user themselves tapped: it can // also be whatever a WebView's shouldOverrideUrlLoading reported for // ANY navigation attempt inside an article (see ReaderWebView's // onOpenLink), including one triggered by the article's own HTML // linking to a non-http scheme (intent://, tel:, etc.). Refusing to // load anything but http(s) here means this JS-enabled WebView can't // become a sink for whatever scheme a crafted article handed it. Text( "Can't open this link: $url", modifier = Modifier.fillMaxSize().padding(16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { // Qualified: `settings` unqualified would now reach this // screen's app Settings (reader font size), which shadows // the WebView's own WebSettings inside this apply block. // JS on here is this screen's documented fallback -- see the // @SuppressLint and the http(s)-only guard just above. this.settings.javaScriptEnabled = true this.settings.domStorageEnabled = true loadUrl(url) } }, onRelease = { webView -> webView.destroy() } ) } } else -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } youtubeVideoId?.let { videoId -> YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null }) } zoomedImageUrl?.let { imageUrl -> ImageZoomDialog(imageUrl = imageUrl, onDismiss = { zoomedImageUrl = null }) } longPressedLink?.let { linkUrl -> LinkActionsDialog( url = linkUrl, onDismiss = { longPressedLink = null }, onOpenInBrowser = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(linkUrl))) }, onShare = { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, linkUrl) } context.startActivity(Intent.createChooser(send, null)) }, onSaveToStash = { scope.launch { val entity = ensureStandaloneArticle(context, db, linkUrl) val outcome = saveArticleToStash(context, db, entity.id, linkUrl, entity.title) snackbarHostState.showSnackbar(outcome.message) } }, onSaveForLater = { scope.launch { val entity = ensureStandaloneArticle(context, db, linkUrl) if (!entity.isSaved) db.articleDao().setSaved(entity.id, true) snackbarHostState.showSnackbar("Saved for later") } } ) } } }