From 184c8bc5b69e1dabdc5fe0ac8ae0a0bd9381762f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 2 May 2026 19:12:08 +0200 Subject: [PATCH] Add rss discovery from home page --- .../reader/infrastructure/rss/RssHelper.kt | 71 +++++++++++++++---- .../feeds/subscribe/SubscribeViewModel.kt | 14 +++- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt index a465698b6..acfb68e9e 100644 --- a/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt +++ b/app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt @@ -10,6 +10,7 @@ import com.rometools.rome.feed.synd.SyndFeed import com.rometools.rome.feed.synd.SyndImageImpl import com.rometools.rome.io.SyndFeedInput import com.rometools.rome.io.XmlReader +import java.io.ByteArrayInputStream import dagger.hilt.android.qualifiers.ApplicationContext import java.nio.charset.Charset import java.util.* @@ -45,28 +46,72 @@ constructor( private val okHttpClient: OkHttpClient, ) { + data class SearchFeedResult( + val feed: SyndFeed, + val feedLink: String, + ) + @Throws(Exception::class) - suspend fun searchFeed(feedLink: String): SyndFeed { + suspend fun searchFeed(feedLink: String): SearchFeedResult { return withContext(ioDispatcher) { - val response = response(okHttpClient, feedLink) - val contentType = response.header("Content-Type") - val httpContentType = - contentType?.let { - if (it.contains("charset=", ignoreCase = true)) it - else "$it; charset=UTF-8" - } ?: "text/xml; charset=UTF-8" + val directResponse = response(okHttpClient, feedLink) + if (!directResponse.commonIsSuccessful) throw IOException(directResponse.message) + val directBody = directResponse.body.bytes() + val directHttpContentType = toHttpContentType(directResponse.header("Content-Type")) + val parsedDirectFeed = runCatching { parseFeed(directBody, directHttpContentType) }.getOrNull() - response.body.byteStream().use { inputStream -> - SyndFeedInput().build(XmlReader(inputStream, httpContentType)).also { - it.icon = SyndImageImpl() - it.icon.link = queryRssIconLink(feedLink) - it.icon.url = it.icon.link + val resolvedFeedLink = + if (parsedDirectFeed != null) feedLink + else discoverFeedLink(feedLink, directBody) + ?: throw IOException("Unable to detect RSS feed URL") + + + val feed = parsedDirectFeed ?: run { + val discoveredResponse = response(okHttpClient, resolvedFeedLink) + if (!discoveredResponse.commonIsSuccessful) { + throw IOException(discoveredResponse.message) } + parseFeed( + discoveredResponse.body.bytes(), + toHttpContentType(discoveredResponse.header("Content-Type")), + ) } + + feed.also { + it.icon = SyndImageImpl() + it.icon.link = queryRssIconLink(resolvedFeedLink) + it.icon.url = it.icon.link + } + + SearchFeedResult(feed = feed, feedLink = resolvedFeedLink) } } + private fun toHttpContentType(contentType: String?): String = + contentType?.let { + if (it.contains("charset=", ignoreCase = true)) it else "$it; charset=UTF-8" + } ?: "text/xml; charset=UTF-8" + + private fun parseFeed(body: ByteArray, httpContentType: String): SyndFeed = + ByteArrayInputStream(body).use { inputStream -> + SyndFeedInput().build(XmlReader(inputStream, httpContentType)) + } + + private fun discoverFeedLink(pageUrl: String, body: ByteArray): String? { + val document = Jsoup.parse(String(body, Charsets.UTF_8), pageUrl) + val links = document.select("head link[rel~=(?i)alternate][href]") + val preferred = + links.firstOrNull { + val type = it.attr("type").lowercase(Locale.ROOT) + type == "application/rss+xml" || + type == "application/atom+xml" || + type == "application/rdf+xml" + } + val fallback = links.firstOrNull() + return (preferred ?: fallback)?.absUrl("href")?.takeIf { it.isNotBlank() } + } + @Throws(Exception::class) suspend fun parseFullContent(link: String, title: String): String { return withContext(ioDispatcher) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt index ad0eaf313..d2cc67d22 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt @@ -150,11 +150,21 @@ constructor( viewModelScope.launch { runCatching { rssHelper.searchFeed(feedLink) } .onSuccess { + if (rssService.get().isFeedExist(it.feedLink)) { + _subscribeState.value = + currentState.copy( + errorMessage = + androidStringsHelper.getString( + R.string.already_subscribed + ) + ) + return@onSuccess + } val groups = groupsFlow.value _subscribeState.value = SubscribeState.Configure( - searchedFeed = it, - feedLink = feedLink, + searchedFeed = it.feed, + feedLink = it.feedLink, groups = groups, selectedGroupId = firstGroupId, )