From 5ced2138c83692841cc440dc83b704e6d1a1bb9d Mon Sep 17 00:00:00 2001 From: mlkgrnt Date: Thu, 3 Sep 2026 22:07:05 +0800 Subject: [PATCH 1/2] fix: show readable error messages when feed subscription fails When importing a feed fails, ReadYou previously surfaced the raw exception message in the subscribe dialog, e.g. "SSLHandshakeException: connection closed" for hosts that abort the TLS handshake, or "Unable to detect RSS feed URL" without any hint of the HTTP status. Such messages are confusing for end users and gave no clue whether the problem was the network, the server, or the URL itself. Instead of throwing bare IOExceptions, classify the failure and present a short, localized message: - RssHelper now throws FeedHttpException (carrying the HTTP status code) and FeedNotFoundException instead of raw IOExceptions. - A new toSubscribeError() mapping turns these, along with network exceptions (SSL/DNS/connect/timeout) and parse exceptions, into localized string resources shown in the subscribe dialog. Anything unexpected falls back to the original message or a generic hint. - Strings are added in values/ and values-zh-rCN/. Adds unit tests for the error mapping and for parsing an Atom document in the shape served by statuspage-style hosts (an RSS URL that redirects to an Atom feed with content type application/atom+xml). --- .../reader/infrastructure/rss/RssHelper.kt | 20 ++++- .../home/feeds/subscribe/SubscribeErrors.kt | 50 +++++++++++++ .../feeds/subscribe/SubscribeViewModel.kt | 20 ++++- app/src/main/res/values-zh-rCN/strings.xml | 5 ++ app/src/main/res/values/strings.xml | 5 ++ .../rss/StatuspageAtomParsingTest.kt | 71 ++++++++++++++++++ .../feeds/subscribe/SubscribeErrorsTest.kt | 75 +++++++++++++++++++ .../fixtures/statuspage_history.atom | 35 +++++++++ 8 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt create mode 100644 app/src/test/java/me/ash/reader/infrastructure/rss/StatuspageAtomParsingTest.kt create mode 100644 app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt create mode 100644 app/src/test/resources/fixtures/statuspage_history.atom 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 acfb68e9e..c9cebf6ab 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 @@ -37,6 +37,12 @@ import org.jsoup.Jsoup val enclosureRegex = """""".toRegex() val imgRegex = """img.*?src=(["'])((?!data).*?)\1""".toRegex(RegexOption.DOT_MATCHES_ALL) +/** Thrown when the requested feed URL answers with a non-successful HTTP status code. */ +class FeedHttpException(message: String, val statusCode: Int) : IOException(message) + +/** Thrown when the requested URL is not a feed and no feed could be discovered on the page. */ +class FeedNotFoundException(message: String) : IOException(message) + /** Some operations on RSS. */ class RssHelper @Inject @@ -55,7 +61,12 @@ constructor( suspend fun searchFeed(feedLink: String): SearchFeedResult { return withContext(ioDispatcher) { val directResponse = response(okHttpClient, feedLink) - if (!directResponse.commonIsSuccessful) throw IOException(directResponse.message) + if (!directResponse.commonIsSuccessful) { + throw FeedHttpException( + message = "HTTP ${directResponse.code} ${directResponse.message}", + statusCode = directResponse.code, + ) + } val directBody = directResponse.body.bytes() val directHttpContentType = toHttpContentType(directResponse.header("Content-Type")) @@ -64,13 +75,16 @@ constructor( val resolvedFeedLink = if (parsedDirectFeed != null) feedLink else discoverFeedLink(feedLink, directBody) - ?: throw IOException("Unable to detect RSS feed URL") + ?: throw FeedNotFoundException("Unable to detect RSS feed URL") val feed = parsedDirectFeed ?: run { val discoveredResponse = response(okHttpClient, resolvedFeedLink) if (!discoveredResponse.commonIsSuccessful) { - throw IOException(discoveredResponse.message) + throw FeedHttpException( + message = "HTTP ${discoveredResponse.code} ${discoveredResponse.message}", + statusCode = discoveredResponse.code, + ) } parseFeed( discoveredResponse.body.bytes(), diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt new file mode 100644 index 000000000..8c13c55db --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt @@ -0,0 +1,50 @@ +package me.ash.reader.ui.page.home.feeds.subscribe + +import androidx.annotation.StringRes +import com.rometools.rome.io.FeedException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLException +import me.ash.reader.R +import me.ash.reader.infrastructure.rss.FeedHttpException +import me.ash.reader.infrastructure.rss.FeedNotFoundException +import org.xml.sax.SAXParseException + +/** + * Maps an exception raised while importing a feed through + * [me.ash.reader.infrastructure.rss.RssHelper.searchFeed] to a user-facing + * error message. + * + * Import failures fall into a few categories that the user can actually act on: + * - the host is unreachable or TLS is blocked (e.g. a status page that is + * geo-blocked or behind an ICP firewall) -> [R.string.subscribe_error_network] + * - the server answered with an error status -> [R.string.subscribe_error_http] + * - the URL is not a feed and no feed could be discovered + * -> [R.string.subscribe_error_feed_not_found] + * - the body could not be parsed as RSS/Atom -> [R.string.subscribe_error_parse] + * + * Anything else keeps its original message ([SubscribeError.Raw]) so that no + * debugging information is lost. + */ +fun Throwable.toSubscribeError(): SubscribeError = + when (this) { + is FeedHttpException -> SubscribeError.Text(R.string.subscribe_error_http, listOf(statusCode)) + is FeedNotFoundException -> SubscribeError.Text(R.string.subscribe_error_feed_not_found) + is SSLException, + is UnknownHostException, + is ConnectException, + is SocketTimeoutException, + -> SubscribeError.Text(R.string.subscribe_error_network) + is SAXParseException, is FeedException, + -> SubscribeError.Text(R.string.subscribe_error_parse) + else -> SubscribeError.Raw(message.orEmpty()) + } + +sealed interface SubscribeError { + /** A localized message referenced by a string resource. */ + data class Text(@StringRes val resId: Int, val formatArgs: List = emptyList()) : SubscribeError + + /** The raw exception message; falls back to [R.string.subscribe_error_fallback] when empty. */ + data class Raw(val message: String) : SubscribeError +} 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 d2cc67d22..50a9c35cb 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 @@ -169,8 +169,24 @@ constructor( selectedGroupId = firstGroupId, ) } - .onFailure { - _subscribeState.value = currentState.copy(errorMessage = it.message) + .onFailure { throwable -> + val errorMessage = + when (val error = throwable.toSubscribeError()) { + is SubscribeError.Text -> + androidStringsHelper.getString( + error.resId, + *error.formatArgs.toTypedArray(), + ) + + is SubscribeError.Raw -> + error.message.ifBlank { + androidStringsHelper.getString( + R.string.subscribe_error_fallback + ) + } + } + _subscribeState.value = + currentState.copy(errorMessage = errorMessage) } } diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 439703cb8..bf2f66ac3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -32,6 +32,11 @@ 搜索中… 订阅 已有订阅 + 无法连接到服务器,请检查网络连接,并确认该站点可以访问。 + 服务器拒绝了该订阅地址(HTTP %1$d)。 + 在此地址未找到 RSS/Atom 订阅源。 + 此地址看起来是订阅源,但内容无法解析。 + 添加订阅失败,请重试。 清空 粘贴 订阅源或站点链接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f7f249f63..e1953d6f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,6 +38,11 @@ Searching… Subscribe Already subscribed + Couldn\'t connect to the server. Check your network connection and make sure this site is reachable. + The server rejected this feed URL (HTTP %1$d). + No RSS/Atom feed was found at this URL. + This URL looks like a feed, but its content could not be parsed. + Failed to add this feed. Please try again. Clear Paste Feed or site URL diff --git a/app/src/test/java/me/ash/reader/infrastructure/rss/StatuspageAtomParsingTest.kt b/app/src/test/java/me/ash/reader/infrastructure/rss/StatuspageAtomParsingTest.kt new file mode 100644 index 000000000..0c628567c --- /dev/null +++ b/app/src/test/java/me/ash/reader/infrastructure/rss/StatuspageAtomParsingTest.kt @@ -0,0 +1,71 @@ +package me.ash.reader.infrastructure.rss + +import com.rometools.rome.io.SyndFeedInput +import com.rometools.rome.io.XmlReader +import java.io.ByteArrayInputStream +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Verifies that ROME (the feed parser used by ReadYou) can parse an Atom feed + * in the exact shape served by Atlassian Statuspage instances + * (e.g. https://status.deepseek.com/feed.rss which redirects to /history.atom). + * + * The fixture is a real statuspage Atom payload captured from a live instance + * and truncated to the first 3 entries. + */ +class StatuspageAtomParsingTest { + + private fun readSampleAtom(): ByteArray { + val resource = + requireNotNull(javaClass.classLoader?.getResourceAsStream("fixtures/statuspage_history.atom")) { + "fixture statuspage_history.atom not found on classpath" + } + return resource.use { it.readBytes() } + } + + @Test + fun `statuspage atom feed parses with http content type like real server`() { + val body = readSampleAtom() + val feed = + SyndFeedInput().build( + XmlReader( + ByteArrayInputStream(body), + "application/atom+xml; charset=utf-8", // exact Content-Type of statuspage + ) + ) + + assertNotNull(feed.title) + assertTrue("feed must expose entries", feed.entries.isNotEmpty()) + assertEquals("first entry should carry an incident link", 3, feed.entries.size) + + val first = feed.entries.first() + assertNotNull("entry title", first.title) + assertNotNull("entry link", first.link) + assertTrue( + "entry link should point to an incident page", + first.link!!.contains("/incidents/") || first.link!!.contains("/history"), + ) + } + + @Test + fun `statuspage atom entries expose published and updated dates`() { + val body = readSampleAtom() + val feed = + SyndFeedInput().build( + XmlReader( + ByteArrayInputStream(body), + "application/atom+xml; charset=utf-8", + ) + ) + feed.entries.forEach { entry -> + assertNotNull( + "entry '${entry.title}' must expose a usable date (published or updated)", + entry.publishedDate ?: entry.updatedDate, + ) + } + } +} diff --git a/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt b/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt new file mode 100644 index 000000000..c2e0f7052 --- /dev/null +++ b/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt @@ -0,0 +1,75 @@ +package me.ash.reader.ui.page.home.feeds.subscribe + +import com.rometools.rome.io.ParsingFeedException +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLHandshakeException +import me.ash.reader.R +import me.ash.reader.infrastructure.rss.FeedHttpException +import me.ash.reader.infrastructure.rss.FeedNotFoundException +import org.junit.Assert.assertEquals +import org.junit.Test +import org.xml.sax.SAXParseException + +class SubscribeErrorsTest { + + @Test + fun `http error maps to localized http message with status code`() { + val error = FeedHttpException("HTTP 403 Forbidden", 403).toSubscribeError() + assertEquals(SubscribeError.Text(R.string.subscribe_error_http, listOf(403)), error) + } + + @Test + fun `unreachable host maps to network message`() { + val errors = + listOf( + SSLHandshakeException("connection closed"), + UnknownHostException("status.deepseek.com"), + ConnectException("refused"), + SocketTimeoutException("timeout"), + ) + errors.forEach { + assertEquals( + "expected network message for ${it::class.java.simpleName}", + SubscribeError.Text(R.string.subscribe_error_network), + it.toSubscribeError(), + ) + } + } + + @Test + fun `feed not found maps to dedicated message`() { + val error = FeedNotFoundException("Unable to detect RSS feed URL").toSubscribeError() + assertEquals(SubscribeError.Text(R.string.subscribe_error_feed_not_found), error) + } + + @Test + fun `unparseable feed body maps to parse message`() { + val errors = + listOf( + SAXParseException("mismatched tag", null, null), + ParsingFeedException("Invalid XML"), + ) + errors.forEach { + assertEquals( + "expected parse message for ${it::class.java.simpleName}", + SubscribeError.Text(R.string.subscribe_error_parse), + it.toSubscribeError(), + ) + } + } + + @Test + fun `other io exceptions keep their original message`() { + val error = IOException("boom").toSubscribeError() + assertEquals(SubscribeError.Raw("boom"), error) + } + + @Test + fun `generic exceptions keep their original message`() { + val error = IllegalStateException("unexpected").toSubscribeError() + assertEquals(SubscribeError.Raw("unexpected"), error) + } +} diff --git a/app/src/test/resources/fixtures/statuspage_history.atom b/app/src/test/resources/fixtures/statuspage_history.atom new file mode 100644 index 000000000..7c1da5201 --- /dev/null +++ b/app/src/test/resources/fixtures/statuspage_history.atom @@ -0,0 +1,35 @@ + + + tag:status.example.com,2005:/history + + + Example Status - Incident History + 2026-09-03T09:04:16Z + + Example + + + tag:status.example.com,2005:Incident/31327560 + 2026-09-01T16:01:21Z + 2026-09-01T16:01:21Z + + Delays in commit processing + <p> <small>Sep <var data-var='date'> 1</var>, <var data-var='time'>16:01</var> UTC</small><br> <strong>Resolved</strong> - This incident has been resolved. </p> <p> <small>Sep <var data-var='date'> 1</var>, <var data-var='time'>15:00</var> UTC</small><br> <strong>Investigating</strong> - We are investigating reports of degraded performance. </p> + + + tag:status.example.com,2005:Incident/31316411 + 2026-08-31T09:58:14Z + 2026-08-31T09:58:14Z + + Elevated rate of errors + <p> <small>Aug <var data-var='date'>31</var>, <var data-var='time'>09:58</var> UTC</small><br> <strong>Resolved</strong> - This incident has been resolved. <br /><br />We will continue monitoring to ensure stability. </p> + + + tag:status.example.com,2005:Incident/31285481 + 2026-08-27T19:44:08Z + 2026-08-31T22:46:13Z + + Disruption with billing + <p> <small>Aug <var data-var='date'>27</var>, <var data-var='time'>19:44</var> UTC</small><br> <strong>Resolved</strong> - On August 26, billing experienced degraded performance. <br /><br />This was caused by a concentrated workload. </p> + + From 8174b24afaa2927434cb0282f5f3d7c2f297f3e5 Mon Sep 17 00:00:00 2001 From: mlkgrnt Date: Mon, 7 Sep 2026 12:36:57 +0800 Subject: [PATCH 2/2] fix: map NoRouteToHostException to network error message --- .../ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt | 2 ++ .../reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt index 8c13c55db..1a07fb7a6 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt @@ -3,6 +3,7 @@ package me.ash.reader.ui.page.home.feeds.subscribe import androidx.annotation.StringRes import com.rometools.rome.io.FeedException import java.net.ConnectException +import java.net.NoRouteToHostException import java.net.SocketTimeoutException import java.net.UnknownHostException import javax.net.ssl.SSLException @@ -35,6 +36,7 @@ fun Throwable.toSubscribeError(): SubscribeError = is UnknownHostException, is ConnectException, is SocketTimeoutException, + is NoRouteToHostException, -> SubscribeError.Text(R.string.subscribe_error_network) is SAXParseException, is FeedException, -> SubscribeError.Text(R.string.subscribe_error_parse) diff --git a/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt b/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt index c2e0f7052..1d798ea07 100644 --- a/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt +++ b/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt @@ -3,6 +3,7 @@ package me.ash.reader.ui.page.home.feeds.subscribe import com.rometools.rome.io.ParsingFeedException import java.io.IOException import java.net.ConnectException +import java.net.NoRouteToHostException import java.net.SocketTimeoutException import java.net.UnknownHostException import javax.net.ssl.SSLHandshakeException @@ -29,6 +30,7 @@ class SubscribeErrorsTest { UnknownHostException("status.deepseek.com"), ConnectException("refused"), SocketTimeoutException("timeout"), + NoRouteToHostException("no route"), ) errors.forEach { assertEquals(