Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions app/src/main/java/me/ash/reader/infrastructure/rss/RssHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ import org.jsoup.Jsoup
val enclosureRegex = """<enclosure\s+url="([^"]+)"\s+type=".*"\s*/>""".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
Expand All @@ -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"))

Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Any> = emptyList()) : SubscribeError

/** The raw exception message; falls back to [R.string.subscribe_error_fallback] when empty. */
data class Raw(val message: String) : SubscribeError
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
<string name="searching">搜索中…</string>
<string name="subscribe">订阅</string>
<string name="already_subscribed">已有订阅</string>
<string name="subscribe_error_network">无法连接到服务器,请检查网络连接,并确认该站点可以访问。</string>
<string name="subscribe_error_http">服务器拒绝了该订阅地址(HTTP %1$d)。</string>
<string name="subscribe_error_feed_not_found">在此地址未找到 RSS/Atom 订阅源。</string>
<string name="subscribe_error_parse">此地址看起来是订阅源,但内容无法解析。</string>
<string name="subscribe_error_fallback">添加订阅失败,请重试。</string>
<string name="clear">清空</string>
<string name="paste">粘贴</string>
<string name="feed_or_site_url">订阅源或站点链接</string>
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
<string name="searching">Searching…</string>
<string name="subscribe">Subscribe</string>
<string name="already_subscribed">Already subscribed</string>
<string name="subscribe_error_network">Couldn\'t connect to the server. Check your network connection and make sure this site is reachable.</string>
<string name="subscribe_error_http">The server rejected this feed URL (HTTP %1$d).</string>
<string name="subscribe_error_feed_not_found">No RSS/Atom feed was found at this URL.</string>
<string name="subscribe_error_parse">This URL looks like a feed, but its content could not be parsed.</string>
<string name="subscribe_error_fallback">Failed to add this feed. Please try again.</string>
<string name="clear">Clear</string>
<string name="paste">Paste</string>
<string name="feed_or_site_url">Feed or site URL</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Throwable>(
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)
}
}
35 changes: 35 additions & 0 deletions app/src/test/resources/fixtures/statuspage_history.atom
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xml:lang="en-US" xmlns="http://www.w3.org/2005/Atom">
<id>tag:status.example.com,2005:/history</id>
<link rel="alternate" type="text/html" href="https://status.example.com"/>
<link rel="self" type="application/atom+xml" href="https://status.example.com/history.atom"/>
<title>Example Status - Incident History</title>
<updated>2026-09-03T09:04:16Z</updated>
<author>
<name>Example</name>
</author>
<entry>
<id>tag:status.example.com,2005:Incident/31327560</id>
<published>2026-09-01T16:01:21Z</published>
<updated>2026-09-01T16:01:21Z</updated>
<link rel="alternate" type="text/html" href="https://status.example.com/incidents/jk2dy5h9yp3m"/>
<title>Delays in commit processing</title>
<content type="html">&lt;p&gt; &lt;small&gt;Sep &lt;var data-var='date'&gt; 1&lt;/var&gt;, &lt;var data-var='time'&gt;16:01&lt;/var&gt; UTC&lt;/small&gt;&lt;br&gt; &lt;strong&gt;Resolved&lt;/strong&gt; - This incident has been resolved. &lt;/p&gt; &lt;p&gt; &lt;small&gt;Sep &lt;var data-var='date'&gt; 1&lt;/var&gt;, &lt;var data-var='time'&gt;15:00&lt;/var&gt; UTC&lt;/small&gt;&lt;br&gt; &lt;strong&gt;Investigating&lt;/strong&gt; - We are investigating reports of degraded performance. &lt;/p&gt;</content>
</entry>
<entry>
<id>tag:status.example.com,2005:Incident/31316411</id>
<published>2026-08-31T09:58:14Z</published>
<updated>2026-08-31T09:58:14Z</updated>
<link rel="alternate" type="text/html" href="https://status.example.com/incidents/7fxts6gmq5gr"/>
<title>Elevated rate of errors</title>
<content type="html">&lt;p&gt; &lt;small&gt;Aug &lt;var data-var='date'&gt;31&lt;/var&gt;, &lt;var data-var='time'&gt;09:58&lt;/var&gt; UTC&lt;/small&gt;&lt;br&gt; &lt;strong&gt;Resolved&lt;/strong&gt; - This incident has been resolved. &lt;br /&gt;&lt;br /&gt;We will continue monitoring to ensure stability. &lt;/p&gt;</content>
</entry>
<entry>
<id>tag:status.example.com,2005:Incident/31285481</id>
<published>2026-08-27T19:44:08Z</published>
<updated>2026-08-31T22:46:13Z</updated>
<link rel="alternate" type="text/html" href="https://status.example.com/incidents/5bn0vk444m1w"/>
<title>Disruption with billing</title>
<content type="html">&lt;p&gt; &lt;small&gt;Aug &lt;var data-var='date'&gt;27&lt;/var&gt;, &lt;var data-var='time'&gt;19:44&lt;/var&gt; UTC&lt;/small&gt;&lt;br&gt; &lt;strong&gt;Resolved&lt;/strong&gt; - On August 26, billing experienced degraded performance. &lt;br /&gt;&lt;br /&gt;This was caused by a concentrated workload. &lt;/p&gt;</content>
</entry>
</feed>
Loading