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[^>]*>(.*?)", 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[^>]*>(.*?)", 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[^>]*>(.*?)", 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[^>]*>(.*?)", 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> { + val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b([^>]*)/?>", Pattern.CASE_INSENSITIVE) + val matcher = pattern.matcher(inXml) + val results = mutableListOf>() + while (matcher.find()) { + matcher.group(1)?.let { results.add(parseXmlAttributes(it)) } + } + return results +} + +private fun parseXmlAttributes(raw: String): Map { + val pattern = Pattern.compile("(\\w+(?::\\w+)?)\\s*=\\s*\"([^\"]*)\"") + val matcher = pattern.matcher(raw) + val result = mutableMapOf() + while (matcher.find()) { + val key = matcher.group(1)?.lowercase() ?: continue + val value = matcher.group(2)?.xmlDecoded()?.xmlTrimmed() ?: continue + result[key] = value + } + return result +} + +fun generateUuid(): String = UUID.randomUUID().toString() diff --git a/native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt b/native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt index c03793f..2954016 100644 --- a/native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt +++ b/native-route/android/src/test/java/com/rssuper/database/RssDatabaseTest.kt @@ -46,9 +46,9 @@ class RssDatabaseTest { @Test fun ftsVirtualTableExists() { val cursor = database.run { - openHelper.writableDatabase.rawQuery( + openHelper.writableDatabase.query( "SELECT name FROM sqlite_master WHERE type='table' AND name='feed_items_fts'", - null + emptyArray() ) } diff --git a/native-route/android/src/test/java/com/rssuper/models/SearchResultTest.kt b/native-route/android/src/test/java/com/rssuper/models/SearchResultTest.kt index bbd6982..aedd853 100644 --- a/native-route/android/src/test/java/com/rssuper/models/SearchResultTest.kt +++ b/native-route/android/src/test/java/com/rssuper/models/SearchResultTest.kt @@ -122,7 +122,7 @@ class SearchResultTest { assertEquals("article-1", modified.id) assertEquals(SearchResultType.ARTICLE, modified.type) assertEquals("Modified Title", modified.title) - assertEquals(0.99, modified.score, 0.001) + assertEquals(0.99, modified.score!!, 0.001) } @Test diff --git a/native-route/android/src/test/java/com/rssuper/parsing/AtomParserTest.kt b/native-route/android/src/test/java/com/rssuper/parsing/AtomParserTest.kt new file mode 100644 index 0000000..2bac758 --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/parsing/AtomParserTest.kt @@ -0,0 +1,245 @@ +package com.rssuper.parsing + +import com.rssuper.models.Enclosure +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24]) +class AtomParserTest { + + @Test + fun testParseBasicAtom() { + val xml = """ + + + Atom Feed + Feed subtitle + + urn:uuid:feed-id-123 + 2024-01-01T12:00:00Z + Atom Generator + + Entry 1 + + urn:uuid:entry-1 + 2024-01-01T10:00:00Z + Summary of entry 1 + + + Entry 2 + + urn:uuid:entry-2 + 2023-12-31T10:00:00Z + Full content of entry 2 + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + assertEquals("Atom Feed", feed.title) + assertEquals("https://example.com", feed.link) + assertEquals("Feed subtitle", feed.subtitle) + assertEquals(2, feed.items.size) + + val entry1 = feed.items[0] + assertEquals("Entry 1", entry1.title) + assertEquals("https://example.com/entry1", entry1.link) + assertEquals("Summary of entry 1", entry1.description) + assertNotNull(entry1.published) + + val entry2 = feed.items[1] + assertEquals("Entry 2", entry2.title) + assertEquals("Full content of entry 2", entry2.content) + } + + @Test + fun testParseAtomWithAuthor() { + val xml = """ + + + Author Feed + urn:uuid:feed-id + + Entry with Author + urn:uuid:entry + + John Doe + john@example.com + + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + val entry = feed.items[0] + assertEquals("John Doe", entry.author) + } + + @Test + fun testParseAtomWithCategories() { + val xml = """ + + + Categorized Feed + urn:uuid:feed-id + + Categorized Entry + urn:uuid:entry + + + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + val entry = feed.items[0] + assertEquals(2, entry.categories?.size) + assertEquals("technology", entry.categories?.get(0)) + assertEquals("programming", entry.categories?.get(1)) + } + + @Test + fun testParseAtomWithEnclosure() { + val xml = """ + + + Enclosure Feed + urn:uuid:feed-id + + Episode + urn:uuid:entry + + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + val entry = feed.items[0] + assertNotNull(entry.enclosure) + assertEquals("https://example.com/ep.mp3", entry.enclosure?.url) + assertEquals("audio/mpeg", entry.enclosure?.type) + assertEquals(12345678L, entry.enclosure?.length) + } + + @Test + fun testParseAtomWithContent() { + val xml = """ + + + Content Feed + urn:uuid:feed-id + + Entry + urn:uuid:entry + Short summary + Full HTML content + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + val entry = feed.items[0] + assertEquals("Full HTML content", entry.content) + assertEquals("Short summary", entry.description) + } + + @Test + fun testParseAtomWithiTunesExtension() { + val xml = """ + + + Podcast + urn:uuid:feed-id + + Episode + urn:uuid:entry + 3600 + Episode summary + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + val entry = feed.items[0] + assertEquals("Episode summary", entry.description) + } + + @Test + fun testParseAtomWithPublished() { + val xml = """ + + + Date Feed + urn:uuid:feed-id + 2024-06-15T12:00:00Z + + Entry + urn:uuid:entry + 2024-01-01T08:00:00Z + 2024-01-02T10:00:00Z + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + val entry = feed.items[0] + assertNotNull(entry.published) + } + + @Test + fun testParseAtomWithEmptyFeed() { + val xml = """ + + + Empty Feed + urn:uuid:feed-id + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + assertEquals("Empty Feed", feed.title) + assertEquals(0, feed.items.size) + } + + @Test + fun testParseAtomWithMissingFields() { + val xml = """ + + + + Minimal Entry + + + """.trimIndent() + + val feed = AtomParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(feed) + assertEquals("Untitled Feed", feed.title) + assertEquals(1, feed.items.size) + assertEquals("Minimal Entry", feed.items[0].title) + assertNull(feed.items[0].link) + } +} diff --git a/native-route/android/src/test/java/com/rssuper/parsing/FeedParserTest.kt b/native-route/android/src/test/java/com/rssuper/parsing/FeedParserTest.kt new file mode 100644 index 0000000..0d98619 --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/parsing/FeedParserTest.kt @@ -0,0 +1,162 @@ +package com.rssuper.parsing + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24]) +class FeedParserTest { + + @Test + fun testParseRSSFeed() { + val xml = """ + + + + RSS Feed + https://example.com + + Item + https://example.com/item + + + + """.trimIndent() + + val result = FeedParser.parse(xml, "https://example.com/feed.xml") + + assertNotNull(result) + assertEquals(FeedType.RSS, result.feedType) + assertEquals("RSS Feed", result.feed.title) + } + + @Test + fun testParseAtomFeed() { + val xml = """ + + + Atom Feed + urn:uuid:feed + + Entry + urn:uuid:entry + + + """.trimIndent() + + val result = FeedParser.parse(xml, "https://example.com/feed.atom") + + assertNotNull(result) + assertEquals(FeedType.Atom, result.feedType) + assertEquals("Atom Feed", result.feed.title) + } + + @Test + fun testParseRSSWithNamespaces() { + val xml = """ + + + + Namespaced Feed + + Author + + Item + + + + """.trimIndent() + + val result = FeedParser.parse(xml, "https://example.com/feed.xml") + + assertNotNull(result) + assertEquals(FeedType.RSS, result.feedType) + } + + @Test + fun testParseMalformedXml() { + val malformedXml = """ + + + + Broken + """.trimIndent() + + try { + val result = FeedParser.parse(malformedXml, "https://example.com/feed.xml") + assertNotNull(result) + } catch (e: Exception) { + assertNotNull(e) + } + } + + @Test + fun testParseInvalidFeedType() { + val invalidXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <invalid> + <data>Some data</data> + </invalid> + """.trimIndent() + + try { + FeedParser.parse(invalidXml, "https://example.com/feed.xml") + fail("Expected exception for invalid feed type") + } catch (e: FeedParsingError) { + assertEquals(FeedParsingError.UnsupportedFeedType, e) + } + } + + @Test + fun testParseEmptyFeed() { + val emptyXml = """ + <?xml version="1.0" encoding="UTF-8"?> + <rss version="2.0"> + <channel> + <title> + + + """.trimIndent() + + val result = FeedParser.parse(emptyXml, "https://example.com/feed.xml") + + assertNotNull(result) + assertEquals("Untitled Feed", result.feed.title) + } + + @Test + fun testAsyncCallback() { + val xml = """ + + + + Async Feed + + Item + + + + """.trimIndent() + + FeedParser.parseAsync(xml, "https://example.com/feed.xml") { result -> + assert(result.isSuccess) + val feed = result.getOrNull() + assertNotNull(feed) + assertEquals("Async Feed", feed?.feed?.title) + } + } + + @Test + fun testAsyncCallbackError() { + val invalidXml = "not xml" + + FeedParser.parseAsync(invalidXml, "https://example.com/feed.xml") { result -> + assert(result.isFailure) + } + } +} diff --git a/native-route/android/src/test/java/com/rssuper/parsing/RSSParserTest.kt b/native-route/android/src/test/java/com/rssuper/parsing/RSSParserTest.kt new file mode 100644 index 0000000..307dc9b --- /dev/null +++ b/native-route/android/src/test/java/com/rssuper/parsing/RSSParserTest.kt @@ -0,0 +1,255 @@ +package com.rssuper.parsing + +import com.rssuper.models.Enclosure +import com.rssuper.models.Feed +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24]) +class RSSParserTest { + + @Test + fun testParseBasicRSS() { + val xml = """ + + + + Test Feed + https://example.com + A test feed + en-us + Mon, 01 Jan 2024 12:00:00 GMT + RSS Generator + 60 + + Item 1 + https://example.com/item1 + Description of item 1 + https://example.com/item1 + Mon, 01 Jan 2024 10:00:00 GMT + + + Item 2 + https://example.com/item2 + Description of item 2 + item-2-guid + Sun, 31 Dec 2023 10:00:00 GMT + + + + """.trimIndent() + + val feed = RSSParser.parse(xml, "https://example.com/feed.xml") + + assertNotNull(feed) + assertEquals("Test Feed", feed.title) + assertEquals("https://example.com", feed.link) + assertEquals("A test feed", feed.description) + assertEquals("en-us", feed.language) + assertEquals(60, feed.ttl) + assertEquals(2, feed.items.size) + + val item1 = feed.items[0] + assertEquals("Item 1", item1.title) + assertEquals("https://example.com/item1", item1.link) + assertEquals("Description of item 1", item1.description) + assertNotNull(item1.published) + } + + @Test + fun testParseRSSWithiTunesNamespace() { + val xml = """ + + + + Podcast Feed + https://example.com/podcast + My podcast + Podcast subtitle + Author Name + + Episode 1 + https://example.com/episode1 + Episode description + 01:30:00 + + + + + """.trimIndent() + + val feed = RSSParser.parse(xml, "https://example.com/feed.xml") + + assertNotNull(feed) + assertEquals("Podcast Feed", feed.title) + + val item = feed.items[0] + assertEquals("Episode 1", item.title) + assertNotNull(item.enclosure) + assertEquals("https://example.com/ep1.mp3", item.enclosure?.url) + assertEquals("audio/mpeg", item.enclosure?.type) + assertEquals(12345678L, item.enclosure?.length) + } + + @Test + fun testParseRSSWithContentNamespace() { + val xml = """ + + + + Feed with Content + + Item with Content + Short description + Full content here

]]>
+
+
+
+ """.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 = """ + + + + <![CDATA[CDATA Title]]> + 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) + } +}