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
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.library")
|
id("com.android.library") version "8.5.0"
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android") version "1.9.22"
|
||||||
id("kotlin-parcelize")
|
id("org.jetbrains.kotlin.plugin.parcelize") version "1.9.22"
|
||||||
id("kotlin-kapt")
|
id("org.jetbrains.kotlin.kapt") version "1.9.22"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -23,29 +23,40 @@ android {
|
|||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
java.srcDirs("src/main/java")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
|
||||||
// AndroidX
|
// AndroidX
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
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-runtime:2.6.1")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
kapt("androidx.room:room-compiler:2.6.1")
|
kapt("androidx.room:room-compiler:2.6.1")
|
||||||
|
|
||||||
// Moshi for JSON serialization
|
// Moshi for JSON serialization
|
||||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
implementation("com.squareup.moshi:moshi:1.15.0")
|
||||||
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
|
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
|
||||||
implementation("com.squareup.moshi:moshi-kotlin-reflect:1.15.1")
|
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("com.squareup.moshi:moshi-kotlin:1.15.1")
|
testImplementation("com.squareup.moshi:moshi:1.15.0")
|
||||||
testImplementation("com.squareup.moshi:moshi-kotlin-reflect:1.15.1")
|
testImplementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||||
testImplementation("org.mockito:mockito-core:5.7.0")
|
testImplementation("org.mockito:mockito-core:5.7.0")
|
||||||
testImplementation("org.mockito:mockito-inline:5.2.0")
|
testImplementation("org.mockito:mockito-inline:5.2.0")
|
||||||
testImplementation("androidx.room:room-testing:2.6.1")
|
testImplementation("androidx.room:room-testing:2.6.1")
|
||||||
@@ -54,4 +65,5 @@ dependencies {
|
|||||||
testImplementation("androidx.test:core:1.5.0")
|
testImplementation("androidx.test:core:1.5.0")
|
||||||
testImplementation("androidx.test.ext:junit:1.1.5")
|
testImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
testImplementation("androidx.test:runner:1.5.2")
|
testImplementation("androidx.test:runner:1.5.2")
|
||||||
|
testImplementation("org.robolectric:robolectric:4.11.1")
|
||||||
}
|
}
|
||||||
|
|||||||
6
native-route/android/gradle.properties
Normal file
6
native-route/android/gradle.properties
Normal file
@@ -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
|
||||||
7
native-route/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
native-route/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||||
170
native-route/android/gradlew
vendored
Executable file
170
native-route/android/gradlew
vendored
Executable file
@@ -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" "$@"
|
||||||
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "rssuper-android"
|
rootProject.name = "RSSuper"
|
||||||
include(":android")
|
include(":android")
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ class FeedItemListConverter {
|
|||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun toFeedItemList(value: String?): List<FeedItem>? {
|
fun toFeedItemList(value: String?): List<FeedItem>? {
|
||||||
return value?.let { adapter.fromJson(it) }
|
return value?.let { adapter.fromJson(it) as? List<FeedItem> }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.os.Parcelable
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.RawValue
|
||||||
import com.squareup.moshi.Json
|
import com.squareup.moshi.Json
|
||||||
import com.squareup.moshi.JsonClass
|
import com.squareup.moshi.JsonClass
|
||||||
|
|
||||||
@@ -15,10 +16,10 @@ data class ReadingPreferences(
|
|||||||
val id: String = "default",
|
val id: String = "default",
|
||||||
|
|
||||||
@Json(name = "fontSize")
|
@Json(name = "fontSize")
|
||||||
val fontSize: FontSize = FontSize.MEDIUM,
|
val fontSize: @RawValue FontSize = FontSize.MEDIUM,
|
||||||
|
|
||||||
@Json(name = "lineHeight")
|
@Json(name = "lineHeight")
|
||||||
val lineHeight: LineHeight = LineHeight.NORMAL,
|
val lineHeight: @RawValue LineHeight = LineHeight.NORMAL,
|
||||||
|
|
||||||
@Json(name = "showTableOfContents")
|
@Json(name = "showTableOfContents")
|
||||||
val showTableOfContents: Boolean = false,
|
val showTableOfContents: Boolean = false,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.room.TypeConverters
|
|||||||
import com.rssuper.converters.DateConverter
|
import com.rssuper.converters.DateConverter
|
||||||
import com.rssuper.converters.StringListConverter
|
import com.rssuper.converters.StringListConverter
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.RawValue
|
||||||
import com.squareup.moshi.Json
|
import com.squareup.moshi.Json
|
||||||
import com.squareup.moshi.JsonClass
|
import com.squareup.moshi.JsonClass
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -32,10 +33,10 @@ data class SearchFilters(
|
|||||||
val authors: List<String>? = null,
|
val authors: List<String>? = null,
|
||||||
|
|
||||||
@Json(name = "contentType")
|
@Json(name = "contentType")
|
||||||
val contentType: ContentType? = null,
|
val contentType: @RawValue ContentType? = null,
|
||||||
|
|
||||||
@Json(name = "sortOption")
|
@Json(name = "sortOption")
|
||||||
val sortOption: SearchSortOption = SearchSortOption.RELEVANCE
|
val sortOption: @RawValue SearchSortOption = SearchSortOption.RELEVANCE
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|
||||||
sealed class ContentType(val value: String) {
|
sealed class ContentType(val value: String) {
|
||||||
|
|||||||
@@ -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<FeedItem>()
|
||||||
|
|
||||||
|
var currentItem: MutableMap<String, Any?>? = 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<String> ?: 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<String, Any?>): 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<String>
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ParseResult>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<FeedItem>()
|
||||||
|
|
||||||
|
var currentItem: MutableMap<String, Any?>? = 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<String> ?: 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<String, Any?>): 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<String>
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SimpleDateFormat> 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("<!\\[CDATA\\[", RegexOption.IGNORE_CASE), "")
|
||||||
|
.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<String> {
|
||||||
|
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
|
||||||
|
val matcher = pattern.matcher(inXml)
|
||||||
|
val results = mutableListOf<String>()
|
||||||
|
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<String> {
|
||||||
|
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b[^>]*>(.*?)</(?:\\w+:)?$tag}>", Pattern.CASE_INSENSITIVE)
|
||||||
|
val matcher = pattern.matcher(inXml)
|
||||||
|
val results = mutableListOf<String>()
|
||||||
|
while (matcher.find()) {
|
||||||
|
matcher.group(1)?.let { results.add(it) }
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
fun xmlAllTagAttributes(tag: String, inXml: String): List<Map<String, String>> {
|
||||||
|
val pattern = Pattern.compile("(?is)<(?:\\w+:)?$tag\\b([^>]*)/?>", Pattern.CASE_INSENSITIVE)
|
||||||
|
val matcher = pattern.matcher(inXml)
|
||||||
|
val results = mutableListOf<Map<String, String>>()
|
||||||
|
while (matcher.find()) {
|
||||||
|
matcher.group(1)?.let { results.add(parseXmlAttributes(it)) }
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseXmlAttributes(raw: String): Map<String, String> {
|
||||||
|
val pattern = Pattern.compile("(\\w+(?::\\w+)?)\\s*=\\s*\"([^\"]*)\"")
|
||||||
|
val matcher = pattern.matcher(raw)
|
||||||
|
val result = mutableMapOf<String, String>()
|
||||||
|
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()
|
||||||
@@ -46,9 +46,9 @@ class RssDatabaseTest {
|
|||||||
@Test
|
@Test
|
||||||
fun ftsVirtualTableExists() {
|
fun ftsVirtualTableExists() {
|
||||||
val cursor = database.run {
|
val cursor = database.run {
|
||||||
openHelper.writableDatabase.rawQuery(
|
openHelper.writableDatabase.query(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='feed_items_fts'",
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='feed_items_fts'",
|
||||||
null
|
emptyArray()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class SearchResultTest {
|
|||||||
assertEquals("article-1", modified.id)
|
assertEquals("article-1", modified.id)
|
||||||
assertEquals(SearchResultType.ARTICLE, modified.type)
|
assertEquals(SearchResultType.ARTICLE, modified.type)
|
||||||
assertEquals("Modified Title", modified.title)
|
assertEquals("Modified Title", modified.title)
|
||||||
assertEquals(0.99, modified.score, 0.001)
|
assertEquals(0.99, modified.score!!, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Atom Feed</title>
|
||||||
|
<subtitle>Feed subtitle</subtitle>
|
||||||
|
<link href="https://example.com" rel="alternate"/>
|
||||||
|
<id>urn:uuid:feed-id-123</id>
|
||||||
|
<updated>2024-01-01T12:00:00Z</updated>
|
||||||
|
<generator>Atom Generator</generator>
|
||||||
|
<entry>
|
||||||
|
<title>Entry 1</title>
|
||||||
|
<link href="https://example.com/entry1" rel="alternate"/>
|
||||||
|
<id>urn:uuid:entry-1</id>
|
||||||
|
<updated>2024-01-01T10:00:00Z</updated>
|
||||||
|
<summary>Summary of entry 1</summary>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<title>Entry 2</title>
|
||||||
|
<link href="https://example.com/entry2" rel="alternate"/>
|
||||||
|
<id>urn:uuid:entry-2</id>
|
||||||
|
<updated>2023-12-31T10:00:00Z</updated>
|
||||||
|
<content>Full content of entry 2</content>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Author Feed</title>
|
||||||
|
<id>urn:uuid:feed-id</id>
|
||||||
|
<entry>
|
||||||
|
<title>Entry with Author</title>
|
||||||
|
<id>urn:uuid:entry</id>
|
||||||
|
<author>
|
||||||
|
<name>John Doe</name>
|
||||||
|
<email>john@example.com</email>
|
||||||
|
</author>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Categorized Feed</title>
|
||||||
|
<id>urn:uuid:feed-id</id>
|
||||||
|
<entry>
|
||||||
|
<title>Categorized Entry</title>
|
||||||
|
<id>urn:uuid:entry</id>
|
||||||
|
<category term="technology"/>
|
||||||
|
<category term="programming"/>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Enclosure Feed</title>
|
||||||
|
<id>urn:uuid:feed-id</id>
|
||||||
|
<entry>
|
||||||
|
<title>Episode</title>
|
||||||
|
<id>urn:uuid:entry</id>
|
||||||
|
<link href="https://example.com/ep.mp3" rel="enclosure" type="audio/mpeg" length="12345678"/>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Content Feed</title>
|
||||||
|
<id>urn:uuid:feed-id</id>
|
||||||
|
<entry>
|
||||||
|
<title>Entry</title>
|
||||||
|
<id>urn:uuid:entry</id>
|
||||||
|
<summary>Short summary</summary>
|
||||||
|
<content>Full HTML content</content>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<title>Podcast</title>
|
||||||
|
<id>urn:uuid:feed-id</id>
|
||||||
|
<entry>
|
||||||
|
<title>Episode</title>
|
||||||
|
<id>urn:uuid:entry</id>
|
||||||
|
<itunes:duration>3600</itunes:duration>
|
||||||
|
<itunes:summary>Episode summary</itunes:summary>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Date Feed</title>
|
||||||
|
<id>urn:uuid:feed-id</id>
|
||||||
|
<updated>2024-06-15T12:00:00Z</updated>
|
||||||
|
<entry>
|
||||||
|
<title>Entry</title>
|
||||||
|
<id>urn:uuid:entry</id>
|
||||||
|
<published>2024-01-01T08:00:00Z</published>
|
||||||
|
<updated>2024-01-02T10:00:00Z</updated>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Empty Feed</title>
|
||||||
|
<id>urn:uuid:feed-id</id>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<entry>
|
||||||
|
<title>Minimal Entry</title>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>RSS Feed</title>
|
||||||
|
<link>https://example.com</link>
|
||||||
|
<item>
|
||||||
|
<title>Item</title>
|
||||||
|
<link>https://example.com/item</link>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Atom Feed</title>
|
||||||
|
<id>urn:uuid:feed</id>
|
||||||
|
<entry>
|
||||||
|
<title>Entry</title>
|
||||||
|
<id>urn:uuid:entry</id>
|
||||||
|
</entry>
|
||||||
|
</feed>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<channel>
|
||||||
|
<title>Namespaced Feed</title>
|
||||||
|
<atom:link href="https://example.com/feed.xml" rel="self"/>
|
||||||
|
<itunes:author>Author</itunes:author>
|
||||||
|
<item>
|
||||||
|
<title>Item</title>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = FeedParser.parse(xml, "https://example.com/feed.xml")
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(FeedType.RSS, result.feedType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseMalformedXml() {
|
||||||
|
val malformedXml = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss>
|
||||||
|
<channel>
|
||||||
|
<title>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></title>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val result = FeedParser.parse(emptyXml, "https://example.com/feed.xml")
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("Untitled Feed", result.feed.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAsyncCallback() {
|
||||||
|
val xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Async Feed</title>
|
||||||
|
<item>
|
||||||
|
<title>Item</title>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Test Feed</title>
|
||||||
|
<link>https://example.com</link>
|
||||||
|
<description>A test feed</description>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate>Mon, 01 Jan 2024 12:00:00 GMT</lastBuildDate>
|
||||||
|
<generator>RSS Generator</generator>
|
||||||
|
<ttl>60</ttl>
|
||||||
|
<item>
|
||||||
|
<title>Item 1</title>
|
||||||
|
<link>https://example.com/item1</link>
|
||||||
|
<description>Description of item 1</description>
|
||||||
|
<guid isPermaLink="true">https://example.com/item1</guid>
|
||||||
|
<pubDate>Mon, 01 Jan 2024 10:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Item 2</title>
|
||||||
|
<link>https://example.com/item2</link>
|
||||||
|
<description>Description of item 2</description>
|
||||||
|
<guid>item-2-guid</guid>
|
||||||
|
<pubDate>Sun, 31 Dec 2023 10:00:00 GMT</pubDate>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<channel>
|
||||||
|
<title>Podcast Feed</title>
|
||||||
|
<link>https://example.com/podcast</link>
|
||||||
|
<description>My podcast</description>
|
||||||
|
<itunes:subtitle>Podcast subtitle</itunes:subtitle>
|
||||||
|
<itunes:author>Author Name</itunes:author>
|
||||||
|
<item>
|
||||||
|
<title>Episode 1</title>
|
||||||
|
<link>https://example.com/episode1</link>
|
||||||
|
<description>Episode description</description>
|
||||||
|
<itunes:duration>01:30:00</itunes:duration>
|
||||||
|
<enclosure url="https://example.com/ep1.mp3" type="audio/mpeg" length="12345678"/>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||||
|
<channel>
|
||||||
|
<title>Feed with Content</title>
|
||||||
|
<item>
|
||||||
|
<title>Item with Content</title>
|
||||||
|
<description>Short description</description>
|
||||||
|
<content:encoded><![CDATA[<p>Full content here</p>]]></content:encoded>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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("<p>Full content here</p>", feed.items[0].content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseRSSWithCategories() {
|
||||||
|
val xml = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Categorized Feed</title>
|
||||||
|
<item>
|
||||||
|
<title>Tech Article</title>
|
||||||
|
<category>Technology</category>
|
||||||
|
<category>Programming</category>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Author Feed</title>
|
||||||
|
<item>
|
||||||
|
<title>Article by Author</title>
|
||||||
|
<author>author@example.com (John Doe)</author>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Guid Feed</title>
|
||||||
|
<item>
|
||||||
|
<title>Item</title>
|
||||||
|
<guid>custom-guid-12345</guid>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Minimal Feed</title>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<item>
|
||||||
|
<title>Only Title</title>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".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 = """
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title><![CDATA[CDATA Title]]></title>
|
||||||
|
<description><![CDATA[<p>HTML <strong>content</strong></p>]]></description>
|
||||||
|
<item>
|
||||||
|
<title>CDATA Item</title>
|
||||||
|
<description><![CDATA[Item content]]></description>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val feed = RSSParser.parse(xml, "https://example.com/feed.xml")
|
||||||
|
|
||||||
|
assertNotNull(feed)
|
||||||
|
assertEquals("CDATA Title", feed.title)
|
||||||
|
assertEquals("<p>HTML <strong>content</strong></p>", feed.description)
|
||||||
|
assertEquals("Item content", feed.items[0].description)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user