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..1a07fb7a6 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrors.kt @@ -0,0 +1,52 @@ +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 +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, + is NoRouteToHostException, + -> 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..1d798ea07 --- /dev/null +++ b/app/src/test/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeErrorsTest.kt @@ -0,0 +1,77 @@ +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 +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"), + NoRouteToHostException("no route"), + ) + 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> + +