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?, val imageUrl: String? = null ) data class ParsedFeed( val title: String, val siteUrl: String?, val items: List ) /** * 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() var eventType = parser.next() var depth = 1 // Track whether we're directly inside (title/link there) vs inside an . 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 var itemImageUrl: 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; itemImageUrl = 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() "media:thumbnail", "media:content" -> if (inItem && itemImageUrl == null) { val url = parser.getAttributeValue(null, "url") val type = parser.getAttributeValue(null, "type") if (url != null && (type == null || type.startsWith("image/"))) itemImageUrl = url } "enclosure" -> if (inItem && itemImageUrl == null) { val url = parser.getAttributeValue(null, "url") val type = parser.getAttributeValue(null, "type") if (url != null && type?.startsWith("image/") == true) itemImageUrl = url } } } XmlPullParser.END_TAG -> { depth-- if (parser.name.lowercase() == "item") { inItem = false val content = itemContent ?: itemDescription items += ParsedItem( guid = itemGuid ?: itemLink.ifBlank { null }, title = itemTitle, link = itemLink, author = itemAuthor, publishedAt = itemDate, contentHtml = content, imageUrl = itemImageUrl ?: extractFirstImageUrl(content) ) } } } 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() 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 var entryImageUrl: 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; entryImageUrl = 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") val type = parser.getAttributeValue(null, "type") if (href != null && (rel == null || rel == "alternate")) { if (inEntry) entryLink = href else if (siteUrl == null) siteUrl = href } if (inEntry && rel == "enclosure" && type?.startsWith("image/") == true && href != null) { entryImageUrl = 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() "media:thumbnail" -> if (inEntry && entryImageUrl == null) { entryImageUrl = parser.getAttributeValue(null, "url") } } } XmlPullParser.END_TAG -> { depth-- if (parser.name.lowercase() == "entry") { inEntry = false val content = entryContent ?: entrySummary items += ParsedItem( guid = entryId ?: entryLink.ifBlank { null }, title = entryTitle, link = entryLink, author = entryAuthor, publishedAt = entryDate, contentHtml = content, imageUrl = entryImageUrl ?: extractFirstImageUrl(content) ) } } } if (depth > 0) eventType = parser.next() } return ParsedFeed(feedTitle, siteUrl, items) } private val imgTagRegex = Regex("""]+src=["']([^"']+)["']""", RegexOption.IGNORE_CASE) // Fallback for feeds with no explicit media:thumbnail/enclosure image -- // most blog feeds just embed the post's lead image directly in the content. private fun extractFirstImageUrl(html: String?): String? = html?.let { imgTagRegex.find(it)?.groupValues?.get(1) } 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 } }