summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/AppDatabase.kt17
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt7
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Entities.kt1
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FeedParser.kt41
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt9
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt35
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt53
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt12
-rw-r--r--app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt1
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
)
}