From d8ce4b3c0f383e844b125d2d3abd618715bb3c89 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Wed, 22 Jul 2026 08:08:58 +0000 Subject: 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 Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL --- .../java/org/terst/feedreader/ui/AddFeedScreen.kt | 152 +++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt (limited to 'app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt') 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(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}" + } + } -- cgit v1.2.3