package org.terst.feedreader.ui import android.content.Intent import android.net.Uri import android.webkit.WebView import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState 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.OpenInBrowser import androidx.compose.material.icons.outlined.Article import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.viewinterop.AndroidView import kotlinx.coroutines.flow.first 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 // 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. private const val THIN_CONTENT_THRESHOLD = 500 /** * Swipeable wrapper: snapshots the article-id order of the list this reader * was opened from (Unread/Saved/a specific feed) ONCE at entry, then pages * across that fixed queue. Deliberately a snapshot, not a live Flow -- opening * an article marks it read, which would otherwise drop it out of the live * Unread query mid-swipe and shift every subsequent page underneath the user. */ @OptIn(ExperimentalFoundationApi::class) @Composable fun ReaderScreen(feedId: String, initialArticleId: String, onBack: () -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } var articleIds by remember(feedId) { mutableStateOf?>(null) } LaunchedEffect(feedId) { val flow = when (feedId) { UNREAD_FEED_ID -> db.articleDao().observeUnread() SAVED_FEED_ID -> db.articleDao().observeSaved() else -> db.articleDao().observeByFeed(feedId) } articleIds = flow.first().map { it.id } } val ids = articleIds if (ids == null) { Box(modifier = Modifier.fillMaxSize()) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } return } // The article may have been opened from somewhere not in this queue (shouldn't normally // happen, but fall back to a single-item queue rather than crashing on indexOfFirst = -1). val safeIds = if (ids.contains(initialArticleId)) ids else listOf(initialArticleId) val initialIndex = safeIds.indexOf(initialArticleId).coerceAtLeast(0) val pagerState = rememberPagerState(initialPage = initialIndex) { safeIds.size } HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> ReaderContent(articleId = safeIds[page], onBack = onBack) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ReaderContent(articleId: String, onBack: () -> Unit) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } var article 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) } var isSaving by remember(articleId) { mutableStateOf(false) } LaunchedEffect(articleId) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect article = loaded db.articleDao().setRead(articleId, true) feedContent = loaded.feedContentHtml readerContent = loaded.extractedContent val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0 if (readerContent == null && plainLength < THIN_CONTENT_THRESHOLD) { // Feed content is too thin to be the real article -- auto-extract, since in this // case reader mode isn't really an alternative view, it's the only real content. isExtracting = true val extracted = ReaderExtractor.extract(context, loaded.link) isExtracting = false extracted?.content?.let { readerContent = it db.articleDao().setExtractedContent(articleId, it) } isReaderMode = true } } fun toggleReaderMode() { val a = article ?: return if (isReaderMode) { isReaderMode = false return } if (readerContent != null) { isReaderMode = true return } scope.launch { isExtracting = true val extracted = ReaderExtractor.extract(context, a.link) isExtracting = false val content = extracted?.content if (content != null) { readerContent = content db.articleDao().setExtractedContent(articleId, content) isReaderMode = true } else { snackbarHostState.showSnackbar("Couldn't extract a reader view for this article") } } } Scaffold( topBar = { TopAppBar( title = { Text(article?.title.orEmpty(), maxLines = 1) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } }, actions = { val currentArticle = article // Only offer the toggle when there's an actual feed-provided alternative to // switch back to -- if we already had to auto-extract (thin feed content), // reader mode IS the only content, so there's nothing to toggle between. if (feedContent != null) { IconButton(enabled = !isExtracting, onClick = { toggleReaderMode() }) { Icon( if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article, contentDescription = "Toggle reader view" ) } } IconButton(onClick = { val a = currentArticle ?: return@IconButton val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(a.link)) context.startActivity(browserIntent) }) { Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser") } IconButton( enabled = !isSaving, onClick = { val a = currentArticle ?: return@IconButton isSaving = true scope.launch { 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().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.artifact.id) snackbarHostState.showSnackbar("Saved to Stash") } is StashSaveResult.AlreadySaved -> { db.articleDao().setSaved(a.id, true) db.articleDao().setStashArtifactId(a.id, result.existingId) snackbarHostState.showSnackbar("Already in Stash") } is StashSaveResult.Failed -> snackbarHostState.showSnackbar("Save failed: ${result.message}") } isSaving = false } } ) { Icon( if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, contentDescription = "Save to Stash" ) } } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { val content = if (isReaderMode) readerContent else (feedContent ?: readerContent) val baseUrl = article?.link if (content != null) { AndroidView( modifier = Modifier.fillMaxSize(), factory = { ctx -> WebView(ctx).apply { settings.javaScriptEnabled = false loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) } }, update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) }, // 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)) } } } } private fun wrapHtml(bodyContent: String): String = """ $bodyContent """.trimIndent()