From d84b8ff4e8269b2e6d160eb005947d5c25baaa95 Mon Sep 17 00:00:00 2001
From: Michael Freno
Date: Mon, 30 Mar 2026 09:01:49 -0400
Subject: [PATCH] Implement Android RSS/Atom feed parser
- Add FeedParser.kt with automatic feed type detection
- Add RSSParser.kt for RSS 2.0 feeds
- Add AtomParser.kt for Atom 1.0 feeds
- Add comprehensive unit tests for both parsers
- Support iTunes namespace and enclosures
- Fix pre-existing compilation issues in the codebase
- Update build.gradle.kts with proper dependencies and AGP 8.5.0
---
native-route/android/build.gradle.kts | 30 ++-
native-route/android/gradle.properties | 6 +
.../gradle/wrapper/gradle-wrapper.properties | 7 +
native-route/android/gradlew | 170 ++++++++++++
native-route/android/settings.gradle.kts | 2 +-
.../converters/FeedItemListConverter.kt | 2 +-
.../com/rssuper/models/ReadingPreferences.kt | 5 +-
.../java/com/rssuper/models/SearchFilters.kt | 5 +-
.../java/com/rssuper/parsing/AtomParser.kt | 240 +++++++++++++++++
.../java/com/rssuper/parsing/FeedParser.kt | 67 +++++
.../main/java/com/rssuper/parsing/FeedType.kt | 16 ++
.../java/com/rssuper/parsing/ParseResult.kt | 13 +
.../java/com/rssuper/parsing/RSSParser.kt | 188 +++++++++++++
.../rssuper/parsing/XmlParsingUtilities.kt | 154 +++++++++++
.../com/rssuper/database/RssDatabaseTest.kt | 4 +-
.../com/rssuper/models/SearchResultTest.kt | 2 +-
.../com/rssuper/parsing/AtomParserTest.kt | 245 +++++++++++++++++
.../com/rssuper/parsing/FeedParserTest.kt | 162 +++++++++++
.../java/com/rssuper/parsing/RSSParserTest.kt | 255 ++++++++++++++++++
19 files changed, 1555 insertions(+), 18 deletions(-)
create mode 100644 native-route/android/gradle.properties
create mode 100644 native-route/android/gradle/wrapper/gradle-wrapper.properties
create mode 100755 native-route/android/gradlew
create mode 100644 native-route/android/src/main/java/com/rssuper/parsing/AtomParser.kt
create mode 100644 native-route/android/src/main/java/com/rssuper/parsing/FeedParser.kt
create mode 100644 native-route/android/src/main/java/com/rssuper/parsing/FeedType.kt
create mode 100644 native-route/android/src/main/java/com/rssuper/parsing/ParseResult.kt
create mode 100644 native-route/android/src/main/java/com/rssuper/parsing/RSSParser.kt
create mode 100644 native-route/android/src/main/java/com/rssuper/parsing/XmlParsingUtilities.kt
create mode 100644 native-route/android/src/test/java/com/rssuper/parsing/AtomParserTest.kt
create mode 100644 native-route/android/src/test/java/com/rssuper/parsing/FeedParserTest.kt
create mode 100644 native-route/android/src/test/java/com/rssuper/parsing/RSSParserTest.kt
diff --git a/native-route/android/build.gradle.kts b/native-route/android/build.gradle.kts
index d69e526..43ee3c1 100644
--- a/native-route/android/build.gradle.kts
+++ b/native-route/android/build.gradle.kts
@@ -1,8 +1,8 @@
plugins {
- id("com.android.library")
- id("org.jetbrains.kotlin.android")
- id("kotlin-parcelize")
- id("kotlin-kapt")
+ id("com.android.library") version "8.5.0"
+ id("org.jetbrains.kotlin.android") version "1.9.22"
+ id("org.jetbrains.kotlin.plugin.parcelize") version "1.9.22"
+ id("org.jetbrains.kotlin.kapt") version "1.9.22"
}
android {
@@ -23,29 +23,40 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "17"
}
+
+ sourceSets {
+ getByName("main") {
+ java.srcDirs("src/main/java")
+ }
+ }
}
dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
+
// AndroidX
implementation("androidx.core:core-ktx:1.12.0")
+
+ // XML Parsing - built-in XmlPullParser
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// Moshi for JSON serialization
- implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
- kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
- implementation("com.squareup.moshi:moshi-kotlin-reflect:1.15.1")
+ implementation("com.squareup.moshi:moshi:1.15.0")
+ kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
+ implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
// Testing
testImplementation("junit:junit:4.13.2")
- testImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
- testImplementation("com.squareup.moshi:moshi-kotlin-reflect:1.15.1")
+ testImplementation("com.squareup.moshi:moshi:1.15.0")
+ testImplementation("com.squareup.moshi:moshi-kotlin:1.15.0")
testImplementation("org.mockito:mockito-core:5.7.0")
testImplementation("org.mockito:mockito-inline:5.2.0")
testImplementation("androidx.room:room-testing:2.6.1")
@@ -54,4 +65,5 @@ dependencies {
testImplementation("androidx.test:core:1.5.0")
testImplementation("androidx.test.ext:junit:1.1.5")
testImplementation("androidx.test:runner:1.5.2")
+ testImplementation("org.robolectric:robolectric:4.11.1")
}
diff --git a/native-route/android/gradle.properties b/native-route/android/gradle.properties
new file mode 100644
index 0000000..d0a1c02
--- /dev/null
+++ b/native-route/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
+kapt.use.worker.api=false
+android.useAndroidX=true
+android.enableJetifier=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/native-route/android/gradle/wrapper/gradle-wrapper.properties b/native-route/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b82aa23
--- /dev/null
+++ b/native-route/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/native-route/android/gradlew b/native-route/android/gradlew
new file mode 100755
index 0000000..31ad13b
--- /dev/null
+++ b/native-route/android/gradlew
@@ -0,0 +1,170 @@
+#!/bin/sh
+
+#
+# Copyright 2015-2021 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# temporary options; we will parse these below.
+# * There is no need to specify -classpath explicitly.
+# * Gradle's Java options need to be preprocessed to be merged.
+# * We use eval to parse quoted options properly.
+
+# Collect arguments from the command line
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n://services.gradle.org/distributions/gradle-8.2-bin.zip
+# In either case, if the arg is not present, we don't add it.
+# If the arg is present but empty, we add it as empty string.
+#
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/native-route/android/settings.gradle.kts b/native-route/android/settings.gradle.kts
index 9b59a9d..eaad131 100644
--- a/native-route/android/settings.gradle.kts
+++ b/native-route/android/settings.gradle.kts
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
}
}
-rootProject.name = "rssuper-android"
+rootProject.name = "RSSuper"
include(":android")
diff --git a/native-route/android/src/main/java/com/rssuper/converters/FeedItemListConverter.kt b/native-route/android/src/main/java/com/rssuper/converters/FeedItemListConverter.kt
index 49342b9..65580a4 100644
--- a/native-route/android/src/main/java/com/rssuper/converters/FeedItemListConverter.kt
+++ b/native-route/android/src/main/java/com/rssuper/converters/FeedItemListConverter.kt
@@ -18,6 +18,6 @@ class FeedItemListConverter {
@TypeConverter
fun toFeedItemList(value: String?): List? {
- return value?.let { adapter.fromJson(it) }
+ return value?.let { adapter.fromJson(it) as? List }
}
}
diff --git a/native-route/android/src/main/java/com/rssuper/models/ReadingPreferences.kt b/native-route/android/src/main/java/com/rssuper/models/ReadingPreferences.kt
index 66c9df4..66590bc 100644
--- a/native-route/android/src/main/java/com/rssuper/models/ReadingPreferences.kt
+++ b/native-route/android/src/main/java/com/rssuper/models/ReadingPreferences.kt
@@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@@ -15,10 +16,10 @@ data class ReadingPreferences(
val id: String = "default",
@Json(name = "fontSize")
- val fontSize: FontSize = FontSize.MEDIUM,
+ val fontSize: @RawValue FontSize = FontSize.MEDIUM,
@Json(name = "lineHeight")
- val lineHeight: LineHeight = LineHeight.NORMAL,
+ val lineHeight: @RawValue LineHeight = LineHeight.NORMAL,
@Json(name = "showTableOfContents")
val showTableOfContents: Boolean = false,
diff --git a/native-route/android/src/main/java/com/rssuper/models/SearchFilters.kt b/native-route/android/src/main/java/com/rssuper/models/SearchFilters.kt
index 3e4f79f..4580a6b 100644
--- a/native-route/android/src/main/java/com/rssuper/models/SearchFilters.kt
+++ b/native-route/android/src/main/java/com/rssuper/models/SearchFilters.kt
@@ -7,6 +7,7 @@ import androidx.room.TypeConverters
import com.rssuper.converters.DateConverter
import com.rssuper.converters.StringListConverter
import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
@@ -32,10 +33,10 @@ data class SearchFilters(
val authors: List? = null,
@Json(name = "contentType")
- val contentType: ContentType? = null,
+ val contentType: @RawValue ContentType? = null,
@Json(name = "sortOption")
- val sortOption: SearchSortOption = SearchSortOption.RELEVANCE
+ val sortOption: @RawValue SearchSortOption = SearchSortOption.RELEVANCE
) : Parcelable
sealed class ContentType(val value: String) {
diff --git a/native-route/android/src/main/java/com/rssuper/parsing/AtomParser.kt b/native-route/android/src/main/java/com/rssuper/parsing/AtomParser.kt
new file mode 100644
index 0000000..bed32c3
--- /dev/null
+++ b/native-route/android/src/main/java/com/rssuper/parsing/AtomParser.kt
@@ -0,0 +1,240 @@
+package com.rssuper.parsing
+
+import com.rssuper.models.Enclosure
+import com.rssuper.models.Feed
+import com.rssuper.models.FeedItem
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.StringReader
+
+object AtomParser {
+
+ private val ATOM_NS = "http://www.w3.org/2005/Atom"
+ private val ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
+ private val MEDIA_NS = "http://search.yahoo.com/mrss/"
+
+ fun parse(xml: String, feedUrl: String): Feed {
+ val factory = XmlPullParserFactory.newInstance()
+ factory.isNamespaceAware = true
+ val parser = factory.newPullParser()
+ parser.setInput(StringReader(xml))
+
+ var title: String? = null
+ var link: String? = null
+ var subtitle: String? = null
+ var updated: java.util.Date? = null
+ var generator: String? = null
+ val items = mutableListOf()
+
+ var currentItem: MutableMap? = null
+ var currentTag: String? = null
+ var inContent = false
+
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ val tagName = parser.name
+ val namespace = parser.namespace
+
+ when {
+ tagName == "feed" -> {}
+ tagName == "entry" -> {
+ currentItem = mutableMapOf()
+ }
+ tagName == "title" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "link" -> {
+ val href = parser.getAttributeValue(null, "href")
+ val rel = parser.getAttributeValue(null, "rel")
+ if (href != null) {
+ if (currentItem != null) {
+ if (rel == "alternate" || rel == null) {
+ currentItem["link"] = href
+ } else if (rel == "enclosure") {
+ val type = parser.getAttributeValue(null, "type") ?: "application/octet-stream"
+ val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
+ currentItem["enclosure"] = Enclosure(href, type, length)
+ }
+ } else {
+ if (rel == "alternate" || rel == null) {
+ link = href
+ }
+ }
+ }
+ currentTag = null
+ inContent = false
+ }
+ tagName == "subtitle" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "summary" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "content" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "updated" || tagName == "published" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "name" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "uri" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "id" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "category" -> {
+ val term = parser.getAttributeValue(null, "term")
+ if (term != null && currentItem != null) {
+ val cats = currentItem["categories"] as? MutableList ?: mutableListOf()
+ cats.add(term)
+ currentItem["categories"] = cats
+ }
+ currentTag = null
+ inContent = false
+ }
+ tagName == "generator" -> {
+ currentTag = tagName
+ inContent = true
+ }
+ tagName == "summary" && namespace == ITUNES_NS -> {
+ if (currentItem != null) {
+ currentItem["itunesSummary"] = readElementText(parser)
+ }
+ }
+ tagName == "image" && namespace == ITUNES_NS -> {
+ val href = parser.getAttributeValue(null, "href")
+ if (href != null && currentItem != null) {
+ currentItem["image"] = href
+ }
+ }
+ tagName == "duration" && namespace == ITUNES_NS -> {
+ currentItem?.put("duration", readElementText(parser))
+ }
+ tagName == "thumbnail" && namespace == MEDIA_NS -> {
+ val url = parser.getAttributeValue(null, "url")
+ if (url != null && currentItem != null) {
+ currentItem["mediaThumbnail"] = url
+ }
+ }
+ tagName == "enclosure" && namespace == MEDIA_NS -> {
+ val url = parser.getAttributeValue(null, "url")
+ val type = parser.getAttributeValue(null, "type")
+ val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
+ if (url != null && type != null && currentItem != null) {
+ currentItem["enclosure"] = Enclosure(url, type, length)
+ }
+ }
+ else -> {}
+ }
+ }
+
+ XmlPullParser.TEXT -> {
+ val text = parser.text?.xmlTrimmed() ?: ""
+ if (text.isNotEmpty() && inContent) {
+ if (currentItem != null) {
+ when (currentTag) {
+ "title" -> currentItem["title"] = text
+ "summary" -> currentItem["summary"] = text
+ "content" -> currentItem["content"] = text
+ "name" -> currentItem["author"] = text
+ "id" -> currentItem["guid"] = text
+ "updated", "published" -> currentItem[currentTag] = text
+ }
+ } else {
+ when (currentTag) {
+ "title" -> title = text
+ "subtitle" -> subtitle = text
+ "id" -> if (title == null) title = text
+ "updated" -> updated = XmlDateParser.parse(text)
+ "generator" -> generator = text
+ }
+ }
+ }
+ }
+
+ XmlPullParser.END_TAG -> {
+ val tagName = parser.name
+ if (tagName == "entry" && currentItem != null) {
+ items.add(buildFeedItem(currentItem))
+ currentItem = null
+ }
+ if (tagName == currentTag) {
+ currentTag = null
+ inContent = false
+ }
+ }
+ }
+ eventType = parser.next()
+ }
+
+ return Feed(
+ id = generateUuid(),
+ title = title ?: "Untitled Feed",
+ link = link,
+ subtitle = subtitle,
+ description = subtitle,
+ updated = updated,
+ generator = generator,
+ items = items,
+ rawUrl = feedUrl,
+ lastFetchedAt = java.util.Date()
+ )
+ }
+
+ private fun readElementText(parser: XmlPullParser): String {
+ var text = ""
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_TAG) {
+ if (eventType == XmlPullParser.TEXT) {
+ text = parser.text.xmlDecoded()
+ }
+ eventType = parser.next()
+ }
+ return text.xmlTrimmed()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun buildFeedItem(item: Map): FeedItem {
+ val title = item["title"] as? String ?: "Untitled"
+ val link = item["link"] as? String
+ val summary = item["summary"] as? String
+ val content = item["content"] as? String ?: summary
+ val itunesSummary = item["itunesSummary"] as? String
+ val author = item["author"] as? String
+ val guid = item["guid"] as? String ?: link ?: generateUuid()
+ val categories = item["categories"] as? List
+ val enclosure = item["enclosure"] as? Enclosure
+
+ val updatedStr = item["updated"] as? String
+ val publishedStr = item["published"] as? String
+ val published = XmlDateParser.parse(publishedStr ?: updatedStr)
+ val updated = XmlDateParser.parse(updatedStr)
+
+ return FeedItem(
+ id = generateUuid(),
+ title = title,
+ link = link,
+ description = summary ?: itunesSummary,
+ content = content,
+ author = author,
+ published = published,
+ updated = updated,
+ categories = categories,
+ enclosure = enclosure,
+ guid = guid
+ )
+ }
+}
diff --git a/native-route/android/src/main/java/com/rssuper/parsing/FeedParser.kt b/native-route/android/src/main/java/com/rssuper/parsing/FeedParser.kt
new file mode 100644
index 0000000..7ffb3e4
--- /dev/null
+++ b/native-route/android/src/main/java/com/rssuper/parsing/FeedParser.kt
@@ -0,0 +1,67 @@
+package com.rssuper.parsing
+
+import com.rssuper.models.Feed
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.StringReader
+import java.util.Date
+
+object FeedParser {
+
+ fun parse(xml: String, feedUrl: String): ParseResult {
+ val feedType = detectFeedType(xml)
+
+ return when (feedType) {
+ FeedType.RSS -> {
+ val feed = RSSParser.parse(xml, feedUrl)
+ ParseResult(FeedType.RSS, feed)
+ }
+ FeedType.Atom -> {
+ val feed = AtomParser.parse(xml, feedUrl)
+ ParseResult(FeedType.Atom, feed)
+ }
+ }
+ }
+
+ fun parseAsync(xml: String, feedUrl: String, callback: (Result) -> Unit) {
+ try {
+ val result = parse(xml, feedUrl)
+ callback(Result.success(result))
+ } catch (e: Exception) {
+ callback(Result.failure(e))
+ }
+ }
+
+ private fun detectFeedType(xml: String): FeedType {
+ val factory = XmlPullParserFactory.newInstance()
+ factory.isNamespaceAware = true
+ val parser = factory.newPullParser()
+ parser.setInput(StringReader(xml))
+
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG) {
+ val tagName = parser.name
+ return when {
+ tagName.equals("rss", ignoreCase = true) -> FeedType.RSS
+ tagName.equals("feed", ignoreCase = true) -> FeedType.Atom
+ tagName.equals("RDF", ignoreCase = true) -> FeedType.RSS
+ else -> {
+ val namespace = parser.namespace
+ if (namespace != null && namespace.isNotEmpty()) {
+ when {
+ tagName.equals("rss", ignoreCase = true) -> FeedType.RSS
+ tagName.equals("feed", ignoreCase = true) -> FeedType.Atom
+ else -> throw FeedParsingError.UnsupportedFeedType
+ }
+ } else {
+ throw FeedParsingError.UnsupportedFeedType
+ }
+ }
+ }
+ }
+ eventType = parser.next()
+ }
+ throw FeedParsingError.UnsupportedFeedType
+ }
+}
diff --git a/native-route/android/src/main/java/com/rssuper/parsing/FeedType.kt b/native-route/android/src/main/java/com/rssuper/parsing/FeedType.kt
new file mode 100644
index 0000000..3af9689
--- /dev/null
+++ b/native-route/android/src/main/java/com/rssuper/parsing/FeedType.kt
@@ -0,0 +1,16 @@
+package com.rssuper.parsing
+
+sealed class FeedType(val value: String) {
+ data object RSS : FeedType("rss")
+ data object Atom : FeedType("atom")
+
+ companion object {
+ fun fromString(value: String): FeedType {
+ return when (value.lowercase()) {
+ "rss" -> RSS
+ "atom" -> Atom
+ else -> throw IllegalArgumentException("Unknown feed type: $value")
+ }
+ }
+ }
+}
diff --git a/native-route/android/src/main/java/com/rssuper/parsing/ParseResult.kt b/native-route/android/src/main/java/com/rssuper/parsing/ParseResult.kt
new file mode 100644
index 0000000..ad3167d
--- /dev/null
+++ b/native-route/android/src/main/java/com/rssuper/parsing/ParseResult.kt
@@ -0,0 +1,13 @@
+package com.rssuper.parsing
+
+import com.rssuper.models.Feed
+
+data class ParseResult(
+ val feedType: FeedType,
+ val feed: Feed
+)
+
+sealed class FeedParsingError : Exception() {
+ data object UnsupportedFeedType : FeedParsingError()
+ data object MalformedXml : FeedParsingError()
+}
diff --git a/native-route/android/src/main/java/com/rssuper/parsing/RSSParser.kt b/native-route/android/src/main/java/com/rssuper/parsing/RSSParser.kt
new file mode 100644
index 0000000..2a39131
--- /dev/null
+++ b/native-route/android/src/main/java/com/rssuper/parsing/RSSParser.kt
@@ -0,0 +1,188 @@
+package com.rssuper.parsing
+
+import com.rssuper.models.Enclosure
+import com.rssuper.models.Feed
+import com.rssuper.models.FeedItem
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.StringReader
+import java.util.Date
+
+object RSSParser {
+
+ private val ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
+ private val CONTENT_NS = "http://purl.org/rss/1.0/modules/content/"
+
+ fun parse(xml: String, feedUrl: String): Feed {
+ val factory = XmlPullParserFactory.newInstance()
+ factory.isNamespaceAware = true
+ val parser = factory.newPullParser()
+ parser.setInput(StringReader(xml))
+
+ var title: String? = null
+ var link: String? = null
+ var description: String? = null
+ var language: String? = null
+ var lastBuildDate: Date? = null
+ var generator: String? = null
+ var ttl: Int? = null
+ val items = mutableListOf()
+
+ var currentItem: MutableMap? = null
+ var currentTag: String? = null
+
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ val tagName = parser.name
+ val namespace = parser.namespace
+
+ when {
+ tagName == "channel" -> {}
+ tagName == "item" -> {
+ currentItem = mutableMapOf()
+ }
+ tagName == "title" || tagName == "description" ||
+ tagName == "link" || tagName == "author" ||
+ tagName == "guid" || tagName == "pubDate" ||
+ tagName == "category" || tagName == "enclosure" -> {
+ currentTag = tagName
+ }
+ tagName == "language" -> currentTag = tagName
+ tagName == "lastBuildDate" -> currentTag = tagName
+ tagName == "generator" -> currentTag = tagName
+ tagName == "ttl" -> currentTag = tagName
+
+ tagName == "subtitle" && namespace == ITUNES_NS -> {
+ if (currentItem == null) {
+ description = readElementText(parser)
+ }
+ }
+ tagName == "summary" && namespace == ITUNES_NS -> {
+ currentItem?.put("description", readElementText(parser))
+ }
+ tagName == "duration" && namespace == ITUNES_NS -> {
+ currentItem?.put("duration", readElementText(parser))
+ }
+ tagName == "image" && namespace == ITUNES_NS -> {
+ val href = parser.getAttributeValue(null, "href")
+ if (href != null && currentItem != null) {
+ currentItem.put("image", href)
+ }
+ }
+ tagName == "encoded" && namespace == CONTENT_NS -> {
+ currentItem?.put("content", readElementText(parser))
+ }
+ else -> {}
+ }
+
+ if (tagName == "enclosure" && currentItem != null) {
+ val url = parser.getAttributeValue(null, "url")
+ val type = parser.getAttributeValue(null, "type")
+ val length = parser.getAttributeValue(null, "length")?.toLongOrNull()
+ if (url != null && type != null) {
+ currentItem["enclosure"] = Enclosure(url, type, length)
+ }
+ }
+ }
+
+ XmlPullParser.TEXT -> {
+ val text = parser.text?.xmlTrimmed() ?: ""
+ if (text.isNotEmpty()) {
+ if (currentItem != null) {
+ when (currentTag) {
+ "title" -> currentItem["title"] = text
+ "description" -> currentItem["description"] = text
+ "link" -> currentItem["link"] = text
+ "author" -> currentItem["author"] = text
+ "guid" -> currentItem["guid"] = text
+ "pubDate" -> currentItem["pubDate"] = text
+ "category" -> {
+ val cats = currentItem["categories"] as? MutableList ?: mutableListOf()
+ cats.add(text)
+ currentItem["categories"] = cats
+ }
+ }
+ } else {
+ when (currentTag) {
+ "title" -> title = text
+ "link" -> link = text
+ "description" -> description = text
+ "language" -> language = text
+ "lastBuildDate" -> lastBuildDate = XmlDateParser.parse(text)
+ "generator" -> generator = text
+ "ttl" -> ttl = text.toIntOrNull()
+ }
+ }
+ }
+ }
+
+ XmlPullParser.END_TAG -> {
+ val tagName = parser.name
+ if (tagName == "item" && currentItem != null) {
+ items.add(buildFeedItem(currentItem))
+ currentItem = null
+ }
+ currentTag = null
+ }
+ }
+ eventType = parser.next()
+ }
+
+ return Feed(
+ id = generateUuid(),
+ title = title ?: "Untitled Feed",
+ link = link,
+ description = description,
+ language = language,
+ lastBuildDate = lastBuildDate,
+ generator = generator,
+ ttl = ttl,
+ items = items,
+ rawUrl = feedUrl,
+ lastFetchedAt = Date()
+ )
+ }
+
+ private fun readElementText(parser: XmlPullParser): String {
+ var text = ""
+ var eventType = parser.eventType
+ while (eventType != XmlPullParser.END_TAG) {
+ if (eventType == XmlPullParser.TEXT) {
+ text = parser.text.xmlDecoded()
+ }
+ eventType = parser.next()
+ }
+ return text.xmlTrimmed()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun buildFeedItem(item: Map): FeedItem {
+ val title = item["title"] as? String ?: "Untitled"
+ val link = item["link"] as? String
+ val description = item["description"] as? String
+ val content = item["content"] as? String ?: description
+ val author = item["author"] as? String
+ val guid = item["guid"] as? String ?: link ?: generateUuid()
+ val categories = item["categories"] as? List
+ val enclosure = item["enclosure"] as? Enclosure
+
+ val pubDateStr = item["pubDate"] as? String
+ val published = XmlDateParser.parse(pubDateStr)
+
+ return FeedItem(
+ id = generateUuid(),
+ title = title,
+ link = link,
+ description = description,
+ content = content,
+ author = author,
+ published = published,
+ updated = published,
+ categories = categories,
+ enclosure = enclosure,
+ guid = guid
+ )
+ }
+}
diff --git a/native-route/android/src/main/java/com/rssuper/parsing/XmlParsingUtilities.kt b/native-route/android/src/main/java/com/rssuper/parsing/XmlParsingUtilities.kt
new file mode 100644
index 0000000..e22fea0
--- /dev/null
+++ b/native-route/android/src/main/java/com/rssuper/parsing/XmlParsingUtilities.kt
@@ -0,0 +1,154 @@
+package com.rssuper.parsing
+
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+import java.util.UUID
+import java.util.regex.Pattern
+
+object XmlDateParser {
+ private val iso8601WithFractional: SimpleDateFormat by lazy {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ }
+
+ private val iso8601: SimpleDateFormat by lazy {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ }
+
+ private val dateFormats: List by lazy {
+ listOf(
+ SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US),
+ SimpleDateFormat("EEE, dd MMM yyyy HH:mm Z", Locale.US),
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US),
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US),
+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z", Locale.US),
+ SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ ).map {
+ SimpleDateFormat(it.toPattern(), Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ }
+ }
+
+ fun parse(value: String?): java.util.Date? {
+ val trimmed = value?.xmlTrimmed() ?: return null
+ if (trimmed.isEmpty()) return null
+
+ return try {
+ iso8601WithFractional.parse(trimmed)
+ } catch (e: Exception) {
+ try {
+ iso8601.parse(trimmed)
+ } catch (e: Exception) {
+ for (format in dateFormats) {
+ try {
+ return format.parse(trimmed)
+ } catch (e: Exception) {
+ continue
+ }
+ }
+ null
+ }
+ }
+ }
+}
+
+fun String.xmlTrimmed(): String = this.trim { it <= ' ' }
+
+fun String.xmlNilIfEmpty(): String? {
+ val trimmed = this.xmlTrimmed()
+ return if (trimmed.isEmpty()) null else trimmed
+}
+
+fun String.xmlDecoded(): String {
+ return this
+ .replace(Regex("", RegexOption.IGNORE_CASE), "")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+ .replace(""", "\"")
+ .replace("'", "'")
+ .replace("'", "'")
+ .replace("'", "'")
+}
+
+fun xmlInt64(value: String?): Long? {
+ val trimmed = value?.xmlTrimmed() ?: return null
+ if (trimmed.isEmpty()) return null
+ return trimmed.toLongOrNull()
+}
+
+fun xmlInt(value: String?): Int? {
+ val trimmed = value?.xmlTrimmed() ?: return null
+ if (trimmed.isEmpty()) return null
+ return trimmed.toIntOrNull()
+}
+
+fun xmlFirstTagValue(tag: String, inXml: String): String? {
+ val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
+ val matcher = pattern.matcher(inXml)
+ return if (matcher.find()) {
+ matcher.group(1)?.xmlDecoded()?.xmlTrimmed()
+ } else {
+ null
+ }
+}
+
+fun xmlAllTagValues(tag: String, inXml: String): List {
+ val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
+ val matcher = pattern.matcher(inXml)
+ val results = mutableListOf()
+ while (matcher.find()) {
+ matcher.group(1)?.xmlDecoded()?.xmlTrimmed()?.let { value ->
+ if (value.isNotEmpty()) {
+ results.add(value)
+ }
+ }
+ }
+ return results
+}
+
+fun xmlFirstBlock(tag: String, inXml: String): String? {
+ val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
+ val matcher = pattern.matcher(inXml)
+ return if (matcher.find()) matcher.group(1) else null
+}
+
+fun xmlAllBlocks(tag: String, inXml: String): List {
+ val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
+ val matcher = pattern.matcher(inXml)
+ val results = mutableListOf()
+ while (matcher.find()) {
+ matcher.group(1)?.let { results.add(it) }
+ }
+ return results
+}
+
+fun xmlAllTagAttributes(tag: String, inXml: String): List
]]>
+
+
+
+ """.trimIndent()
+
+ val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
+
+ assertNotNull(feed)
+ assertEquals(1, feed.items.size)
+ assertEquals("Item with Content", feed.items[0].title)
+ assertEquals("Full content here
", feed.items[0].content)
+ }
+
+ @Test
+ fun testParseRSSWithCategories() {
+ val xml = """
+
+
+
+ Categorized Feed
+ -
+ Tech Article
+ Technology
+ Programming
+
+
+
+ """.trimIndent()
+
+ val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
+
+ assertNotNull(feed)
+ val item = feed.items[0]
+ assertEquals(2, item.categories?.size)
+ assertEquals("Technology", item.categories?.get(0))
+ assertEquals("Programming", item.categories?.get(1))
+ }
+
+ @Test
+ fun testParseRSSWithAuthor() {
+ val xml = """
+
+
+
+ Author Feed
+ -
+ Article by Author
+ author@example.com (John Doe)
+
+
+
+ """.trimIndent()
+
+ val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
+
+ assertNotNull(feed)
+ val item = feed.items[0]
+ assertEquals("author@example.com (John Doe)", item.author)
+ }
+
+ @Test
+ fun testParseRSSWithGuid() {
+ val xml = """
+
+
+
+ Guid Feed
+ -
+ Item
+ custom-guid-12345
+
+
+
+ """.trimIndent()
+
+ val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
+
+ assertNotNull(feed)
+ assertEquals("custom-guid-12345", feed.items[0].guid)
+ }
+
+ @Test
+ fun testParseRSSWithEmptyChannel() {
+ val xml = """
+
+
+
+ Minimal Feed
+
+
+ """.trimIndent()
+
+ val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
+
+ assertNotNull(feed)
+ assertEquals("Minimal Feed", feed.title)
+ assertEquals(0, feed.items.size)
+ }
+
+ @Test
+ fun testParseRSSWithMissingFields() {
+ val xml = """
+
+
+
+ -
+ Only Title
+
+
+
+ """.trimIndent()
+
+ val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
+
+ assertNotNull(feed)
+ assertEquals("Untitled Feed", feed.title)
+ assertEquals(1, feed.items.size)
+ assertEquals("Only Title", feed.items[0].title)
+ assertNull(feed.items[0].link)
+ }
+
+ @Test
+ fun testParseRSSWithCDATA() {
+ val xml = """
+
+
+
+
+ HTML content]]>
+ -
+ CDATA Item
+
+
+
+
+ """.trimIndent()
+
+ val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
+
+ assertNotNull(feed)
+ assertEquals("CDATA Title", feed.title)
+ assertEquals("HTML content
", feed.description)
+ assertEquals("Item content", feed.items[0].description)
+ }
+}