summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
commitd8ce4b3c0f383e844b125d2d3abd618715bb3c89 (patch)
treeb586ff9b42392f2c44645773c2a2f2aadb9db406 /app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
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/feedreader/ui/AddFeedScreen.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt152
1 files changed, 152 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}"
+ }
+ }