diff options
Diffstat (limited to 'app/src/main/java/org/terst/feedreader')
9 files changed, 158 insertions, 18 deletions
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 8579341..e2f6c1a 100644 --- a/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt @@ -4,10 +4,12 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.room.migration.Migration @Database( entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class], - version = 1, + version = 2, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -18,12 +20,23 @@ abstract class AppDatabase : RoomDatabase() { companion object { @Volatile private var instance: AppDatabase? = null + // v2: added ArticleEntity.imageUrl (thumbnail support). Real migrations, + // not fallbackToDestructiveMigration, since this app already has real + // subscriptions/read-state on-device during active development -- + // wiping the DB on every schema tweak would mean re-importing OPML + // and losing read history each time. + private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE articles ADD COLUMN imageUrl TEXT") + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "feedreader.db" - ).build().also { instance = it } + ).addMigrations(MIGRATION_1_2).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 0a41f0c..2240ff4 100644 --- a/app/src/main/java/org/terst/feedreader/data/Daos.kt +++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt @@ -64,6 +64,13 @@ interface ArticleDao { @Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId") suspend fun markAllReadForFeed(feedId: String) + // Run when unsubscribing from a feed. Saved articles are deliberately kept + // regardless (isSaved is a separate, durable "keep forever" concept, + // independent of subscription) -- everything else is deleted so it doesn't + // linger forever as an orphaned row cluttering the cross-feed Unread view. + @Query("DELETE FROM articles WHERE feedId = :feedId AND isSaved = 0") + suspend fun deleteUnsavedByFeed(feedId: String) + @Query("UPDATE articles SET extractedContent = :content WHERE id = :id") suspend fun setExtractedContent(id: String, content: String) 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 d71e1eb..190340c 100644 --- a/app/src/main/java/org/terst/feedreader/data/Entities.kt +++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt @@ -25,6 +25,7 @@ data class ArticleEntity( val publishedAt: Long? = null, val feedContentHtml: String? = null, // whatever content the feed itself provided (may be a summary, may be full text) val extractedContent: String? = null, // Readability-extracted full text, filled in on first read if feedContentHtml was thin + val imageUrl: String? = null, // thumbnail, from media:thumbnail/enclosure or the first <img> in the feed content -- see FeedParser val isRead: Boolean = false, val isSaved: Boolean = false, // explicitly kept (independent of read state) -- triggers a Stash save val stashArtifactId: String? = null, // set once successfully archived to Stash; null means not yet saved or save failed diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt index cd337d6..735a707 100644 --- a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt +++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt @@ -16,7 +16,8 @@ data class ParsedItem( val link: String, val author: String?, val publishedAt: Long?, - val contentHtml: String? + val contentHtml: String?, + val imageUrl: String? = null ) data class ParsedFeed( @@ -65,6 +66,7 @@ object FeedParser { var itemDate: Long? = null var itemContent: String? = null var itemDescription: String? = null + var itemImageUrl: String? = null while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { when (eventType) { @@ -74,7 +76,7 @@ object FeedParser { "item" -> { inItem = true itemGuid = null; itemTitle = ""; itemLink = ""; itemAuthor = null - itemDate = null; itemContent = null; itemDescription = null + itemDate = null; itemContent = null; itemDescription = null; itemImageUrl = null } "title" -> { val text = parser.nextText() @@ -90,19 +92,31 @@ object FeedParser { "pubdate" -> if (inItem) itemDate = parseRfc822Date(parser.nextText()) "content:encoded" -> if (inItem) itemContent = parser.nextText() "description" -> if (inItem) itemDescription = parser.nextText() + "media:thumbnail", "media:content" -> if (inItem && itemImageUrl == null) { + val url = parser.getAttributeValue(null, "url") + val type = parser.getAttributeValue(null, "type") + if (url != null && (type == null || type.startsWith("image/"))) itemImageUrl = url + } + "enclosure" -> if (inItem && itemImageUrl == null) { + val url = parser.getAttributeValue(null, "url") + val type = parser.getAttributeValue(null, "type") + if (url != null && type?.startsWith("image/") == true) itemImageUrl = url + } } } XmlPullParser.END_TAG -> { depth-- if (parser.name.lowercase() == "item") { inItem = false + val content = itemContent ?: itemDescription items += ParsedItem( guid = itemGuid ?: itemLink.ifBlank { null }, title = itemTitle, link = itemLink, author = itemAuthor, publishedAt = itemDate, - contentHtml = itemContent ?: itemDescription + contentHtml = content, + imageUrl = itemImageUrl ?: extractFirstImageUrl(content) ) } } @@ -127,6 +141,7 @@ object FeedParser { var entryDate: Long? = null var entryContent: String? = null var entrySummary: String? = null + var entryImageUrl: String? = null while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { when (eventType) { @@ -136,7 +151,7 @@ object FeedParser { "entry" -> { inEntry = true entryId = null; entryTitle = ""; entryLink = ""; entryAuthor = null - entryDate = null; entryContent = null; entrySummary = null + entryDate = null; entryContent = null; entrySummary = null; entryImageUrl = null } "title" -> { val text = parser.nextText() @@ -145,9 +160,13 @@ object FeedParser { "link" -> { val rel = parser.getAttributeValue(null, "rel") val href = parser.getAttributeValue(null, "href") + val type = parser.getAttributeValue(null, "type") if (href != null && (rel == null || rel == "alternate")) { if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href } + if (inEntry && rel == "enclosure" && type?.startsWith("image/") == true && href != null) { + entryImageUrl = href + } } "id" -> if (inEntry) entryId = parser.nextText() "name" -> if (inEntry && entryAuthor == null) entryAuthor = parser.nextText() @@ -155,19 +174,24 @@ object FeedParser { "published" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) ?: entryDate "content" -> if (inEntry) entryContent = parser.nextText() "summary" -> if (inEntry) entrySummary = parser.nextText() + "media:thumbnail" -> if (inEntry && entryImageUrl == null) { + entryImageUrl = parser.getAttributeValue(null, "url") + } } } XmlPullParser.END_TAG -> { depth-- if (parser.name.lowercase() == "entry") { inEntry = false + val content = entryContent ?: entrySummary items += ParsedItem( guid = entryId ?: entryLink.ifBlank { null }, title = entryTitle, link = entryLink, author = entryAuthor, publishedAt = entryDate, - contentHtml = entryContent ?: entrySummary + contentHtml = content, + imageUrl = entryImageUrl ?: extractFirstImageUrl(content) ) } } @@ -177,6 +201,13 @@ object FeedParser { return ParsedFeed(feedTitle, siteUrl, items) } + private val imgTagRegex = Regex("""<img[^>]+src=["']([^"']+)["']""", RegexOption.IGNORE_CASE) + + // Fallback for feeds with no explicit media:thumbnail/enclosure image -- + // most blog feeds just embed the post's lead image directly in the content. + private fun extractFirstImageUrl(html: String?): String? = + html?.let { imgTagRegex.find(it)?.groupValues?.get(1) } + private val rfc822Formatters = listOf( DateTimeFormatter.RFC_1123_DATE_TIME, DateTimeFormatterBuilder() 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 1e053af..5d19c90 100644 --- a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt @@ -57,11 +57,16 @@ fun AddFeedScreen(onDone: () -> Unit) { val imported = withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri)?.use { stream -> Opml.parse(stream) } ?: emptyList() } - for (feed in imported) { + // A feed cross-listed under multiple OPML category folders (common when + // exported from a reader with folders/labels) appears once per folder here -- + // dedupe by feed URL before reporting a count, or "108 feeds" would be reported + // as some larger number even though upsert already collapses them to one row each. + val uniqueFeeds = imported.distinctBy { it.xmlUrl } + for (feed in uniqueFeeds) { db.feedDao().upsert(FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl)) } FeedFetchWorker.fetchNow(context) - status = "Imported ${imported.size} feed(s)" + status = "Imported ${uniqueFeeds.size} feed(s)" isBusy = false } } 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 d9f3d8a..22130f9 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -5,10 +5,13 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.List import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -23,8 +26,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import kotlinx.coroutines.flow.combine import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.ArticleEntity @@ -38,7 +45,8 @@ import java.util.Date fun ArticleListScreen( feedId: String, onOpenArticle: (String) -> Unit, - onBack: () -> Unit + onBack: () -> Unit, + onOpenFeedList: () -> Unit ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } @@ -70,8 +78,16 @@ fun ArticleListScreen( ) }, navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + // Unread is the app's landing screen (root of the back stack), so there's + // nothing to pop back to -- show a way forward to the feed list instead. + if (feedId == UNREAD_FEED_ID) { + IconButton(onClick = onOpenFeedList) { + Icon(Icons.Default.List, contentDescription = "Feeds") + } + } else { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } } } ) @@ -94,6 +110,19 @@ fun ArticleListScreen( private fun ArticleRow(article: ArticleEntity, highlighted: Boolean, onClick: () -> Unit) { val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } ListItem( + // Passed as null (not an empty/conditional lambda) when there's no image, so + // ListItem's own layout collapses the leading slot entirely for text-only rows + // instead of reserving blank space for every row regardless of content. + leadingContent = article.imageUrl?.let { url -> + { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(56.dp).clip(RoundedCornerShape(6.dp)) + ) + } + }, headlineContent = { Text( article.title, 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 85c8d3c..a4852c6 100644 --- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -1,16 +1,21 @@ package org.terst.feedreader.ui import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -21,16 +26,21 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch import org.terst.feedreader.data.AppDatabase import org.terst.feedreader.data.FeedEntity import org.terst.feedreader.work.FeedFetchWorker @@ -46,6 +56,7 @@ fun FeedListScreen( ) { val context = LocalContext.current val db = remember { AppDatabase.get(context) } + val scope = rememberCoroutineScope() val feeds by remember { combine(db.feedDao().observeAll(), db.articleDao().observeUnreadCounts()) { feeds, counts -> @@ -56,6 +67,27 @@ fun FeedListScreen( val totalUnread = feeds.sumOf { it.second } var menuExpanded by remember { mutableStateOf(false) } + var pendingDelete by remember { mutableStateOf<FeedEntity?>(null) } + + pendingDelete?.let { feed -> + AlertDialog( + onDismissRequest = { pendingDelete = null }, + title = { Text("Unsubscribe from ${feed.title.ifBlank { feed.id }}?") }, + text = { Text("Removes its articles. Anything you've saved is kept.") }, + confirmButton = { + TextButton(onClick = { + scope.launch { + db.articleDao().deleteUnsavedByFeed(feed.id) + db.feedDao().delete(feed.id) + } + pendingDelete = null + }) { Text("Unsubscribe") } + }, + dismissButton = { + TextButton(onClick = { pendingDelete = null }) { Text("Cancel") } + } + ) + } Scaffold( topBar = { @@ -101,7 +133,12 @@ fun FeedListScreen( HorizontalDivider() } items(feeds, key = { it.first.id }) { (feed, unreadCount) -> - FeedRow(feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) }) + FeedRow( + feed = feed, + unreadCount = unreadCount, + onClick = { onOpenFeed(feed.id) }, + onDelete = { pendingDelete = feed } + ) HorizontalDivider() } } @@ -109,7 +146,7 @@ fun FeedListScreen( } @Composable -private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) { +private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit, onDelete: () -> Unit) { ListItem( headlineContent = { Text(feed.title.ifBlank { feed.id }) }, supportingContent = { @@ -117,7 +154,17 @@ private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) { Text("Last fetch failed: ${feed.lastError}") } }, - trailingContent = { if (unreadCount > 0) Text(unreadCount.toString()) }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (unreadCount > 0) { + Text(unreadCount.toString()) + Spacer(modifier = Modifier.width(8.dp)) + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Unsubscribe") + } + } + }, modifier = Modifier.fillMaxWidth().clickable { onClick() } ) } diff --git a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt index 345bede..c080d89 100644 --- a/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt +++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavType @@ -29,7 +30,8 @@ class MainActivity : ComponentActivity() { FeedFetchWorker.schedulePeriodic(applicationContext) setContent { - MaterialTheme { + // Dark by default -- per user request, not following system light/dark for now. + MaterialTheme(colorScheme = darkColorScheme()) { Surface(modifier = Modifier.fillMaxSize()) { FeedReaderApp() } @@ -41,7 +43,10 @@ class MainActivity : ComponentActivity() { @Composable fun FeedReaderApp() { val navController = rememberNavController() - NavHost(navController = navController, startDestination = "feeds") { + // Unread is the landing screen (per user request) -- it has no "back" affordance of its + // own since it's the root of the back stack; ArticleListScreen instead shows a way to + // reach the feed list from there (a forward navigation, not a pop). + NavHost(navController = navController, startDestination = "articles/${encodeNavArg(UNREAD_FEED_ID)}") { composable("feeds") { FeedListScreen( onOpenFeed = { feedId -> navController.navigate("articles/${encodeNavArg(feedId)}") }, @@ -59,7 +64,8 @@ fun FeedReaderApp() { ArticleListScreen( feedId = feedId, onOpenArticle = { articleId -> navController.navigate("reader/${encodeNavArg(articleId)}") }, - onBack = { navController.popBackStack() } + onBack = { navController.popBackStack() }, + onOpenFeedList = { navController.navigate("feeds") } ) } composable( diff --git a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt index 560b3d8..5ec6bab 100644 --- a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt +++ b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt @@ -66,6 +66,7 @@ class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWor author = item.author, publishedAt = item.publishedAt, feedContentHtml = item.contentHtml, + imageUrl = item.imageUrl, createdAt = now ) } |
