summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
diff options
context:
space:
mode:
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.kt65
1 files changed, 51 insertions, 14 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
index 1e8814c..9f817ab 100644
--- a/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/AddFeedScreen.kt
@@ -33,11 +33,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import org.terst.feedreader.data.AppDatabase
+import org.terst.feedreader.data.FeedDiscovery
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
+import java.io.ByteArrayInputStream
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -168,25 +170,60 @@ fun AddFeedScreen(onDone: () -> Unit) {
private fun preferHttps(url: String): String =
if (url.startsWith("http://", ignoreCase = true)) "https://" + url.removePrefix("http://") else url
+/**
+ * Fetches [url] and returns its bytes, or null when the server refused.
+ *
+ * Read into memory rather than parsed from the live stream because the bytes may need looking
+ * at twice: once as a feed, and -- when that is not what the URL turned out to be -- again as a
+ * web page advertising where its feed actually lives. A response body is a one-shot stream, so
+ * the second look is only possible if the first did not consume it.
+ */
+private class Fetched(val bytes: ByteArray?, val error: String?)
+
+private fun fetchBytes(url: String): Fetched =
+ Net.client.newCall(Request.Builder().url(url).build()).execute().use { response ->
+ when {
+ !response.isSuccessful -> Fetched(null, "Couldn't fetch feed: HTTP ${response.code}")
+ else -> response.body?.let { Fetched(it.bytes(), null) } ?: Fetched(null, "Empty response")
+ }
+ }
+
private suspend fun addFeedByUrl(context: android.content.Context, db: AppDatabase, feedUrl: String): String =
withContext(Dispatchers.IO) {
- val url = preferHttps(feedUrl)
+ val typedUrl = preferHttps(feedUrl)
try {
- val response = Net.client.newCall(Request.Builder().url(url).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())
- // Same REPLACE-wipes-other-fields concern as the OPML import path.
- val existing = db.feedDao().getById(url)
- val entity = existing?.copy(
- title = parsed.title.ifBlank { url },
- siteUrl = parsed.siteUrl ?: existing.siteUrl
- ) ?: FeedEntity(id = url, title = parsed.title.ifBlank { url }, siteUrl = parsed.siteUrl)
- db.feedDao().upsert(entity)
+ val fetched = fetchBytes(typedUrl)
+ val bytes = fetched.bytes ?: return@withContext fetched.error ?: "Empty response"
+
+ // What the user had to hand is usually the site, not the feed -- a site advertises
+ // its feed in its head, it does not print the URL anywhere a reader can copy. So a
+ // failed parse is not the answer yet: look for what the page says its feed is and
+ // try that, once. See FeedDiscovery.
+ var url = typedUrl
+ var parsed = runCatching { FeedParser.parse(ByteArrayInputStream(bytes)) }.getOrNull()
+ if (parsed == null) {
+ val discovered = FeedDiscovery.findFeedUrl(String(bytes, Charsets.UTF_8), typedUrl)
+ ?: return@withContext "No feed found at that address"
+ val feed = fetchBytes(discovered)
+ val feedBytes = feed.bytes ?: return@withContext feed.error ?: "Empty response"
+ // One hop only: whatever the page pointed at has to be a feed. Following a
+ // second page would be a redirect chain nobody asked for.
+ parsed = FeedParser.parse(ByteArrayInputStream(feedBytes))
+ url = discovered
}
+
+ // Same REPLACE-wipes-other-fields concern as the OPML import path.
+ val existing = db.feedDao().getById(url)
+ val entity = existing?.copy(
+ title = parsed.title.ifBlank { url },
+ siteUrl = parsed.siteUrl ?: existing.siteUrl
+ ) ?: FeedEntity(id = url, title = parsed.title.ifBlank { url }, siteUrl = parsed.siteUrl)
+ db.feedDao().upsert(entity)
+
FeedFetchWorker.fetchNow(context)
- "Added"
+ // Says which feed was added when it is not the URL that was typed, so a site URL
+ // quietly becoming a different feed URL is visible rather than surprising later.
+ if (url == typedUrl) "Added" else "Added ${parsed.title.ifBlank { url }}"
} catch (e: Exception) {
"Couldn't add feed: ${e.message}"
}