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.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.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.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 import java.net.URLEncoder import java.security.MessageDigest private fun urlToArticleId(url: String): String = MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } /** * 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), 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 scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } val articleId = remember(url) { urlToArticleId(url) } 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 moreMenuExpanded by remember(url) { mutableStateOf(false) } LaunchedEffect(url) { val existing = db.articleDao().getById(articleId) article = existing if (existing?.extractedContent != null) { extractedTitle = existing.title extractedContent = existing.extractedContent } 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 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().setStashArtifactId(a.id, result.artifact.id) article = a.copy(stashArtifactId = result.artifact.id) snackbarHostState.showSnackbar("Saved to Stash") } is StashSaveResult.AlreadySaved -> { db.articleDao().setStashArtifactId(a.id, result.existingId) article = a.copy(stashArtifactId = result.existingId) snackbarHostState.showSnackbar("Already in Stash") } is StashSaveResult.Failed -> snackbarHostState.showSnackbar("Save failed: ${result.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( 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 -> { val loadedContent = remember(url) { mutableStateOf(null) } AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> newReaderWebView(ctx, onOpenLink).apply { loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null) loadedContent.value = currentExtractedContent } }, update = { webView -> if (loadedContent.value != currentExtractedContent) { webView.loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null) loadedContent.value = currentExtractedContent } }, 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 -> { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { settings.javaScriptEnabled = true settings.domStorageEnabled = true loadUrl(url) } }, onRelease = { webView -> webView.destroy() } ) } else -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } }