package org.terst.feedreader.ui import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri import android.util.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.AlertDialog import androidx.compose.material3.BottomAppBar 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.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.terst.feedreader.readability.ExtractionResult import org.terst.feedreader.readability.diagnostic import org.terst.feedreader.readability.userMessage 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.FeedEntity import org.terst.feedreader.data.Settings import org.terst.feedreader.data.ensureStandaloneArticle import org.terst.feedreader.data.saveArticleToStash import org.terst.feedreader.data.setReaderModeEnabled import org.terst.feedreader.readability.ReaderExtractor import java.net.URLEncoder /** * Not a swipeable multi-article queue anymore -- it was, via a HorizontalPager paging * across a snapshot of sibling article ids taken once at entry, but that design had two * real bugs traced back to the same root cause: the snapshot lived in plain `remember` * state that did NOT survive an orientation change, while the pager's own page index DID * (Compose's PagerState saves that part) -- rotating mid-session left the index pointing * into a freshly re-fetched (and by then shorter/shifted, since paging past an article * marks it read and drops it from the Unread query) list, landing on a different article * than the one actually being read. And because the visually-displayed article and the * nav route argument backing the whole screen instance could drift apart that way, "back" * got confusing too -- it was always popping back to wherever the ORIGINAL initialArticleId * pointed, not wherever the pager had since scrolled to. * * Fixed by not having a separate queue/index at all: each Next/Previous replaces this * screen's own back-stack entry with a fresh navigation to the neighboring article's id * (MainActivity wires onNavigateToArticle with the same popUpTo-replace pattern * LinkReaderScreen already uses for its own link chain), so "which article is showing" IS * the nav route argument -- Android's own Saved State mechanism, not custom state that can * fall out of sync with it -- and the back stack is always exactly [list, this one reader * entry] no matter how many articles you've paged through, so back always returns to the * list in one press, deterministically. */ @Composable fun ReaderScreen( feedId: String, articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, onNavigateToArticle: (String) -> Unit ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val settings = remember { Settings(context) } val scope = rememberCoroutineScope() val fontSizePx by settings.readerFontSizePx.collectAsState(initial = DEFAULT_READER_FONT_SIZE_PX) var previousId by remember(articleId) { mutableStateOf(null) } var nextId by remember(articleId) { mutableStateOf(null) } // Looked up fresh every time articleId changes (i.e. every navigation), not cached -- // always reflects the CURRENT live Unread/Older/Saved/feed ordering, so there's no // stale-snapshot state to fall out of sync with anything, on rotation or otherwise. // sinceFetch (Settings.lastFetchAt) is what keeps a just-read article from vanishing // out of this same list before Previous/Next can find it -- see observeUnread's comment. LaunchedEffect(articleId) { val cutoff = System.currentTimeMillis() - RECENT_WINDOW_MS val sinceFetch = settings.lastFetchAt.first() val flow = when (feedId) { UNREAD_FEED_ID -> db.articleDao().observeUnread(cutoff, sinceFetch) OLDER_FEED_ID -> db.articleDao().observeOlderUnread(cutoff, sinceFetch) SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } val ids = flow.first().map { it.id } val idx = ids.indexOf(articleId) previousId = if (idx > 0) ids[idx - 1] else null nextId = if (idx in 0 until ids.size - 1) ids[idx + 1] else null } // Marking read happens HERE (moving to another article), not on open -- backing out // to the list without paging past an article leaves it unread. Only Next/Previous // count as "moved away from it"; exiting via the back arrow deliberately doesn't. fun goToPreviousArticle() { val target = previousId ?: return scope.launch { db.articleDao().setRead(articleId, true, System.currentTimeMillis()) onNavigateToArticle(target) } } fun goToNextArticle() { val target = nextId ?: return scope.launch { db.articleDao().setRead(articleId, true, System.currentTimeMillis()) onNavigateToArticle(target) } } // "I looked, but I'm not done with this one" -- advances the same as Next, just without // the implicit setRead(true) that moving on normally carries. fun goToNextArticleKeepUnread() { val target = nextId ?: return onNavigateToArticle(target) } ReaderContent( articleId = articleId, onBack = onBack, onOpenLink = onOpenLink, onOpenBrowser = onOpenBrowser, hasPrevious = previousId != null, hasNext = nextId != null, onPrevious = ::goToPreviousArticle, onNext = ::goToNextArticle, onNextKeepUnread = ::goToNextArticleKeepUnread, fontSizePx = fontSizePx ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent( articleId: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit, onNextKeepUnread: () -> Unit, fontSizePx: Int ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } // Independently remembered rather than threaded down from ReaderScreen, matching db/scope // above -- Settings is a stateless DataStore wrapper, cheap to construct again, and this // keeps pinch-to-zoom from growing ReaderContent's already-long parameter list any further. val settings = remember { Settings(context) } val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } var article by remember(articleId) { mutableStateOf(null) } var feed by remember(articleId) { mutableStateOf(null) } var feedContent by remember(articleId) { mutableStateOf(null) } var readerContent by remember(articleId) { mutableStateOf(null) } var isReaderMode by remember(articleId) { mutableStateOf(false) } var isExtracting by remember(articleId) { mutableStateOf(false) } // Non-null when the last extraction attempt failed. Keyed on articleId like // the rest of the per-article state so a stale error cannot carry over to // the next article. var extractionError by remember(articleId) { mutableStateOf(null) } var isSaving by remember(articleId) { mutableStateOf(false) } var moreMenuExpanded by remember(articleId) { mutableStateOf(false) } var showBugReportDialog by remember(articleId) { mutableStateOf(false) } var bugReportDescription by remember(articleId) { mutableStateOf("") } var longPressedLink by remember(articleId) { mutableStateOf(null) } var youtubeVideoId by remember(articleId) { mutableStateOf(null) } var zoomedImageUrl by remember(articleId) { mutableStateOf(null) } LaunchedEffect(articleId) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect article = loaded // Deliberately NOT marking read here -- see ReaderScreen.goToNextArticle/ // goToPreviousArticle, which is where "read" actually gets set now. val loadedFeed = loaded.feedId?.let { db.feedDao().getById(it) } feed = loadedFeed feedContent = loaded.feedContentHtml readerContent = loaded.extractedContent // Reader mode is NEVER heuristic/automatic (no more "content looks thin, switch // for you") -- it strictly follows the feed's persisted, explicitly-set opt-in. // Once a feed has it on, articles from it default to the extracted view and // auto-extract on open (that's the point of persisting it per-feed: no re-tapping // per article), but nothing here ever turns it on by itself. if (loadedFeed?.readerModeEnabled == true) { isReaderMode = true if (readerContent == null) { isExtracting = true val result = ReaderExtractor.extractResult(context, loaded.link) isExtracting = false when (result) { is ExtractionResult.Success -> { val content = result.article.content if (content != null) { readerContent = content db.articleDao().setExtractedContent(articleId, content) } } is ExtractionResult.Failure -> { // Previously this branch did not exist: a failure was // dropped, leaving the raw feed content on screen with // no indication anything had gone wrong. Log.w("ReaderExtractor", "extract failed for ${loaded.link}: ${result.reason.diagnostic()}") extractionError = result.reason.userMessage() snackbarHostState.showSnackbar(result.reason.userMessage()) } } } } } // The toggle now sets reader mode for the ARTICLE'S FEED, persisted immediately, with // a confirmation snackbar -- not a per-article, this-session-only view flip. Tapping it // is the one and only way reader mode ever turns on. fun toggleFeedReaderMode() { val f = feed ?: return val a = article ?: return val newEnabled = !f.readerModeEnabled feed = f.copy(readerModeEnabled = newEnabled) scope.launch { val message = db.feedDao().setReaderModeEnabled(f, newEnabled) if (newEnabled) { if (readerContent == null) { isExtracting = true val result = ReaderExtractor.extractResult(context, a.link) isExtracting = false when (result) { is ExtractionResult.Success -> { val content = result.article.content if (content != null) { readerContent = content db.articleDao().setExtractedContent(articleId, content) } } is ExtractionResult.Failure -> { Log.w("ReaderExtractor", "extract failed for ${a.link}: ${result.reason.diagnostic()}") extractionError = result.reason.userMessage() } } } isReaderMode = true } else { isReaderMode = false readerContent = null db.articleDao().clearExtractedContent(articleId) } snackbarHostState.showSnackbar(message) } } Scaffold( topBar = { TopAppBar( title = { Column { Text( article?.title.orEmpty(), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium ) feed?.title?.let { Text( it, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } }, actions = { val currentArticle = article // Sets reader mode for the whole feed (persisted), not just this article -- // shown whenever the article belongs to an actual subscribed feed. if (feed != null) { IconButton(enabled = !isExtracting, onClick = { toggleFeedReaderMode() }) { Icon( if (feed?.readerModeEnabled == true) Icons.Filled.Article else Icons.Outlined.Article, contentDescription = if (feed?.readerModeEnabled == true) "Reader mode enabled for this feed -- tap to disable" else "Reader mode disabled for this feed -- tap to enable" ) } } // Purely local: just flips isSaved (shows up in the Saved list). Does NOT // touch Stash -- that's a separate, explicit action in the overflow menu, // since archiving to Stash is a heavier/networked action the user should // trigger on purpose. IconButton(onClick = { val a = currentArticle ?: return@IconButton val newSaved = !a.isSaved article = a.copy(isSaved = newSaved) scope.launch { db.articleDao().setSaved(a.id, newSaved) } }) { Icon( if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, contentDescription = "Save locally" ) } IconButton(onClick = { moreMenuExpanded = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More") } DropdownMenu(expanded = moreMenuExpanded, onDismissRequest = { moreMenuExpanded = false }) { DropdownMenuItem( leadingIcon = { Icon(Icons.Default.OpenInBrowser, contentDescription = null) }, text = { Text("Open original") }, onClick = { moreMenuExpanded = false val a = currentArticle ?: return@DropdownMenuItem context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(a.link))) } ) DropdownMenuItem( enabled = !isSaving, leadingIcon = { Icon( if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload, contentDescription = null ) }, text = { Text(if (currentArticle?.stashArtifactId != null) "Saved to Stash" else "Save to Stash") }, onClick = { moreMenuExpanded = false val a = currentArticle ?: return@DropdownMenuItem isSaving = true scope.launch { 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 } } ) DropdownMenuItem( text = { Text("Mark unread") }, onClick = { moreMenuExpanded = false val a = currentArticle ?: return@DropdownMenuItem article = a.copy(isRead = false) scope.launch { db.articleDao().setRead(a.id, false, null) snackbarHostState.showSnackbar("Marked unread") } } ) // 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) } ) // Forces a fresh fetch + Readability pass, overwriting whatever's // cached -- for when extraction came back thin, stale, or broken // (e.g. a YouTube thumbnail that failed to resolve) and simply hasn't // been retried since whatever caused that. DropdownMenuItem( enabled = !isExtracting, text = { Text("Re-download and re-extract") }, onClick = { moreMenuExpanded = false val a = currentArticle ?: return@DropdownMenuItem scope.launch { isExtracting = true val reResult = ReaderExtractor.extractResult(context, a.link) val extracted = (reResult as? ExtractionResult.Success)?.article if (reResult is ExtractionResult.Failure) { Log.w("ReaderExtractor", "re-extract failed for ${a.link}: ${reResult.reason.diagnostic()}") extractionError = reResult.reason.userMessage() } isExtracting = false val content = extracted?.content if (content != null) { readerContent = content isReaderMode = true db.articleDao().setExtractedContent(a.id, content) snackbarHostState.showSnackbar("Re-extracted") } else { snackbarHostState.showSnackbar(extractionError ?: "Re-extraction failed") } } } ) DropdownMenuItem( text = { Text("Report bug") }, onClick = { moreMenuExpanded = false showBugReportDialog = true } ) } } ) }, bottomBar = { BottomAppBar { TextButton(onClick = onPrevious, enabled = hasPrevious, modifier = Modifier.weight(1f)) { Text("Previous") } // Advances like Next, but skips the implicit "mark read" that moving on // normally carries -- for "I looked, but I'm not done with this one yet." TextButton(onClick = onNextKeepUnread, enabled = hasNext, modifier = Modifier.weight(1f)) { Text("Keep unread") } TextButton(onClick = onNext, enabled = hasNext, modifier = Modifier.weight(1f)) { Text("Next") } } }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> if (showBugReportDialog) { // Clipboard-only by design, not a half-built feature -- there's no backend to // submit to (this is a single-user personal app), and the real "destination" is // pasting this straight into a message during the normal hands-on-testing workflow // (see BACKLOG.md / project notes: sideload a build, use it, report issues in one // message). A submission endpoint would be infrastructure with no second user to // justify it. val a = article AlertDialog( onDismissRequest = { showBugReportDialog = false }, title = { Text("Report bug") }, text = { Column { Text("Describe what happened. Copies article/feed state along with it so it can be reproduced.") Spacer(modifier = Modifier.height(12.dp)) OutlinedTextField( value = bugReportDescription, onValueChange = { bugReportDescription = it }, label = { Text("Description") }, modifier = Modifier.fillMaxWidth() ) } }, confirmButton = { TextButton(onClick = { val report = buildString { appendLine("Feeds bug report") appendLine("Description: ${bugReportDescription.ifBlank { "(none provided)" }}") appendLine("Article title: ${a?.title}") appendLine("Article URL: ${a?.link}") appendLine("Article id: ${a?.id}") appendLine("Feed: ${feed?.title} (${feed?.id})") appendLine("Feed readerModeEnabled: ${feed?.readerModeEnabled}") appendLine("Session isReaderMode: $isReaderMode") appendLine("isSaved: ${a?.isSaved}, stashArtifactId: ${a?.stashArtifactId}") appendLine("extractedContent cached: ${readerContent != null}") } val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("Bug report", report)) showBugReportDialog = false scope.launch { snackbarHostState.showSnackbar("Copied to clipboard") } }) { Text("Copy") } }, dismissButton = { TextButton(onClick = { showBugReportDialog = false }) { Text("Cancel") } } ) } youtubeVideoId?.let { videoId -> YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null }) } zoomedImageUrl?.let { imageUrl -> ImageZoomDialog(imageUrl = imageUrl, onDismiss = { zoomedImageUrl = null }) } longPressedLink?.let { url -> LinkActionsDialog( url = url, onDismiss = { longPressedLink = null }, onOpenInBrowser = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }, onShare = { val send = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, url) } context.startActivity(Intent.createChooser(send, null)) }, onSaveToStash = { scope.launch { val entity = ensureStandaloneArticle(context, db, url) val outcome = saveArticleToStash(context, db, entity.id, url, entity.title) snackbarHostState.showSnackbar(outcome.message) } }, onSaveForLater = { scope.launch { val entity = ensureStandaloneArticle(context, db, url) if (!entity.isSaved) db.articleDao().setSaved(entity.id, true) snackbarHostState.showSnackbar("Saved for later") } } ) } Box(modifier = Modifier.fillMaxSize().padding(padding)) { val content = if (isReaderMode) readerContent else (feedContent ?: readerContent) val baseUrl = article?.link if (content != null) { // AndroidView's update lambda reruns on every recomposition of this // composable -- including ones unrelated to content, like isSaving // flipping while a Stash save is in flight. Reloading the WebView's HTML // on those unrelated recompositions was resetting scroll position mid-swipe, // which is what made scrolling feel unsmooth/janky. Track what's actually // loaded and only reload when the content string or font size actually // changes -- fontSizePx is included deliberately (not just content) so // changing it in Settings and coming back to an already-open article // picks up the new size, without reloading on every unrelated recomposition. val loadedKey = remember(articleId) { mutableStateOf?>(null) } // newReaderWebView's factory runs once per WebView instance, but fontSizePx can // change later (e.g. the user tweaks it in Settings while this same article stays // open) -- rememberUpdatedState keeps the pinch gesture's "what am I scaling from" // read current instead of frozen at whatever it was on first composition. val latestFontSizePx by rememberUpdatedState(fontSizePx) AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> newReaderWebView( ctx, ReaderWebViewCallbacks( onOpenLink = onOpenLink, onLongPressLink = { longPressedLink = it }, onYouTubeVideo = { youtubeVideoId = it }, onImageTap = { zoomedImageUrl = it }, onScrollChanged = { scrollY -> ReaderScrollPositions.set(articleId, scrollY) }, // The page's real content height isn't known until layout finishes // -- scrolling to a saved offset any earlier would just clamp back // to 0 on a page that hasn't grown tall enough yet. onPageFinished = { webView -> webView.scrollTo(0, ReaderScrollPositions.get(articleId)) }, currentFontSizePx = { latestFontSizePx }, onFontSizeChanged = { newSizePx -> scope.launch { settings.setReaderFontSizePx(newSizePx) } } ) ).apply { val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx) loadReaderHtml(baseUrl, html, fontSizePx) loadedKey.value = content to fontSizePx } }, update = { webView -> if (loadedKey.value != content to fontSizePx) { val html = wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl, fontSizePx) webView.loadReaderHtml(baseUrl, html, fontSizePx) loadedKey.value = content to fontSizePx } }, // AndroidView does not destroy() the underlying WebView on its own -- // without this, swiping through many articles (each page holding its own // WebView) leaks a WebView's native resources per page and eventually // exhausts memory over a long reading session. onRelease = { webView -> webView.destroy() } ) } else if (isExtracting) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } } } }