summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-23 07:38:28 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-23 07:38:28 +0000
commitecdb45778e274126737aa87e27ed1b1c00cda01c (patch)
treeb632e53bc91475ba49094539e04b51e7a5eaa41c
parent3bec692d9613b9b3e688441e5945424bd0fe20f0 (diff)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
-rw-r--r--BACKLOG.md40
-rw-r--r--app/src/main/AndroidManifest.xml4
-rw-r--r--app/src/main/java/org/terst/feedreader/data/AppDatabase.kt13
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt9
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Entities.kt1
-rw-r--r--app/src/main/java/org/terst/feedreader/opml/Opml.kt53
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt32
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt7
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt12
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt71
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt116
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt10
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt15
-rw-r--r--app/src/main/res/drawable/ic_launcher_background.xml9
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml69
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--app/src/main/res/values/strings.xml2
18 files changed, 389 insertions, 84 deletions
diff --git a/BACKLOG.md b/BACKLOG.md
index 6ed83cd..559113c 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -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>