summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt152
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt109
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt123
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/MainActivity.kt79
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt166
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/SettingsScreen.kt162
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ShareReceiverActivity.kt113
7 files changed, 904 insertions, 0 deletions
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") }
+ }
+ }
+ }
+ }
+}