diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 08:08:58 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-07-22 08:08:58 +0000 |
| commit | d8ce4b3c0f383e844b125d2d3abd618715bb3c89 (patch) | |
| tree | b586ff9b42392f2c44645773c2a2f2aadb9db406 /app/src/main/java/org/terst | |
Initial commit: Feeds, an Android RSS reader
Feed subscriptions (RSS 2.0 + Atom, hand-rolled parser), OPML
import/export, WorkManager periodic fetch with conditional GET,
Room-backed local storage (feeds/articles/filter rules), keyword
mute/highlight filtering.
Reader mode: on-device extraction via Mozilla's Readability.js
(vendored from github.com/mozilla/readability, pinned to release
0.6.0) run in a hidden WebView, for feed articles whose provided
content is too thin to be the real article body.
Save: explicit action (from a feed article, or the Android share-sheet
target for any URL) archives to Stash via POST /artifacts?sync=true,
while a local on-device copy (Readability-extracted) is always kept so
saved articles read offline regardless of Stash's reachability.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app/src/main/java/org/terst')
18 files changed, 1713 insertions, 0 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 new file mode 100644 index 0000000..8579341 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/AppDatabase.kt @@ -0,0 +1,29 @@ +package org.terst.feedreader.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [FeedEntity::class, ArticleEntity::class, FilterRuleEntity::class], + version = 1, + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + abstract fun feedDao(): FeedDao + abstract fun articleDao(): ArticleDao + abstract fun filterRuleDao(): FilterRuleDao + + companion object { + @Volatile private var instance: AppDatabase? = null + + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "feedreader.db" + ).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 new file mode 100644 index 0000000..0a41f0c --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt @@ -0,0 +1,93 @@ +package org.terst.feedreader.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface FeedDao { + @Query("SELECT * FROM feeds ORDER BY title ASC") + fun observeAll(): Flow<List<FeedEntity>> + + @Query("SELECT * FROM feeds ORDER BY title ASC") + suspend fun getAll(): List<FeedEntity> + + @Query("SELECT * FROM feeds WHERE id = :id") + suspend fun getById(id: String): FeedEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(feed: FeedEntity) + + @Update + suspend fun update(feed: FeedEntity) + + @Query("DELETE FROM feeds WHERE id = :id") + suspend fun delete(id: String) +} + +data class FeedUnreadCount(val feedId: String, val count: Int) + +@Dao +interface ArticleDao { + // Unread-first, newest-first -- the default "what's new" view across every feed. + @Query("SELECT * FROM articles WHERE isRead = 0 ORDER BY publishedAt DESC") + fun observeUnread(): Flow<List<ArticleEntity>> + + @Query("SELECT feedId, COUNT(*) as count FROM articles WHERE isRead = 0 AND feedId IS NOT NULL GROUP BY feedId") + fun observeUnreadCounts(): Flow<List<FeedUnreadCount>> + + @Query("SELECT * FROM articles WHERE feedId = :feedId ORDER BY publishedAt DESC") + fun observeByFeed(feedId: String): Flow<List<ArticleEntity>> + + @Query("SELECT * FROM articles WHERE isSaved = 1 ORDER BY createdAt DESC") + fun observeSaved(): Flow<List<ArticleEntity>> + + @Query("SELECT * FROM articles WHERE id = :id") + suspend fun getById(id: String): ArticleEntity? + + // IGNORE, not REPLACE: a re-fetch of a feed re-parses items we already have: + // this keeps the existing row (read state, extracted content, saved state) + // untouched instead of clobbering it with the freshly-parsed feed data. + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertNew(articles: List<ArticleEntity>): List<Long> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(article: ArticleEntity) + + @Query("UPDATE articles SET isRead = :read WHERE id = :id") + suspend fun setRead(id: String, read: Boolean) + + @Query("UPDATE articles SET isRead = 1 WHERE feedId = :feedId") + suspend fun markAllReadForFeed(feedId: String) + + @Query("UPDATE articles SET extractedContent = :content WHERE id = :id") + suspend fun setExtractedContent(id: String, content: String) + + @Query("UPDATE articles SET isSaved = :saved WHERE id = :id") + suspend fun setSaved(id: String, saved: Boolean) + + @Query("UPDATE articles SET stashArtifactId = :artifactId WHERE id = :id") + suspend fun setStashArtifactId(id: String, artifactId: String) + + @Delete + suspend fun delete(article: ArticleEntity) +} + +@Dao +interface FilterRuleDao { + @Query("SELECT * FROM filter_rules ORDER BY createdAt DESC") + fun observeAll(): Flow<List<FilterRuleEntity>> + + @Query("SELECT * FROM filter_rules") + suspend fun getAll(): List<FilterRuleEntity> + + @Insert + suspend fun insert(rule: FilterRuleEntity): Long + + @Query("DELETE FROM filter_rules WHERE id = :id") + suspend fun delete(id: Long) +} diff --git a/app/src/main/java/org/terst/feedreader/data/Entities.kt b/app/src/main/java/org/terst/feedreader/data/Entities.kt new file mode 100644 index 0000000..d71e1eb --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/Entities.kt @@ -0,0 +1,43 @@ +package org.terst.feedreader.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "feeds") +data class FeedEntity( + @PrimaryKey val id: String, // the feed URL itself -- naturally unique, no need for a separate id scheme + val title: String, + val siteUrl: String? = null, // the human-facing site link, if the feed provides one (distinct from the feed XML URL) + val lastFetchedAt: Long? = null, + 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 +) + +@Entity(tableName = "articles") +data class ArticleEntity( + @PrimaryKey val id: String, // guid if the feed provides one, else a hash of the link + @ColumnInfo(index = true) val feedId: String?, // null for a standalone article saved via the share-sheet target, not tied to any feed + val title: String, + val link: String, + val author: String? = null, + 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 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 + val createdAt: Long +) + +enum class FilterAction { MUTE, HIGHLIGHT } + +@Entity(tableName = "filter_rules") +data class FilterRuleEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val pattern: String, // plain substring match, case-insensitive -- see FilterRuleEngine + val action: FilterAction, + val feedId: String? = null, // null applies globally across all feeds + val createdAt: Long +) diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt new file mode 100644 index 0000000..cd337d6 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt @@ -0,0 +1,207 @@ +package org.terst.feedreader.data + +import android.util.Xml +import org.xmlpull.v1.XmlPullParser +import java.io.InputStream +import java.time.OffsetDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField +import java.util.Locale + +data class ParsedItem( + val guid: String?, + val title: String, + val link: String, + val author: String?, + val publishedAt: Long?, + val contentHtml: String? +) + +data class ParsedFeed( + val title: String, + val siteUrl: String?, + val items: List<ParsedItem> +) + +/** + * Hand-rolled RSS 2.0 + Atom parser -- deliberately not a third-party library + * (rome, etc.), per the "simple design" scope: only the fields this app + * actually uses (title/link/guid/author/date/content), nothing else. + */ +object FeedParser { + + fun parse(input: InputStream): ParsedFeed { + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(input, null) + + // Peek the root element to decide which dialect we're in. + var eventType = parser.eventType + while (eventType != XmlPullParser.START_TAG) { + eventType = parser.next() + } + return when (parser.name.lowercase()) { + "rss" -> parseRss(parser) + "feed" -> parseAtom(parser) + else -> throw IllegalArgumentException("Unrecognized feed root element: ${parser.name}") + } + } + + private fun parseRss(parser: XmlPullParser): ParsedFeed { + var feedTitle = "" + var siteUrl: String? = null + val items = mutableListOf<ParsedItem>() + + var eventType = parser.next() + var depth = 1 + // Track whether we're directly inside <channel> (title/link there) vs inside an <item>. + var inItem = false + var itemGuid: String? = null + var itemTitle = "" + var itemLink = "" + var itemAuthor: String? = null + var itemDate: Long? = null + var itemContent: String? = null + var itemDescription: String? = null + + while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { + when (eventType) { + XmlPullParser.START_TAG -> { + depth++ + when (parser.name.lowercase()) { + "item" -> { + inItem = true + itemGuid = null; itemTitle = ""; itemLink = ""; itemAuthor = null + itemDate = null; itemContent = null; itemDescription = null + } + "title" -> { + val text = parser.nextText() + if (inItem) itemTitle = text else feedTitle = text + } + "link" -> { + val text = parser.nextText() + if (inItem) itemLink = text else if (siteUrl == null) siteUrl = text + } + "guid" -> if (inItem) itemGuid = parser.nextText() + "author" -> if (inItem) itemAuthor = parser.nextText() + "dc:creator" -> if (inItem) itemAuthor = parser.nextText() + "pubdate" -> if (inItem) itemDate = parseRfc822Date(parser.nextText()) + "content:encoded" -> if (inItem) itemContent = parser.nextText() + "description" -> if (inItem) itemDescription = parser.nextText() + } + } + XmlPullParser.END_TAG -> { + depth-- + if (parser.name.lowercase() == "item") { + inItem = false + items += ParsedItem( + guid = itemGuid ?: itemLink.ifBlank { null }, + title = itemTitle, + link = itemLink, + author = itemAuthor, + publishedAt = itemDate, + contentHtml = itemContent ?: itemDescription + ) + } + } + } + if (depth > 0) eventType = parser.next() + } + return ParsedFeed(feedTitle, siteUrl, items) + } + + private fun parseAtom(parser: XmlPullParser): ParsedFeed { + var feedTitle = "" + var siteUrl: String? = null + val items = mutableListOf<ParsedItem>() + + var eventType = parser.next() + var depth = 1 + var inEntry = false + var entryId: String? = null + var entryTitle = "" + var entryLink = "" + var entryAuthor: String? = null + var entryDate: Long? = null + var entryContent: String? = null + var entrySummary: String? = null + + while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) { + when (eventType) { + XmlPullParser.START_TAG -> { + depth++ + when (parser.name.lowercase()) { + "entry" -> { + inEntry = true + entryId = null; entryTitle = ""; entryLink = ""; entryAuthor = null + entryDate = null; entryContent = null; entrySummary = null + } + "title" -> { + val text = parser.nextText() + if (inEntry) entryTitle = text else feedTitle = text + } + "link" -> { + val rel = parser.getAttributeValue(null, "rel") + val href = parser.getAttributeValue(null, "href") + if (href != null && (rel == null || rel == "alternate")) { + if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href + } + } + "id" -> if (inEntry) entryId = parser.nextText() + "name" -> if (inEntry && entryAuthor == null) entryAuthor = parser.nextText() + "updated" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) + "published" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) ?: entryDate + "content" -> if (inEntry) entryContent = parser.nextText() + "summary" -> if (inEntry) entrySummary = parser.nextText() + } + } + XmlPullParser.END_TAG -> { + depth-- + if (parser.name.lowercase() == "entry") { + inEntry = false + items += ParsedItem( + guid = entryId ?: entryLink.ifBlank { null }, + title = entryTitle, + link = entryLink, + author = entryAuthor, + publishedAt = entryDate, + contentHtml = entryContent ?: entrySummary + ) + } + } + } + if (depth > 0) eventType = parser.next() + } + return ParsedFeed(feedTitle, siteUrl, items) + } + + private val rfc822Formatters = listOf( + DateTimeFormatter.RFC_1123_DATE_TIME, + DateTimeFormatterBuilder() + .appendPattern("EEE, d MMM yyyy HH:mm:ss ") + .optionalStart().appendPattern("zzz").optionalEnd() + .optionalStart().appendOffset("+HHMM", "+0000").optionalEnd() + .parseDefaulting(ChronoField.OFFSET_SECONDS, 0) + .toFormatter(Locale.US) + ) + + private fun parseRfc822Date(raw: String): Long? { + val trimmed = raw.trim() + for (formatter in rfc822Formatters) { + try { + return ZonedDateTime.parse(trimmed, formatter).toInstant().toEpochMilli() + } catch (_: Exception) { + // try the next formatter + } + } + return null + } + + private fun parseIso8601Date(raw: String): Long? = try { + OffsetDateTime.parse(raw.trim()).toInstant().toEpochMilli() + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt new file mode 100644 index 0000000..c32e8e9 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/FilterRuleEngine.kt @@ -0,0 +1,24 @@ +package org.terst.feedreader.data + +/** How a single article is affected by the current set of filter rules. */ +enum class FilterResult { NORMAL, HIGHLIGHTED, MUTED } + +/** + * Plain case-insensitive substring matching against the article title -- + * deliberately not regex, per the "simple design" scope. A rule with a + * non-null feedId only applies to that feed; a null feedId applies + * globally. MUTE wins over HIGHLIGHT if both match (hiding takes priority + * over drawing attention). + */ +object FilterRuleEngine { + fun evaluate(article: ArticleEntity, rules: List<FilterRuleEntity>): FilterResult { + val applicable = rules.filter { it.feedId == null || it.feedId == article.feedId } + val title = article.title.lowercase() + + val muted = applicable.any { it.action == FilterAction.MUTE && title.contains(it.pattern.lowercase()) } + if (muted) return FilterResult.MUTED + + val highlighted = applicable.any { it.action == FilterAction.HIGHLIGHT && title.contains(it.pattern.lowercase()) } + return if (highlighted) FilterResult.HIGHLIGHTED else FilterResult.NORMAL + } +} diff --git a/app/src/main/java/org/terst/feedreader/data/Net.kt b/app/src/main/java/org/terst/feedreader/data/Net.kt new file mode 100644 index 0000000..d3720a0 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/Net.kt @@ -0,0 +1,8 @@ +package org.terst.feedreader.data + +import okhttp3.OkHttpClient + +/** Shared OkHttp client -- one connection pool for the whole app. */ +object Net { + val client: OkHttpClient by lazy { OkHttpClient() } +} diff --git a/app/src/main/java/org/terst/feedreader/data/Settings.kt b/app/src/main/java/org/terst/feedreader/data/Settings.kt new file mode 100644 index 0000000..b4af70e --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/data/Settings.kt @@ -0,0 +1,34 @@ +package org.terst.feedreader.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +val Context.settingsDataStore by preferencesDataStore(name = "settings") + +private object SettingsKeys { + val STASH_BASE_URL = stringPreferencesKey("stash_base_url") + val STASH_API_KEY = stringPreferencesKey("stash_api_key") +} + +/** Stash connection settings, configured on the Settings screen. Both blank until set. */ +class Settings(private val context: Context) { + val stashBaseUrl: Flow<String> = + context.settingsDataStore.data.map { it[SettingsKeys.STASH_BASE_URL] ?: "" } + val stashApiKey: Flow<String> = + context.settingsDataStore.data.map { it[SettingsKeys.STASH_API_KEY] ?: "" } + + suspend fun stashBaseUrlNow(): String = stashBaseUrl.first() + suspend fun stashApiKeyNow(): String = stashApiKey.first() + + suspend fun setStash(baseUrl: String, apiKey: String) { + context.settingsDataStore.edit { prefs -> + prefs[SettingsKeys.STASH_BASE_URL] = baseUrl.trim().trimEnd('/') + prefs[SettingsKeys.STASH_API_KEY] = apiKey.trim() + } + } +} diff --git a/app/src/main/java/org/terst/feedreader/opml/Opml.kt b/app/src/main/java/org/terst/feedreader/opml/Opml.kt new file mode 100644 index 0000000..65510fb --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/opml/Opml.kt @@ -0,0 +1,71 @@ +package org.terst.feedreader.opml + +import android.util.Xml +import org.terst.feedreader.data.FeedEntity +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?) + +/** + * 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. + */ +object Opml { + + fun parse(input: InputStream): List<OpmlFeed> { + val parser = Xml.newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(input, null) + + val feeds = mutableListOf<OpmlFeed>() + 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) + } + } + eventType = parser.next() + } + return feeds + } + + fun write(feeds: List<FeedEntity>, output: OutputStream) { + val serializer = Xml.newSerializer() + serializer.setOutput(output, "UTF-8") + serializer.startDocument("UTF-8", true) + serializer.startTag(null, "opml") + serializer.attribute(null, "version", "2.0") + + serializer.startTag(null, "head") + serializer.startTag(null, "title") + serializer.text("Feeds subscriptions") + serializer.endTag(null, "title") + serializer.endTag(null, "head") + + serializer.startTag(null, "body") + for (feed in feeds) { + serializer.startTag(null, "outline") + serializer.attribute(null, "text", feed.title) + serializer.attribute(null, "title", feed.title) + serializer.attribute(null, "type", "rss") + serializer.attribute(null, "xmlUrl", feed.id) + feed.siteUrl?.let { serializer.attribute(null, "htmlUrl", it) } + serializer.endTag(null, "outline") + } + serializer.endTag(null, "body") + + serializer.endTag(null, "opml") + serializer.endDocument() + serializer.flush() + } +} diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt new file mode 100644 index 0000000..9be08b0 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt @@ -0,0 +1,95 @@ +package org.terst.feedreader.readability + +import android.annotation.SuppressLint +import android.content.Context +import android.webkit.WebView +import android.webkit.WebViewClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.coroutines.resume + +@Serializable +data class ExtractedArticle( + val title: String? = null, + val content: String? = null, // HTML + val textContent: String? = null, + val excerpt: String? = null, + val error: String? = null +) + +/** + * Runs Mozilla's Readability.js (vendored in assets/Readability.js, pinned + * to release 0.6.0) against a page loaded in a hidden WebView, for feed + * articles whose feedContentHtml is missing or too short to be the real + * article body. WebView APIs are main-thread-only, so this hops to + * Dispatchers.Main for the load+eval and suspends the calling coroutine + * until the JS callback fires (or the timeout elapses). + */ +object ReaderExtractor { + private const val TIMEOUT_MS = 15_000L + private val json = Json { ignoreUnknownKeys = true } + + suspend fun extract(context: Context, url: String): ExtractedArticle? = + withTimeoutOrNull(TIMEOUT_MS) { + withContext(Dispatchers.Main) { + extractOnMainThread(context, url) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private suspend fun extractOnMainThread(context: Context, url: String): ExtractedArticle? = + suspendCancellableCoroutine { continuation -> + val webView = WebView(context) + webView.settings.javaScriptEnabled = true + + fun finish(result: ExtractedArticle?) { + if (continuation.isActive) continuation.resume(result) + webView.stopLoading() + webView.destroy() + } + + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, finishedUrl: String) { + val readabilityJs = context.assets.open("Readability.js").bufferedReader().use { it.readText() } + val script = """ + (function() { + try { + $readabilityJs + var article = new Readability(document.cloneNode(true)).parse(); + return JSON.stringify(article); + } catch (e) { + return JSON.stringify({error: e.toString()}); + } + })(); + """.trimIndent() + + view.evaluateJavascript(script) { rawResult -> + // evaluateJavascript's callback value is itself JSON-encoded (our + // JSON.stringify(...) string comes back wrapped in an extra layer + // of JSON string-quoting), so this needs decoding twice. + val unwrapped = runCatching { json.decodeFromString<String>(rawResult) }.getOrNull() + val article = unwrapped?.let { + runCatching { json.decodeFromString<ExtractedArticle>(it) }.getOrNull() + } + finish(article) + } + } + + override fun onReceivedError( + view: WebView, + errorCode: Int, + description: String?, + failingUrl: String? + ) { + finish(ExtractedArticle(error = description ?: "Failed to load $failingUrl")) + } + } + + continuation.invokeOnCancellation { webView.destroy() } + webView.loadUrl(url) + } +} diff --git a/app/src/main/java/org/terst/feedreader/stash/StashClient.kt b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt new file mode 100644 index 0000000..28cc2b3 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/stash/StashClient.kt @@ -0,0 +1,82 @@ +package org.terst.feedreader.stash + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +private val json = Json { ignoreUnknownKeys = true } + +@Serializable +private data class CreateArtifactRequest( + val type: String = "url", + @SerialName("source_url") val sourceUrl: String, + val title: String? = null +) + +@Serializable +data class ArtifactResponse( + val id: String, + val title: String? = null, + @SerialName("content_md") val contentMd: String? = null, + @SerialName("processing_steps") val processingSteps: List<String> = emptyList() +) + +@Serializable +private data class DuplicateErrorResponse(@SerialName("existing_id") val existingId: String) + +sealed class StashSaveResult { + data class Saved(val artifact: ArtifactResponse) : StashSaveResult() + data class AlreadySaved(val existingId: String) : StashSaveResult() + data class Failed(val message: String) : StashSaveResult() +} + +/** + * Client for the parts of Stash's API this app needs: POST /artifacts to + * archive an article. Uses sync=true so content_md comes back in the same + * response (server runs extract+summary_lite inline, up to ~30s) -- + * tags/full-summary/embeddings still fill in async server-side afterward, + * but this app doesn't need to wait on or poll for those. + */ +class StashClient( + private val client: OkHttpClient, + private val baseUrl: String, + private val apiKey: String +) { + suspend fun saveArticle(url: String, title: String?): StashSaveResult = withContext(Dispatchers.IO) { + if (baseUrl.isBlank() || apiKey.isBlank()) { + return@withContext StashSaveResult.Failed("Stash isn't configured (set base URL and API key in Settings)") + } + + val body = json.encodeToString( + CreateArtifactRequest.serializer(), + CreateArtifactRequest(sourceUrl = url, title = title) + ).toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("$baseUrl/artifacts?sync=true") + .header("X-API-Key", apiKey) + .post(body) + .build() + + runCatching { + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + when { + response.code == 201 || response.code == 200 -> + StashSaveResult.Saved(json.decodeFromString(ArtifactResponse.serializer(), responseBody)) + response.code == 409 -> + StashSaveResult.AlreadySaved( + json.decodeFromString(DuplicateErrorResponse.serializer(), responseBody).existingId + ) + else -> StashSaveResult.Failed("HTTP ${response.code}") + } + } + }.getOrElse { StashSaveResult.Failed(it.message ?: "Network error") } + } +} diff --git a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt new file mode 100644 index 0000000..1e053af --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt @@ -0,0 +1,152 @@ +package org.terst.feedreader.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Request +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FeedEntity +import org.terst.feedreader.data.FeedParser +import org.terst.feedreader.data.Net +import org.terst.feedreader.opml.Opml +import org.terst.feedreader.work.FeedFetchWorker + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddFeedScreen(onDone: () -> Unit) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + val scope = rememberCoroutineScope() + + var url by remember { mutableStateOf("") } + var status by remember { mutableStateOf<String?>(null) } + var isBusy by remember { mutableStateOf(false) } + + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + isBusy = true + scope.launch { + val imported = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { stream -> Opml.parse(stream) } ?: emptyList() + } + for (feed in imported) { + db.feedDao().upsert(FeedEntity(id = feed.xmlUrl, title = feed.title, siteUrl = feed.htmlUrl)) + } + FeedFetchWorker.fetchNow(context) + status = "Imported ${imported.size} feed(s)" + isBusy = false + } + } + + val exportLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/x-opml")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + withContext(Dispatchers.IO) { + val feeds = db.feedDao().getAll() + context.contentResolver.openOutputStream(uri)?.use { out -> Opml.write(feeds, out) } + } + status = "Exported subscriptions" + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Feed") }, + navigationIcon = { + IconButton(onClick = onDone) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { + item { + Column(modifier = Modifier.padding(16.dp)) { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("Feed URL") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(4.dp)) + Button( + enabled = url.isNotBlank() && !isBusy, + onClick = { + isBusy = true + val feedUrl = url.trim() + scope.launch { + status = addFeedByUrl(context, db, feedUrl) + if (status?.startsWith("Added") == true) url = "" + isBusy = false + } + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Add") } + + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = { importLauncher.launch("*/*") }, modifier = Modifier.fillMaxWidth()) { + Text("Import OPML") + } + Spacer(modifier = Modifier.height(4.dp)) + OutlinedButton(onClick = { exportLauncher.launch("subscriptions.opml") }, modifier = Modifier.fillMaxWidth()) { + Text("Export OPML") + } + + status?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(it) + } + } + } + } + } +} + +private suspend fun addFeedByUrl(context: android.content.Context, db: AppDatabase, feedUrl: String): String = + withContext(Dispatchers.IO) { + try { + val response = Net.client.newCall(Request.Builder().url(feedUrl).build()).execute() + response.use { + if (!it.isSuccessful) return@withContext "Couldn't fetch feed: HTTP ${it.code}" + val body = it.body ?: return@withContext "Empty response" + val parsed = FeedParser.parse(body.byteStream()) + db.feedDao().upsert( + FeedEntity(id = feedUrl, title = parsed.title.ifBlank { feedUrl }, siteUrl = parsed.siteUrl) + ) + } + FeedFetchWorker.fetchNow(context) + "Added" + } catch (e: Exception) { + "Couldn't add feed: ${e.message}" + } + } diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt new file mode 100644 index 0000000..d9f3d8a --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt @@ -0,0 +1,109 @@ +package org.terst.feedreader.ui + +import androidx.compose.foundation.background +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +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.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextDecoration +import kotlinx.coroutines.flow.combine +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.FilterResult +import org.terst.feedreader.data.FilterRuleEngine +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArticleListScreen( + feedId: String, + onOpenArticle: (String) -> Unit, + onBack: () -> Unit +) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + + val articleFlow = remember(feedId) { + when (feedId) { + UNREAD_FEED_ID -> db.articleDao().observeUnread() + SAVED_FEED_ID -> db.articleDao().observeSaved() + else -> db.articleDao().observeByFeed(feedId) + } + } + val visibleArticles by remember(feedId) { + combine(articleFlow, db.filterRuleDao().observeAll()) { articles, rules -> + articles.map { it to FilterRuleEngine.evaluate(it, rules) } + .filter { (_, result) -> result != FilterResult.MUTED } + } + }.collectAsState(initial = emptyList()) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + when (feedId) { + UNREAD_FEED_ID -> "Unread" + SAVED_FEED_ID -> "Saved" + else -> "Articles" + } + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + items(visibleArticles, key = { it.first.id }) { (article, result) -> + ArticleRow( + article = article, + highlighted = result == FilterResult.HIGHLIGHTED, + onClick = { onOpenArticle(article.id) } + ) + HorizontalDivider() + } + } + } +} + +@Composable +private fun ArticleRow(article: ArticleEntity, highlighted: Boolean, onClick: () -> Unit) { + val dateText = article.publishedAt?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it)) } + ListItem( + headlineContent = { + Text( + article.title, + textDecoration = if (article.isRead) TextDecoration.LineThrough else null + ) + }, + supportingContent = { if (dateText != null) Text(dateText) }, + modifier = Modifier + .fillMaxWidth() + .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) + .clickable { onClick() } + ) +} diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt new file mode 100644 index 0000000..85c8d3c --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt @@ -0,0 +1,123 @@ +package org.terst.feedreader.ui + +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.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.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.flow.combine +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FeedEntity +import org.terst.feedreader.work.FeedFetchWorker + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FeedListScreen( + onOpenFeed: (String) -> Unit, + onOpenUnread: () -> Unit, + onOpenSaved: () -> Unit, + onAddFeed: () -> Unit, + onSettings: () -> Unit +) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + + val feeds by remember { + combine(db.feedDao().observeAll(), db.articleDao().observeUnreadCounts()) { feeds, counts -> + val countsByFeed = counts.associate { it.feedId to it.count } + feeds.map { it to (countsByFeed[it.id] ?: 0) } + } + }.collectAsState(initial = emptyList()) + + val totalUnread = feeds.sumOf { it.second } + var menuExpanded by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Feeds") }, + actions = { + IconButton(onClick = { FeedFetchWorker.fetchNow(context) }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh all") + } + IconButton(onClick = onSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More") + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + DropdownMenuItem( + text = { Text("Import OPML") }, + onClick = { menuExpanded = false; onAddFeed() } + ) + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAddFeed) { + Icon(Icons.Default.Add, contentDescription = "Add feed") + } + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { + item { + ListItem( + headlineContent = { Text("Unread") }, + trailingContent = { Text(totalUnread.toString()) }, + modifier = Modifier.fillMaxWidth().clickable { onOpenUnread() } + ) + HorizontalDivider() + ListItem( + headlineContent = { Text("Saved") }, + modifier = Modifier.fillMaxWidth().clickable { onOpenSaved() } + ) + HorizontalDivider() + } + items(feeds, key = { it.first.id }) { (feed, unreadCount) -> + FeedRow(feed = feed, unreadCount = unreadCount, onClick = { onOpenFeed(feed.id) }) + HorizontalDivider() + } + } + } +} + +@Composable +private fun FeedRow(feed: FeedEntity, unreadCount: Int, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(feed.title.ifBlank { feed.id }) }, + supportingContent = { + if (feed.lastError != null) { + Text("Last fetch failed: ${feed.lastError}") + } + }, + trailingContent = { if (unreadCount > 0) Text(unreadCount.toString()) }, + 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 new file mode 100644 index 0000000..345bede --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/MainActivity.kt @@ -0,0 +1,79 @@ +package org.terst.feedreader.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import org.terst.feedreader.work.FeedFetchWorker +import java.net.URLDecoder +import java.net.URLEncoder + +const val UNREAD_FEED_ID = "__unread__" +const val SAVED_FEED_ID = "__saved__" + +fun encodeNavArg(value: String): String = URLEncoder.encode(value, "UTF-8") +fun decodeNavArg(value: String): String = URLDecoder.decode(value, "UTF-8") + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + FeedFetchWorker.schedulePeriodic(applicationContext) + + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + FeedReaderApp() + } + } + } + } +} + +@Composable +fun FeedReaderApp() { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = "feeds") { + composable("feeds") { + FeedListScreen( + onOpenFeed = { feedId -> navController.navigate("articles/${encodeNavArg(feedId)}") }, + onOpenUnread = { navController.navigate("articles/${encodeNavArg(UNREAD_FEED_ID)}") }, + onOpenSaved = { navController.navigate("articles/${encodeNavArg(SAVED_FEED_ID)}") }, + onAddFeed = { navController.navigate("addFeed") }, + onSettings = { navController.navigate("settings") } + ) + } + composable( + "articles/{feedId}", + arguments = listOf(navArgument("feedId") { type = NavType.StringType }) + ) { backStackEntry -> + val feedId = decodeNavArg(backStackEntry.arguments?.getString("feedId") ?: UNREAD_FEED_ID) + ArticleListScreen( + feedId = feedId, + onOpenArticle = { articleId -> navController.navigate("reader/${encodeNavArg(articleId)}") }, + onBack = { navController.popBackStack() } + ) + } + composable( + "reader/{articleId}", + arguments = listOf(navArgument("articleId") { type = NavType.StringType }) + ) { backStackEntry -> + val articleId = decodeNavArg(backStackEntry.arguments?.getString("articleId") ?: "") + ReaderScreen(articleId = articleId, onBack = { navController.popBackStack() }) + } + composable("addFeed") { + AddFeedScreen(onDone = { navController.popBackStack() }) + } + composable("settings") { + SettingsScreen(onBack = { navController.popBackStack() }) + } + } +} diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt new file mode 100644 index 0000000..8b320eb --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -0,0 +1,166 @@ +package org.terst.feedreader.ui + +import android.content.Intent +import android.net.Uri +import android.webkit.WebView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.viewinterop.AndroidView +import kotlinx.coroutines.launch +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.Net +import org.terst.feedreader.data.Settings +import org.terst.feedreader.readability.ReaderExtractor +import org.terst.feedreader.stash.StashClient +import org.terst.feedreader.stash.StashSaveResult + +// Below this length (of the plain feed-provided HTML), assume it's a teaser/summary, +// not the real article body, and fall back to on-device Readability extraction. +private const val THIN_CONTENT_THRESHOLD = 500 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReaderScreen(articleId: String, onBack: () -> Unit) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var article by remember { mutableStateOf<ArticleEntity?>(null) } + var displayContent by remember { mutableStateOf<String?>(null) } + var isExtracting by remember { mutableStateOf(false) } + var isSaving by remember { mutableStateOf(false) } + + LaunchedEffect(articleId) { + val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect + article = loaded + db.articleDao().setRead(articleId, true) + + val feedContent = loaded.feedContentHtml + val plainLength = feedContent?.replace(Regex("<[^>]*>"), "")?.trim()?.length ?: 0 + + displayContent = when { + loaded.extractedContent != null -> loaded.extractedContent + plainLength >= THIN_CONTENT_THRESHOLD -> feedContent + else -> { + isExtracting = true + val extracted = ReaderExtractor.extract(context, loaded.link) + isExtracting = false + val content = extracted?.content + if (content != null) { + db.articleDao().setExtractedContent(articleId, content) + content + } else { + feedContent ?: "<p>Couldn't load article content. Try opening in browser.</p>" + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(article?.title.orEmpty(), maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + val currentArticle = article + 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") + } + 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().setSaved(a.id, true) + db.articleDao().setStashArtifactId(a.id, result.artifact.id) + snackbarHostState.showSnackbar("Saved to Stash") + } + is StashSaveResult.AlreadySaved -> { + db.articleDao().setSaved(a.id, true) + db.articleDao().setStashArtifactId(a.id, result.existingId) + snackbarHostState.showSnackbar("Already in Stash") + } + is StashSaveResult.Failed -> + snackbarHostState.showSnackbar("Save failed: ${result.message}") + } + isSaving = false + } + } + ) { + Icon( + if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, + contentDescription = "Save to Stash" + ) + } + } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + val content = displayContent + val baseUrl = article?.link + if (content != null) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = false + loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) + } + }, + update = { webView -> webView.loadDataWithBaseURL(baseUrl, wrapHtml(content), "text/html", "UTF-8", null) } + ) + } else if (isExtracting) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } +} + +private fun wrapHtml(bodyContent: String): String = """ + <html><head><meta name="viewport" content="width=device-width, initial-scale=1"> + <style>body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px; } + img { max-width: 100%; height: auto; }</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 new file mode 100644 index 0000000..a5d3f9c --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt @@ -0,0 +1,162 @@ +package org.terst.feedreader.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +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.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.FilterAction +import org.terst.feedreader.data.FilterRuleEntity +import org.terst.feedreader.data.Settings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit) { + val context = LocalContext.current + val db = remember { AppDatabase.get(context) } + val settings = remember { Settings(context) } + val scope = rememberCoroutineScope() + + var stashUrl by remember { mutableStateOf("") } + var stashKey by remember { mutableStateOf("") } + LaunchedEffect(Unit) { + stashUrl = settings.stashBaseUrlNow() + stashKey = settings.stashApiKeyNow() + } + + var newRulePattern by remember { mutableStateOf("") } + var newRuleAction by remember { mutableStateOf(FilterAction.MUTE) } + var actionMenuExpanded by remember { mutableStateOf(false) } + val rules by db.filterRuleDao().observeAll().collectAsState(initial = emptyList()) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + LazyColumn(modifier = Modifier.fillMaxWidth().padding(padding)) { + item { + Column(modifier = Modifier.padding(16.dp)) { + Text("Stash", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = stashUrl, + onValueChange = { stashUrl = it }, + label = { Text("Base URL") }, + placeholder = { Text("https://stash.example.com") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = stashKey, + onValueChange = { stashKey = it }, + label = { Text("API key") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { scope.launch { settings.setStash(stashUrl, stashKey) } }, + modifier = Modifier.fillMaxWidth() + ) { Text("Save") } + } + HorizontalDivider() + } + + item { + Column(modifier = Modifier.padding(16.dp)) { + Text("Filter rules", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = newRulePattern, + onValueChange = { newRulePattern = it }, + label = { Text("Keyword") }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box { + Button(onClick = { actionMenuExpanded = true }) { + Text(if (newRuleAction == FilterAction.MUTE) "Mute" else "Highlight") + } + DropdownMenu(expanded = actionMenuExpanded, onDismissRequest = { actionMenuExpanded = false }) { + DropdownMenuItem(text = { Text("Mute") }, onClick = { newRuleAction = FilterAction.MUTE; actionMenuExpanded = false }) + DropdownMenuItem(text = { Text("Highlight") }, onClick = { newRuleAction = FilterAction.HIGHLIGHT; actionMenuExpanded = false }) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + enabled = newRulePattern.isNotBlank(), + onClick = { + scope.launch { + db.filterRuleDao().insert( + FilterRuleEntity( + pattern = newRulePattern.trim(), + action = newRuleAction, + createdAt = System.currentTimeMillis() + ) + ) + newRulePattern = "" + } + }, + modifier = Modifier.fillMaxWidth() + ) { Text("Add rule") } + } + } + + items(rules, key = { it.id }) { rule -> + ListItem( + headlineContent = { Text(rule.pattern) }, + supportingContent = { Text(if (rule.action == FilterAction.MUTE) "Mute" else "Highlight") }, + trailingContent = { + IconButton(onClick = { scope.launch { db.filterRuleDao().delete(rule.id) } }) { + Icon(Icons.Default.Delete, contentDescription = "Delete rule") + } + } + ) + HorizontalDivider() + } + } + } +} diff --git a/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt new file mode 100644 index 0000000..656bebc --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt @@ -0,0 +1,113 @@ +package org.terst.feedreader.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.Net +import org.terst.feedreader.data.Settings +import org.terst.feedreader.readability.ReaderExtractor +import org.terst.feedreader.stash.StashClient +import org.terst.feedreader.stash.StashSaveResult +import java.security.MessageDigest + +private val URL_REGEX = Regex("""https?://\S+""") + +/** + * Share-sheet target: a URL shared from any other app lands here. Extracts + * the article on-device (so it's readable offline immediately, independent + * of Stash), stores it locally as a standalone saved article (feedId=null, + * not tied to any subscription), and best-effort archives it to Stash. + */ +class ShareReceiverActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sharedText = intent?.getStringExtra(android.content.Intent.EXTRA_TEXT).orEmpty() + val url = URL_REGEX.find(sharedText)?.value + + setContent { + MaterialTheme { + ShareReceiverContent(url = url, onDone = { finish() }) + } + } + } +} + +@Composable +private fun ShareReceiverContent(url: String?, onDone: () -> Unit) { + val context = LocalContext.current + var status by remember { mutableStateOf(if (url == null) "No link found in shared content" else "Saving…") } + var done by remember { mutableStateOf(url == null) } + + LaunchedEffect(url) { + if (url == null) return@LaunchedEffect + val db = AppDatabase.get(context) + + val extracted = ReaderExtractor.extract(context, url) + val id = MessageDigest.getInstance("SHA-256").digest(url.toByteArray()).joinToString("") { "%02x".format(it) } + db.articleDao().upsert( + ArticleEntity( + id = id, + feedId = null, + title = extracted?.title ?: url, + link = url, + extractedContent = extracted?.content, + isSaved = true, + createdAt = System.currentTimeMillis() + ) + ) + + val settings = Settings(context) + val client = StashClient(Net.client, settings.stashBaseUrlNow(), settings.stashApiKeyNow()) + status = when (val result = client.saveArticle(url, extracted?.title)) { + is StashSaveResult.Saved -> { + db.articleDao().setStashArtifactId(id, result.artifact.id) + "Saved for offline reading and archived to Stash" + } + is StashSaveResult.AlreadySaved -> { + db.articleDao().setStashArtifactId(id, result.existingId) + "Saved for offline reading (already in Stash)" + } + is StashSaveResult.Failed -> + "Saved for offline reading. Stash archive failed: ${result.message}" + } + done = true + } + + Column(modifier = Modifier.padding(24.dp)) { + Card(modifier = Modifier.padding(8.dp)) { + Column(modifier = Modifier.padding(20.dp)) { + Text("Feeds", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text(status) + if (!done) { + Spacer(modifier = Modifier.height(8.dp)) + CircularProgressIndicator() + } else { + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onDone) { Text("Done") } + } + } + } + } +} diff --git a/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt new file mode 100644 index 0000000..560b3d8 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/work/FeedFetchWorker.kt @@ -0,0 +1,123 @@ +package org.terst.feedreader.work + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.terst.feedreader.data.AppDatabase +import org.terst.feedreader.data.ArticleEntity +import org.terst.feedreader.data.FeedEntity +import org.terst.feedreader.data.FeedParser +import org.terst.feedreader.data.Net +import okhttp3.Request +import java.security.MessageDigest +import java.util.concurrent.TimeUnit + +/** + * Fetches every subscribed feed on a schedule, parses new items, and + * inserts them (existing articles are left untouched -- see ArticleDao.insertNew's + * IGNORE conflict strategy). Uses conditional GET (ETag / Last-Modified) so + * an unchanged feed costs a 304 instead of a full re-download and re-parse. + */ +class FeedFetchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + private val client = Net.client + + override suspend fun doWork(): Result { + val db = AppDatabase.get(applicationContext) + val feeds = db.feedDao().getAll() + + for (feed in feeds) { + fetchOne(feed, db) + } + return Result.success() + } + + private suspend fun fetchOne(feed: FeedEntity, db: AppDatabase) { + val requestBuilder = Request.Builder().url(feed.id) + feed.etag?.let { requestBuilder.header("If-None-Match", it) } + feed.lastModified?.let { requestBuilder.header("If-Modified-Since", it) } + + try { + client.newCall(requestBuilder.build()).execute().use { response -> + val now = System.currentTimeMillis() + if (response.code == 304) { + db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = null)) + return + } + if (!response.isSuccessful) { + db.feedDao().update(feed.copy(lastFetchedAt = now, lastError = "HTTP ${response.code}")) + return + } + + val body = response.body ?: return + val parsed = FeedParser.parse(body.byteStream()) + + val articles = parsed.items.map { item -> + val id = item.guid ?: sha256(item.link) + ArticleEntity( + id = id, + feedId = feed.id, + title = item.title, + link = item.link, + author = item.author, + publishedAt = item.publishedAt, + feedContentHtml = item.contentHtml, + createdAt = now + ) + } + db.articleDao().insertNew(articles) + + db.feedDao().update( + feed.copy( + title = parsed.title.ifBlank { feed.title }, + siteUrl = parsed.siteUrl ?: feed.siteUrl, + lastFetchedAt = now, + etag = response.header("ETag") ?: feed.etag, + lastModified = response.header("Last-Modified") ?: feed.lastModified, + lastError = null + ) + ) + } + } catch (e: Exception) { + db.feedDao().update(feed.copy(lastFetchedAt = System.currentTimeMillis(), lastError = e.message ?: "Fetch failed")) + } + } + + private fun sha256(input: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } + + companion object { + private const val UNIQUE_WORK_NAME = "feed_fetch_periodic" + + fun schedulePeriodic(context: Context) { + val request = PeriodicWorkRequestBuilder<FeedFetchWorker>(60, TimeUnit.MINUTES) + .setConstraints( + androidx.work.Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + UNIQUE_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } + + /** One-off immediate fetch, e.g. a manual pull-to-refresh. */ + fun fetchNow(context: Context) { + val request = androidx.work.OneTimeWorkRequestBuilder<FeedFetchWorker>().build() + WorkManager.getInstance(context).enqueueUniqueWork( + "feed_fetch_now", + androidx.work.ExistingWorkPolicy.KEEP, + request + ) + } + } +} |
