diff options
18 files changed, 389 insertions, 84 deletions
@@ -81,3 +81,43 @@ scrolling still feels jerky now that nothing above the WebView is doing any gesture detection at all -- if it still does, the cause is something else (WebView rendering/compositing itself, the emulator's software rendering not reflecting real-device feel, etc.), not gesture interception. + +**Confirmed on a real device (2026-07):** scrolling measurably improved +once swipe-to-next was dropped. So any future attempt to bring back a +swipe-to-next gesture needs to be judged against that baseline -- it must +not regress scroll feel back to the pre-removal state. Two arbitration +approaches already tried and failed (see above); a fresh approach (e.g. +a much larger/edge-only swipe hit target that never competes with +vertical scroll gestures at all, rather than trying to disambiguate axis +mid-gesture) would need to be prototyped and felt out live, not just +reasoned about in code review. + +## Extraction test coverage for WHT / Ars Technica (not yet built) + +**Motivation:** both sites' Readability extraction has needed repeat fixes +(lazy-loaded images, doubled captions, paywall-targeted un-hide) and each +fix so far has been diagnosed by hand against a single live article rather +than against a standing set of known-tricky pages. Reported as still +inconsistent after multiple rounds of fixes -- regressions are easy to +reintroduce without something to check against. + +**Rough shape (not yet built):** save a handful of representative fixture +HTML pages from each site (lazy-image markup, a paywalled article, one +with duplicate captions) alongside the expected extracted output, and run +them through `ReaderExtractor`'s pipeline in an instrumented test (it needs +a real WebView to run Readability.js, so this can't be a plain JVM unit +test) on every change to the extraction/paywall-unhide logic, instead of +re-fetching a live article by hand each time. + +**Also confirmed live (2026-07):** the same Ars Technica article extracted +with its gallery photos included on one run and without them on another, +with no code change in between. Ars's gallery section appears to render +in client-side JS some time after the page's initial HTML document load +(`onPageFinished`, which is when `ReaderExtractor` currently runs +Readability) -- so whether it's finished by the time our script executes +is a real race, not something the `loading="lazy"` fix (also added this +round) addresses. A proper fix likely means waiting for the page to go +network-idle (or a short fixed settle delay) before parsing, which trades +against `TIMEOUT_MS` and adds latency to every single extraction -- worth +deciding deliberately rather than bolting on, so left for this backlog +item rather than this round's quick fixes. 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 @@ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <application - android:label="Feeds" + android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" android:theme="@style/Theme.FeedReader" android:allowBackup="false"> 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<List<FeedEntity>> - @Query("SELECT * FROM feeds ORDER BY title ASC") + @Query("SELECT * FROM feeds ORDER BY title COLLATE NOCASE ASC") suspend fun getAll(): List<FeedEntity> @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<List<ArticleListItem>> + // 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<Int> + @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 (<outline> containing other - * <outline> 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 (<outline> containing other <outline> + * 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<OpmlFeed>() + // One entry pushed per <outline> 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<String?>() 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 <figcaption> with the exact same text, inside the + // same <figure>. 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<String?>(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<FeedEntity?>(null) } val snackbarHostState = remember { SnackbarHostState() } @@ -145,34 +147,67 @@ 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<Pair<FeedEntity, Int>>) { + 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, unreadCount: Int, 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 <svg> 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; } </style></head> <body>$bodyContent</body></html> """.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 @@ -78,6 +78,21 @@ fun SettingsScreen(onBack: () -> Unit) { 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) Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <!-- Dusky indigo, matching the app's own dark theme: a "dreaming" backdrop. --> + <path android:fillColor="#2E2350" android:pathData="M0,0h108v108h0z"/> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + An amusing pun on "FeeDreader": a sleepy, bespectacled owl (a "dreader") mid-read, + drifting off ("Zzz"): reading late into the night rather than an aggressive/serious + reader-app mark. +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + + <!-- Ear tufts --> + <path android:fillColor="#B9793A" android:pathData="M40,36 L46,20 L51,37 Z"/> + <path android:fillColor="#B9793A" android:pathData="M68,36 L62,20 L57,37 Z"/> + + <!-- Body --> + <path android:fillColor="#D9A066" android:pathData="M32,61 A22,26 0 1,0 76,61 A22,26 0 1,0 32,61 Z"/> + + <!-- Wings --> + <path android:fillColor="#B9793A" android:pathData="M33,58 A9,15 0 0,0 33,80 A18,20 0 0,1 33,58 Z"/> + <path android:fillColor="#B9793A" android:pathData="M75,58 A9,15 0 0,1 75,80 A18,20 0 0,0 75,58 Z"/> + + <!-- Book, held below the face --> + <path android:fillColor="#EDE3D0" android:pathData="M38,78 L54,74 L54,86 L38,90 Z"/> + <path android:fillColor="#F5EFE2" android:pathData="M70,78 L54,74 L54,86 L70,90 Z"/> + + <!-- Glasses --> + <path + android:strokeColor="#3A2A18" + android:strokeWidth="2.5" + android:pathData="M35,52 A9,9 0 1,0 53,52 A9,9 0 1,0 35,52 Z"/> + <path + android:strokeColor="#3A2A18" + android:strokeWidth="2.5" + android:pathData="M55,52 A9,9 0 1,0 73,52 A9,9 0 1,0 55,52 Z"/> + <path + android:strokeColor="#3A2A18" + android:strokeWidth="2.5" + android:pathData="M53,51 L55,51"/> + + <!-- Eyes: drowsy half-lidded, so the "dreamy" read is a heavy-eyed one, not wide awake --> + <path android:fillColor="#FFFFFF" android:pathData="M39,52 A5,5 0 1,0 49,52 A5,5 0 1,0 39,52 Z"/> + <path android:fillColor="#FFFFFF" android:pathData="M59,52 A5,5 0 1,0 69,52 A5,5 0 1,0 59,52 Z"/> + <path android:fillColor="#2B1F14" android:pathData="M42,52 A2.2,2.2 0 1,0 46.4,52 A2.2,2.2 0 1,0 42,52 Z"/> + <path android:fillColor="#2B1F14" android:pathData="M62,52 A2.2,2.2 0 1,0 66.4,52 A2.2,2.2 0 1,0 62,52 Z"/> + <path android:fillColor="#D9A066" android:pathData="M38,45 A6,3 0 0,1 50,45 L50,47 A6,3 0 0,0 38,47 Z"/> + <path android:fillColor="#D9A066" android:pathData="M58,45 A6,3 0 0,1 70,45 L70,47 A6,3 0 0,0 58,47 Z"/> + + <!-- Beak --> + <path android:fillColor="#E8871E" android:pathData="M51,60 L57,60 L54,66 Z"/> + + <!-- Zzz drifting off, top right --> + <path + android:strokeColor="#B9A6E0" + android:strokeWidth="2.4" + android:strokeLineCap="square" + android:pathData="M74,26 L82,26 L74,34 L82,34"/> + <path + android:strokeColor="#B9A6E0" + android:strokeWidth="1.9" + android:strokeLineCap="square" + android:pathData="M84,17 L90,17 L84,23 L90,23"/> + <path + android:strokeColor="#B9A6E0" + android:strokeWidth="1.5" + android:strokeLineCap="square" + android:pathData="M91,10 L95,10 L91,14 L95,14"/> +</vector> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background"/> + <foreground android:drawable="@drawable/ic_launcher_foreground"/> +</adaptive-icon> 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 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <string name="app_name">Feeds</string> + <string name="app_name">FeeDreader</string> </resources> |
