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>
+
+