summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/data/FeedParser.kt
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/data/FeedParser.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/FeedParser.kt207
1 files changed, 207 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/FeedParser.kt b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt
new file mode 100644
index 0000000..cd337d6
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/data/FeedParser.kt
@@ -0,0 +1,207 @@
+package org.terst.feedreader.data
+
+import android.util.Xml
+import org.xmlpull.v1.XmlPullParser
+import java.io.InputStream
+import java.time.OffsetDateTime
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+import java.time.temporal.ChronoField
+import java.util.Locale
+
+data class ParsedItem(
+ val guid: String?,
+ val title: String,
+ val link: String,
+ val author: String?,
+ val publishedAt: Long?,
+ val contentHtml: String?
+)
+
+data class ParsedFeed(
+ val title: String,
+ val siteUrl: String?,
+ val items: List<ParsedItem>
+)
+
+/**
+ * Hand-rolled RSS 2.0 + Atom parser -- deliberately not a third-party library
+ * (rome, etc.), per the "simple design" scope: only the fields this app
+ * actually uses (title/link/guid/author/date/content), nothing else.
+ */
+object FeedParser {
+
+ fun parse(input: InputStream): ParsedFeed {
+ val parser = Xml.newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(input, null)
+
+ // Peek the root element to decide which dialect we're in.
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.START_TAG) {
+ eventType = parser.next()
+ }
+ return when (parser.name.lowercase()) {
+ "rss" -> parseRss(parser)
+ "feed" -> parseAtom(parser)
+ else -> throw IllegalArgumentException("Unrecognized feed root element: ${parser.name}")
+ }
+ }
+
+ private fun parseRss(parser: XmlPullParser): ParsedFeed {
+ var feedTitle = ""
+ var siteUrl: String? = null
+ val items = mutableListOf<ParsedItem>()
+
+ var eventType = parser.next()
+ var depth = 1
+ // Track whether we're directly inside <channel> (title/link there) vs inside an <item>.
+ var inItem = false
+ var itemGuid: String? = null
+ var itemTitle = ""
+ var itemLink = ""
+ var itemAuthor: String? = null
+ var itemDate: Long? = null
+ var itemContent: String? = null
+ var itemDescription: String? = null
+
+ while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ depth++
+ when (parser.name.lowercase()) {
+ "item" -> {
+ inItem = true
+ itemGuid = null; itemTitle = ""; itemLink = ""; itemAuthor = null
+ itemDate = null; itemContent = null; itemDescription = null
+ }
+ "title" -> {
+ val text = parser.nextText()
+ if (inItem) itemTitle = text else feedTitle = text
+ }
+ "link" -> {
+ val text = parser.nextText()
+ if (inItem) itemLink = text else if (siteUrl == null) siteUrl = text
+ }
+ "guid" -> if (inItem) itemGuid = parser.nextText()
+ "author" -> if (inItem) itemAuthor = parser.nextText()
+ "dc:creator" -> if (inItem) itemAuthor = parser.nextText()
+ "pubdate" -> if (inItem) itemDate = parseRfc822Date(parser.nextText())
+ "content:encoded" -> if (inItem) itemContent = parser.nextText()
+ "description" -> if (inItem) itemDescription = parser.nextText()
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ depth--
+ if (parser.name.lowercase() == "item") {
+ inItem = false
+ items += ParsedItem(
+ guid = itemGuid ?: itemLink.ifBlank { null },
+ title = itemTitle,
+ link = itemLink,
+ author = itemAuthor,
+ publishedAt = itemDate,
+ contentHtml = itemContent ?: itemDescription
+ )
+ }
+ }
+ }
+ if (depth > 0) eventType = parser.next()
+ }
+ return ParsedFeed(feedTitle, siteUrl, items)
+ }
+
+ private fun parseAtom(parser: XmlPullParser): ParsedFeed {
+ var feedTitle = ""
+ var siteUrl: String? = null
+ val items = mutableListOf<ParsedItem>()
+
+ var eventType = parser.next()
+ var depth = 1
+ var inEntry = false
+ var entryId: String? = null
+ var entryTitle = ""
+ var entryLink = ""
+ var entryAuthor: String? = null
+ var entryDate: Long? = null
+ var entryContent: String? = null
+ var entrySummary: String? = null
+
+ while (eventType != XmlPullParser.END_DOCUMENT && depth > 0) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ depth++
+ when (parser.name.lowercase()) {
+ "entry" -> {
+ inEntry = true
+ entryId = null; entryTitle = ""; entryLink = ""; entryAuthor = null
+ entryDate = null; entryContent = null; entrySummary = null
+ }
+ "title" -> {
+ val text = parser.nextText()
+ if (inEntry) entryTitle = text else feedTitle = text
+ }
+ "link" -> {
+ val rel = parser.getAttributeValue(null, "rel")
+ val href = parser.getAttributeValue(null, "href")
+ if (href != null && (rel == null || rel == "alternate")) {
+ if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href
+ }
+ }
+ "id" -> if (inEntry) entryId = parser.nextText()
+ "name" -> if (inEntry && entryAuthor == null) entryAuthor = parser.nextText()
+ "updated" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText())
+ "published" -> if (inEntry) entryDate = parseIso8601Date(parser.nextText()) ?: entryDate
+ "content" -> if (inEntry) entryContent = parser.nextText()
+ "summary" -> if (inEntry) entrySummary = parser.nextText()
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ depth--
+ if (parser.name.lowercase() == "entry") {
+ inEntry = false
+ items += ParsedItem(
+ guid = entryId ?: entryLink.ifBlank { null },
+ title = entryTitle,
+ link = entryLink,
+ author = entryAuthor,
+ publishedAt = entryDate,
+ contentHtml = entryContent ?: entrySummary
+ )
+ }
+ }
+ }
+ if (depth > 0) eventType = parser.next()
+ }
+ return ParsedFeed(feedTitle, siteUrl, items)
+ }
+
+ private val rfc822Formatters = listOf(
+ DateTimeFormatter.RFC_1123_DATE_TIME,
+ DateTimeFormatterBuilder()
+ .appendPattern("EEE, d MMM yyyy HH:mm:ss ")
+ .optionalStart().appendPattern("zzz").optionalEnd()
+ .optionalStart().appendOffset("+HHMM", "+0000").optionalEnd()
+ .parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
+ .toFormatter(Locale.US)
+ )
+
+ private fun parseRfc822Date(raw: String): Long? {
+ val trimmed = raw.trim()
+ for (formatter in rfc822Formatters) {
+ try {
+ return ZonedDateTime.parse(trimmed, formatter).toInstant().toEpochMilli()
+ } catch (_: Exception) {
+ // try the next formatter
+ }
+ }
+ return null
+ }
+
+ private fun parseIso8601Date(raw: String): Long? = try {
+ OffsetDateTime.parse(raw.trim()).toInstant().toEpochMilli()
+ } catch (_: Exception) {
+ null
+ }
+}