From ecdb45778e274126737aa87e27ed1b1c00cda01c Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 23 Jul 2026 07:38:28 +0000 Subject: Rebrand to FeeDreader; overflow-menu cleanup; deferred read-marking; folder-organized feed list - Rename app to FeeDreader with an owl launcher icon and a paywall-avoidance note in Settings - Move Open original/Save to Stash into the reader's overflow menu; add Mark unread there too - Only mark an article read when navigating away via Previous/Next, not on open or on back - Saved list no longer crosses out read items; its count includes them - Back from Unread (the root screen) now opens the feed list instead of exiting the app - Feed list: clearer separator above the per-feed list, folder grouping (from OPML categories) with case-insensitive sort, and a virtual "Last fetch failed" section at the top - Reader extraction: cap unsized decorative SVG icons (fixes Ars Technica's giant section-badge icon), force native loading="lazy" images to load eagerly in the hidden extraction WebView, and drop a lightbox's duplicate caption only when a real figcaption sibling exists - Backlog notes on swipe-to-next research, extraction test coverage, and an async-rendered-gallery race confirmed live on Ars Technica Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- app/src/main/AndroidManifest.xml | 4 +- .../java/org/terst/feedreader/data/AppDatabase.kt | 13 ++- .../main/java/org/terst/feedreader/data/Daos.kt | 9 +- .../java/org/terst/feedreader/data/Entities.kt | 1 + .../main/java/org/terst/feedreader/opml/Opml.kt | 53 +++++++--- .../feedreader/readability/ReaderExtractor.kt | 32 ++++++ .../java/org/terst/feedreader/ui/AddFeedScreen.kt | 7 +- .../org/terst/feedreader/ui/ArticleListScreen.kt | 12 ++- .../java/org/terst/feedreader/ui/FeedListScreen.kt | 71 +++++++++---- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 116 +++++++++++++-------- .../java/org/terst/feedreader/ui/ReaderWebView.kt | 10 ++ .../java/org/terst/feedreader/ui/SettingsScreen.kt | 15 +++ .../main/res/drawable/ic_launcher_background.xml | 9 ++ .../main/res/drawable/ic_launcher_foreground.xml | 69 ++++++++++++ app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/strings.xml | 2 +- 17 files changed, 349 insertions(+), 84 deletions(-) create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml (limited to 'app') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 76148e4..b5b3532 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,7 +6,9 @@ diff --git a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt index 66722fd..3367a71 100644 --- a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt @@ -9,7 +9,7 @@ import androidx.room.migration.Migration @Database( entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class], - version = 5, + version = 6, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -90,12 +90,21 @@ abstract class AppDatabase : RoomDatabase() { } } + // v6: added FeedEntity.folder (OPML category, for grouping the feed list). A plain + // ADD COLUMN is safe here (unlike v3->v4) since folder is a genuinely new field with + // no prior column it needs to replace/rebuild around. + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE feeds ADD COLUMN folder TEXT") + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "feedreader.db" - ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5).build().also { instance = it } + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6).build().also { instance = it } } } } diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt index 22f1d22..0b763e1 100644 --- a/app/src/main/java/org/terst/feedreader/data/Daos.kt +++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt @@ -10,10 +10,10 @@ import kotlinx.coroutines.flow.Flow @Dao interface FeedDao { - @Query("SELECT * FROM feeds ORDER BY title ASC") + @Query("SELECT * FROM feeds ORDER BY title COLLATE NOCASE ASC") fun observeAll(): Flow> - @Query("SELECT * FROM feeds ORDER BY title ASC") + @Query("SELECT * FROM feeds ORDER BY title COLLATE NOCASE ASC") suspend fun getAll(): List @Query("SELECT * FROM feeds WHERE id = :id") @@ -98,6 +98,11 @@ interface ArticleDao { @Query("SELECT $ARTICLE_LIST_COLUMNS FROM $ARTICLE_LIST_FROM WHERE articles.isSaved = 1 ORDER BY articles.createdAt DESC") fun observeSaved(): Flow> + // The Saved count is a "how much is on my shelf" total, not an unread badge -- + // includes already-read articles, unlike every other count in this DAO. + @Query("SELECT COUNT(*) FROM articles WHERE isSaved = 1") + fun observeSavedCount(): Flow + @Query("SELECT * FROM articles WHERE id = :id") suspend fun getById(id: String): ArticleEntity? diff --git a/app/src/main/java/org/terst/feedreader/data/Entities.kt b/app/src/main/java/org/terst/feedreader/data/Entities.kt index f4be19e..5f6a28c 100644 --- a/app/src/main/java/org/terst/feedreader/data/Entities.kt +++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt @@ -13,6 +13,7 @@ data class FeedEntity( val etag: String? = null, // conditional-GET caching (If-None-Match) to avoid re-downloading unchanged feeds val lastModified: String? = null, // conditional-GET caching (If-Modified-Since) val lastError: String? = null, // set when a fetch fails; cleared on the next successful fetch + val folder: String? = null, // OPML category/outline this feed was imported under, if any -- null for ungrouped feeds val readerModeEnabled: Boolean = false // per-feed opt-IN, set only by explicitly tapping the reader-mode // toggle (never automatic/heuristic) -- once on, articles from this feed default to the // extracted view and auto-extract on open instead of requiring a per-article tap each time. diff --git a/app/src/main/java/org/terst/feedreader/opml/Opml.kt b/app/src/main/java/org/terst/feedreader/opml/Opml.kt index 65510fb..65ba1bf 100644 --- a/app/src/main/java/org/terst/feedreader/opml/Opml.kt +++ b/app/src/main/java/org/terst/feedreader/opml/Opml.kt @@ -6,13 +6,14 @@ import org.xmlpull.v1.XmlPullParser import java.io.InputStream import java.io.OutputStream -data class OpmlFeed(val title: String, val xmlUrl: String, val htmlUrl: String?) +data class OpmlFeed(val title: String, val xmlUrl: String, val htmlUrl: String?, val folder: String? = null) /** - * OPML import/export. Any folder nesting ( containing other - * elements instead of an xmlUrl) is flattened -- this app has no - * folder concept, per the "simple design" scope, so a folder's contents - * are just imported as a flat list of feeds. + * OPML import/export. Folder nesting ( containing other + * elements instead of an xmlUrl) is captured as a single folder name per + * feed -- the nearest enclosing container outline's title/text. Nested + * folders-within-folders collapse to just the innermost name; this app + * groups by one folder level, not a full category tree. */ object Opml { @@ -22,16 +23,31 @@ object Opml { parser.setInput(input, null) val feeds = mutableListOf() + // One entry pushed per START_TAG, popped on its END_TAG -- a folder + // container's entry holds its name, a feed (or any other) outline's is null. + // The current folder is whichever named entry is nearest the top of the stack. + val outlineStack = mutableListOf() var eventType = parser.eventType while (eventType != XmlPullParser.END_DOCUMENT) { - if (eventType == XmlPullParser.START_TAG && parser.name.equals("outline", ignoreCase = true)) { - val xmlUrl = parser.getAttributeValue(null, "xmlUrl") - if (xmlUrl != null) { - val title = parser.getAttributeValue(null, "title") - ?: parser.getAttributeValue(null, "text") - ?: xmlUrl - val htmlUrl = parser.getAttributeValue(null, "htmlUrl") - feeds += OpmlFeed(title, xmlUrl, htmlUrl) + when (eventType) { + XmlPullParser.START_TAG -> if (parser.name.equals("outline", ignoreCase = true)) { + val xmlUrl = parser.getAttributeValue(null, "xmlUrl") + if (xmlUrl != null) { + val title = parser.getAttributeValue(null, "title") + ?: parser.getAttributeValue(null, "text") + ?: xmlUrl + val htmlUrl = parser.getAttributeValue(null, "htmlUrl") + val folder = outlineStack.filterNotNull().lastOrNull() + feeds += OpmlFeed(title, xmlUrl, htmlUrl, folder) + outlineStack += null + } else { + val folderName = parser.getAttributeValue(null, "text") + ?: parser.getAttributeValue(null, "title") + outlineStack += folderName + } + } + XmlPullParser.END_TAG -> if (parser.name.equals("outline", ignoreCase = true) && outlineStack.isNotEmpty()) { + outlineStack.removeAt(outlineStack.size - 1) } } eventType = parser.next() @@ -53,7 +69,7 @@ object Opml { serializer.endTag(null, "head") serializer.startTag(null, "body") - for (feed in feeds) { + fun writeFeedOutline(feed: FeedEntity) { serializer.startTag(null, "outline") serializer.attribute(null, "text", feed.title) serializer.attribute(null, "title", feed.title) @@ -62,6 +78,15 @@ object Opml { feed.siteUrl?.let { serializer.attribute(null, "htmlUrl", it) } serializer.endTag(null, "outline") } + val (grouped, ungrouped) = feeds.partition { it.folder != null } + for ((folder, feedsInFolder) in grouped.groupBy { it.folder }) { + serializer.startTag(null, "outline") + serializer.attribute(null, "text", folder) + serializer.attribute(null, "title", folder) + for (feed in feedsInFolder) writeFeedOutline(feed) + serializer.endTag(null, "outline") + } + for (feed in ungrouped) writeFeedOutline(feed) serializer.endTag(null, "body") serializer.endTag(null, "opml") diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt index 3eaa158..c41bbfb 100644 --- a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt +++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt @@ -136,6 +136,15 @@ object ReaderExtractor { // correctly from the (now-resolved) image's real dimensions. img.removeAttribute('width'); img.removeAttribute('height'); + // The browser's native loading="lazy" only fetches an image + // once it's near the (real, on-screen) viewport -- meaningless + // in this hidden, off-screen extraction WebView, where whether + // that ever counts as "near enough" is inconsistent run to run. + // Confirmed live: the same Ars Technica article's gallery photos + // came through in one extraction pass and were silently missing + // in another. Force every image to load immediately instead of + // leaving it to viewport-intersection timing that doesn't apply. + img.removeAttribute('loading'); } var sources = document.querySelectorAll('source'); for (var i = 0; i < sources.length; i++) { @@ -143,6 +152,29 @@ object ReaderExtractor { if (lazySrcset) sources[i].setAttribute('srcset', lazySrcset); } + // A lightbox (PhotoSwipe, and others like it) commonly keeps its own + // copy of an image's caption text in a separate div meant to only + // ever be shown inside the lightbox overlay -- confirmed live on Ars + // Technica: a ".pswp-caption-content" div sits right next to a real, + // normally-visible
with the exact same text, inside the + // same
. Readability's hidden-element skip only catches + // display:none/visibility:hidden, not however that div is actually + // kept offscreen until the lightbox opens, so it was coming through + // as a genuine second copy. Only remove it when that real figcaption + // sibling actually exists -- some of Ars's own gallery images use + // this same div as their ONLY caption (no figcaption at all), and + // removing those unconditionally just deleted their caption outright + // (confirmed live: two gallery photos lost their captions entirely + // rather than losing a duplicate). + var pswpCaptions = document.querySelectorAll('.pswp-caption-content'); + for (var i = 0; i < pswpCaptions.length; i++) { + var pc = pswpCaptions[i]; + var figure = pc.closest('figure'); + if (figure && figure.querySelector('figcaption')) { + pc.parentNode.removeChild(pc); + } + } + // Readability's default hidden-element skip (treating display:none / // visibility:hidden nodes as boilerplate) is correct for the vast // majority of sites -- it's what keeps collapsed nav/footer/sidebar diff --git a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt index 066d8d9..e531fb7 100644 --- a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt @@ -68,8 +68,11 @@ fun AddFeedScreen(onDone: () -> Unit) { // (and etag/lastModified/lastError) back to defaults on every re-import. // Preserve the existing row's other fields; only title/siteUrl come from OPML. val existing = db.feedDao().getById(feed.xmlUrl) - val entity = existing?.copy(title = feed.title, siteUrl = feed.htmlUrl ?: existing.siteUrl) - ?: FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl) + val entity = existing?.copy( + title = feed.title, + siteUrl = feed.htmlUrl ?: existing.siteUrl, + folder = feed.folder ?: existing.folder + ) ?: FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl, folder = feed.folder) db.feedDao().upsert(entity) } FeedFetchWorker.fetchNow(context) diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt index c3a398b..a852288 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -1,5 +1,6 @@ package org.terst.feedreader.ui +import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -80,6 +81,11 @@ fun ArticleListScreen( // single feed's own list already says so in the top bar, so rows there don't repeat it. val showFeedName = feedId == UNREAD_FEED_ID || feedId == OLDER_FEED_ID || feedId == SAVED_FEED_ID + // Unread is the app's landing screen (root of the back stack) -- the system back + // gesture/button here would otherwise exit the app entirely. Route it to the feed + // list instead, matching the same forward affordance already in the top bar. + BackHandler(enabled = feedId == UNREAD_FEED_ID) { onOpenFeedList() } + var specificFeedTitle by remember(feedId) { mutableStateOf(null) } LaunchedEffect(feedId) { if (!showFeedName) { @@ -146,6 +152,9 @@ fun ArticleListScreen( article = article, highlighted = result == FilterResult.HIGHLIGHTED, showFeedName = showFeedName, + // Saved is a "read later" shelf, not a read/unread queue -- articles + // don't get crossed out there just because they were opened elsewhere. + showReadState = feedId != SAVED_FEED_ID, onClick = { onOpenArticle(article.id) }, onMarkReadFrom = { pendingMarkReadFrom = article } ) @@ -161,6 +170,7 @@ private fun ArticleRow( article: ArticleListItem, highlighted: Boolean, showFeedName: Boolean, + showReadState: Boolean, onClick: () -> Unit, onMarkReadFrom: () -> Unit ) { @@ -185,7 +195,7 @@ private fun ArticleRow( headlineContent = { Text( article.title, - textDecoration = if (article.isRead) TextDecoration.LineThrough else null, + textDecoration = if (showReadState && article.isRead) TextDecoration.LineThrough else null, style = MaterialTheme.typography.bodyMedium ) }, diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt index 47f51fc..ee2130c 100644 --- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -73,6 +74,7 @@ fun FeedListScreen( val recentUnreadCount by remember { db.articleDao().observeUnreadCount(cutoff) }.collectAsState(initial = 0) val olderUnreadCount by remember { db.articleDao().observeOlderUnreadCount(cutoff) }.collectAsState(initial = 0) + val savedCount by remember { db.articleDao().observeSavedCount() }.collectAsState(initial = 0) var menuExpanded by remember { mutableStateOf(false) } var pendingDelete by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } @@ -145,33 +147,66 @@ fun FeedListScreen( HorizontalDivider() ListItem( headlineContent = { Text("Saved") }, + trailingContent = { Text(savedCount.toString()) }, modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() } ) - HorizontalDivider() + SectionHeader("Feeds") } - items(feeds, key = { it.first.id }) { (feed, unreadCount) -> - FeedRow( - feed = feed, - unreadCount = unreadCount, - onClick = { onOpenFeed(feed.id) }, - onDelete = { pendingDelete = feed }, - onToggleReaderMode = { - val newEnabled = !feed.readerModeEnabled - scope.launch { - db.feedDao().update(feed.copy(readerModeEnabled = newEnabled)) - val label = feed.title.ifBlank { "this feed" } - snackbarHostState.showSnackbar( - if (newEnabled) "Reader mode enabled for $label" else "Reader mode disabled for $label" - ) + + val failedFeeds = feeds.filter { it.first.lastError != null } + val okFeeds = feeds.filter { it.first.lastError == null } + val groupedByFolder = okFeeds.groupBy { it.first.folder } + val folderNames = groupedByFolder.keys.filterNotNull().sortedBy { it.lowercase() } + val ungrouped = groupedByFolder[null].orEmpty() + + fun feedRowItems(rows: List>) { + items(rows, key = { it.first.id }) { (feed, unreadCount) -> + FeedRow( + feed = feed, + unreadCount = unreadCount, + onClick = { onOpenFeed(feed.id) }, + onDelete = { pendingDelete = feed }, + onToggleReaderMode = { + val newEnabled = !feed.readerModeEnabled + scope.launch { + db.feedDao().update(feed.copy(readerModeEnabled = newEnabled)) + val label = feed.title.ifBlank { "this feed" } + snackbarHostState.showSnackbar( + if (newEnabled) "Reader mode enabled for $label" else "Reader mode disabled for $label" + ) + } } - } - ) - HorizontalDivider() + ) + HorizontalDivider() + } + } + + if (failedFeeds.isNotEmpty()) { + item { SectionHeader("Last fetch failed") } + feedRowItems(failedFeeds) + } + for (folder in folderNames) { + item { SectionHeader(folder) } + feedRowItems(groupedByFolder.getValue(folder)) + } + if (ungrouped.isNotEmpty()) { + if (folderNames.isNotEmpty() || failedFeeds.isNotEmpty()) item { SectionHeader("Other") } + feedRowItems(ungrouped) } } } } +@Composable +private fun SectionHeader(title: String) { + Text( + title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + @Composable private fun FeedRow( feed: FeedEntity, 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 a46d1aa..6e1f939 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -123,11 +123,22 @@ fun ReaderScreen( val currentPage = pagerState.currentPage val hasPrevious = currentPage > 0 val hasNext = currentPage < safeIds.size - 1 + // 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() { - scope.launch { pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceAtLeast(0)) } + val leavingId = safeIds[pagerState.currentPage] + scope.launch { + db.articleDao().setRead(leavingId, true) + pagerState.animateScrollToPage((pagerState.currentPage - 1).coerceAtLeast(0)) + } } fun goToNextArticle() { - scope.launch { pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceAtMost(safeIds.size - 1)) } + val leavingId = safeIds[pagerState.currentPage] + scope.launch { + db.articleDao().setRead(leavingId, true) + pagerState.animateScrollToPage((pagerState.currentPage + 1).coerceAtMost(safeIds.size - 1)) + } } HorizontalPager(state = pagerState, userScrollEnabled = false, modifier = Modifier.fillMaxSize()) { page -> @@ -175,7 +186,8 @@ private fun ReaderContent( LaunchedEffect(articleId) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect article = loaded - db.articleDao().setRead(articleId, true) + // 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 @@ -274,16 +286,10 @@ private fun ReaderContent( ) } } - 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") - } // Purely local: just flips isSaved (shows up in the Saved list). Does NOT - // touch Stash -- that's a separate, explicit action below, since archiving - // to Stash is a heavier/networked action the user should trigger on purpose. + // 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 @@ -295,41 +301,65 @@ private fun ReaderContent( contentDescription = "Save locally" ) } - 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().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( + 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 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 + } + } + ) + 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) + 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( diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt index 42a15f0..503f71e 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -38,6 +38,16 @@ fun wrapReaderHtml(bodyContent: String): String = """ background-color: #121212; color: #e1e1e1; } a { color: #8ab4f8; } img { max-width: 100%; height: auto; } + svg { max-width: 100%; } + /* A decorative icon (section badge, share glyph, etc.) that slipped through + extraction relies on the source site's own stylesheet for its real display + size (commonly Tailwind classes like "h-5 w-5") -- which we don't load here. + An with no explicit width/height falls back to the browser's default + (much larger than any inline icon is meant to be, confirmed live: Ars + Technica's per-article section-badge icon rendering "giant"). One WITH + explicit width/height was deliberately sized by the page itself, so leave + those alone -- just cap the ones that would otherwise size themselves. */ + svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; } $bodyContent """.trimIndent() diff --git a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt index a5d3f9c..b7897f4 100644 --- a/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -76,6 +76,21 @@ fun SettingsScreen(onBack: () -> Unit) { } ) { padding -> LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { + item { + Column(modifier = Modifier.padding(16.dp)) { + Text("FeeDreader", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "If a site paywalls its own feed, that's the site's call. But this reader " + + "won't go out of its way to help a soft paywall block you from reading " + + "something you already subscribed to get -- reader mode extracts the " + + "article you were sent, nothing more, nothing sneaky.", + style = MaterialTheme.typography.bodyMedium + ) + } + HorizontalDivider() + } + item { Column(modifier = Modifier.padding(16.dp)) { Text("Stash", style = MaterialTheme.typography.titleMedium) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..9886385 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5ee01ef --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d378acd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..d378acd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23e3261..5d2e118 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Feeds + FeeDreader -- cgit v1.2.3