From 9a25c1407510b5801839a51be45df0d80a1f939c Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Mon, 8 Jan 2024 08:47:15 +0530 Subject: [PATCH] Use ktxml for parsing feed XML (#212) * Add ktxml dependency * Add common RSS and Atom content parsers * Add common feed parser For the time being the feed parser interface is converted to open class, until the platform specific feed parsers are removed. After that we can change this to normal class * Stop providing platform specific feed parsers * Remove platform specific feed parsers * Remove open modifier for `FeedParser` * Convert content parsers to Kotlin objects * Rename `xmlContent` param name to `feedContent` --- core/network/build.gradle.kts | 1 + .../core/network/di/NetworkComponent.kt | 5 - .../core/network/parser/AndroidFeedParser.kt | 56 ----- .../core/network/fetcher/FeedFetcher.kt | 2 +- .../core/network/parser/AtomContentParser.kt} | 42 ++-- .../core/network/parser/ContentParser.kt} | 28 +-- .../reader/core/network/parser/FeedParser.kt | 43 +++- .../core/network/parser/RssContentParser.kt} | 38 ++-- .../core/network/di/NetworkComponent.kt | 5 - .../reader/core/network/parser/FeedType.kt | 21 -- .../core/network/parser/IOSAtomMapper.kt | 92 -------- .../core/network/parser/IOSFeedParser.kt | 213 ------------------ .../core/network/parser/IOSRssMapper.kt | 100 -------- gradle/libs.versions.toml | 2 + .../reader/network/AndroidFeedParserTest.kt | 164 -------------- .../sasikanth/rss/reader/FeedParserTest.kt} | 12 +- .../dev/sasikanth/rss/reader/TestData.kt | 2 + 17 files changed, 110 insertions(+), 716 deletions(-) delete mode 100644 core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidFeedParser.kt rename core/network/src/{androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidAtomParser.kt => commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt} (81%) rename core/network/src/{androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/Parser.kt => commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/ContentParser.kt} (64%) rename core/network/src/{androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidRssParser.kt => commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt} (83%) delete mode 100644 core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedType.kt delete mode 100644 core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSAtomMapper.kt delete mode 100644 core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSFeedParser.kt delete mode 100644 core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSRssMapper.kt delete mode 100644 shared/src/androidInstrumentedTest/kotlin/dev/sasikanth/rss/reader/network/AndroidFeedParserTest.kt rename shared/src/{iosTest/kotlin/dev/sasikanth/rss/reader/network/IOSFeedParserTest.kt => commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt} (93%) diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index c92fb37d3..f89bc0f95 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -43,6 +43,7 @@ kotlin { // TODO: Extract logging abstraction into separate module implementation(libs.napier) implementation(libs.sentry) + implementation(libs.ktxml) } commonTest.dependencies { implementation(libs.kotlin.test) } diff --git a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt b/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt index 96010485a..fd10a7fb2 100644 --- a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt +++ b/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt @@ -16,8 +16,6 @@ package dev.sasikanth.rss.reader.core.network.di -import dev.sasikanth.rss.reader.core.network.parser.AndroidFeedParser -import dev.sasikanth.rss.reader.core.network.parser.FeedParser import dev.sasikanth.rss.reader.di.scopes.AppScope import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp @@ -25,9 +23,6 @@ import me.tatarka.inject.annotations.Provides actual interface NetworkComponent { - val AndroidFeedParser.bind: FeedParser - @Provides @AppScope get() = this - @Provides @AppScope fun providesHttpClient(): HttpClient { diff --git a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidFeedParser.kt b/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidFeedParser.kt deleted file mode 100644 index 4cd080773..000000000 --- a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidFeedParser.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * 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 - * - * http://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. - */ - -package dev.sasikanth.rss.reader.core.network.parser - -import android.util.Xml -import dev.sasikanth.rss.reader.core.model.remote.FeedPayload -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATOM_TAG -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.HTML_TAG -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.RSS_TAG -import dev.sasikanth.rss.reader.exceptions.XmlParsingError -import dev.sasikanth.rss.reader.util.DispatchersProvider -import kotlinx.coroutines.withContext -import me.tatarka.inject.annotations.Inject -import org.xmlpull.v1.XmlPullParser -import org.xmlpull.v1.XmlPullParserException - -@Inject -class AndroidFeedParser(private val dispatchersProvider: DispatchersProvider) : FeedParser { - - override suspend fun parse(xmlContent: String, feedUrl: String): FeedPayload { - return try { - withContext(dispatchersProvider.io) { - val parser = - Xml.newPullParser().apply { setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) } - - return@withContext xmlContent.reader().use { reader -> - parser.setInput(reader) - parser.nextTag() - - when (parser.name) { - RSS_TAG -> AndroidRssParser(parser, feedUrl).parse() - ATOM_TAG -> AndroidAtomParser(parser, feedUrl).parse() - HTML_TAG -> throw HtmlContentException() - else -> throw UnsupportedOperationException("Unknown feed type: ${parser.name}") - } - } - } - } catch (e: XmlPullParserException) { - throw XmlParsingError(e.stackTraceToString()) - } - } -} diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt index 8ed52cb9d..9636ac998 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/fetcher/FeedFetcher.kt @@ -93,7 +93,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe throw UnsupportedOperationException() } } else { - val feedPayload = feedParser.parse(xmlContent = responseContent, feedUrl = url) + val feedPayload = feedParser.parse(feedContent = responseContent, feedUrl = url) FeedFetchResult.Success(feedPayload) } } diff --git a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidAtomParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt similarity index 81% rename from core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidAtomParser.kt rename to core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt index 464bfa2a9..b99130988 100644 --- a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidAtomParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt @@ -16,7 +16,6 @@ package dev.sasikanth.rss.reader.core.network.parser -import android.net.Uri import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlOptions import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser import dev.sasikanth.rss.reader.core.model.remote.FeedPayload @@ -32,14 +31,15 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUB import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_SUBTITLE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_UPDATED +import io.ktor.http.Url import kotlinx.datetime.Clock -import org.xmlpull.v1.XmlPullParser +import org.kobjects.ktxml.api.EventType +import org.kobjects.ktxml.api.XmlPullParser -internal class AndroidAtomParser(private val parser: XmlPullParser, private val feedUrl: String) : - Parser() { +internal object AtomContentParser : ContentParser() { - override fun parse(): FeedPayload { - parser.require(XmlPullParser.START_TAG, namespace, TAG_ATOM_FEED) + override fun parse(feedUrl: String, parser: XmlPullParser): FeedPayload { + parser.require(EventType.START_TAG, parser.namespace, TAG_ATOM_FEED) val posts = mutableListOf() @@ -47,8 +47,8 @@ internal class AndroidAtomParser(private val parser: XmlPullParser, private val var description: String? = null var link: String? = null - while (parser.next() != XmlPullParser.END_TAG) { - if (parser.eventType != XmlPullParser.START_TAG) continue + while (parser.next() != EventType.END_TAG) { + if (parser.eventType != EventType.START_TAG) continue when (val name = parser.name) { TAG_TITLE -> { title = readTagText(name, parser) @@ -70,21 +70,27 @@ internal class AndroidAtomParser(private val parser: XmlPullParser, private val } } - val domain = Uri.parse(link).host!! - val iconUrl = FeedParser.feedIcon(domain) + val domain = Url(link!!) + val host = + if (domain.host != "localhost") { + domain.host + } else { + throw NullPointerException("Unable to get host domain") + } + val iconUrl = FeedParser.feedIcon(host) return FeedPayload( name = FeedParser.cleanText(title ?: link, decodeUrlEncoding = true)!!, description = FeedParser.cleanText(description, decodeUrlEncoding = true).orEmpty(), icon = iconUrl, - homepageLink = link!!, + homepageLink = link, link = feedUrl, posts = posts.filterNotNull() ) } private fun readAtomEntry(parser: XmlPullParser, hostLink: String): PostPayload? { - parser.require(XmlPullParser.START_TAG, null, "entry") + parser.require(EventType.START_TAG, null, "entry") var title: String? = null var link: String? = null @@ -92,8 +98,8 @@ internal class AndroidAtomParser(private val parser: XmlPullParser, private val var date: String? = null var image: String? = null - while (parser.next() != XmlPullParser.END_TAG) { - if (parser.eventType != XmlPullParser.START_TAG) continue + while (parser.next() != EventType.END_TAG) { + if (parser.eventType != EventType.START_TAG) continue when (val tagName = parser.name) { TAG_TITLE -> { @@ -148,13 +154,13 @@ internal class AndroidAtomParser(private val parser: XmlPullParser, private val private fun readAtomLink(tagName: String, parser: XmlPullParser): String? { var link: String? = null - parser.require(XmlPullParser.START_TAG, namespace, tagName) - val relType = parser.getAttributeValue(namespace, ATTR_REL) + parser.require(EventType.START_TAG, parser.namespace, tagName) + val relType = parser.getAttributeValue(parser.namespace, ATTR_REL) if (relType == ATTR_VALUE_ALTERNATE || relType.isNullOrBlank()) { - link = parser.getAttributeValue(namespace, ATTR_HREF) + link = parser.getAttributeValue(parser.namespace, ATTR_HREF) } parser.nextTag() - parser.require(XmlPullParser.END_TAG, namespace, tagName) + parser.require(EventType.END_TAG, parser.namespace, tagName) return link } } diff --git a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/Parser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/ContentParser.kt similarity index 64% rename from core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/Parser.kt rename to core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/ContentParser.kt index 9cbc26b5d..0abfe05a7 100644 --- a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/Parser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/ContentParser.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sasikanth Miriyampalli + * Copyright 2024 Sasikanth Miriyampalli * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,30 +17,29 @@ package dev.sasikanth.rss.reader.core.network.parser import dev.sasikanth.rss.reader.core.model.remote.FeedPayload -import org.xmlpull.v1.XmlPullParser +import org.kobjects.ktxml.api.EventType +import org.kobjects.ktxml.api.XmlPullParser -abstract class Parser { +abstract class ContentParser { - val namespace: String? = null - - abstract fun parse(): FeedPayload + abstract fun parse(feedUrl: String, parser: XmlPullParser): FeedPayload fun readAttrText(attrName: String, parser: XmlPullParser): String? { - val url = parser.getAttributeValue(namespace, attrName) + val url = parser.getAttributeValue(parser.namespace, attrName) skip(parser) return url } fun readTagText(tagName: String, parser: XmlPullParser): String { - parser.require(XmlPullParser.START_TAG, namespace, tagName) + parser.require(EventType.START_TAG, parser.namespace, tagName) val title = readText(parser) - parser.require(XmlPullParser.END_TAG, namespace, tagName) + parser.require(EventType.END_TAG, parser.namespace, tagName) return title } private fun readText(parser: XmlPullParser): String { var result = "" - if (parser.next() == XmlPullParser.TEXT) { + if (parser.next() == EventType.TEXT) { result = parser.text parser.nextTag() } @@ -48,12 +47,15 @@ abstract class Parser { } fun skip(parser: XmlPullParser) { - parser.require(XmlPullParser.START_TAG, namespace, null) + parser.require(EventType.START_TAG, parser.namespace, null) var depth = 1 while (depth != 0) { when (parser.next()) { - XmlPullParser.END_TAG -> depth-- - XmlPullParser.START_TAG -> depth++ + EventType.END_TAG -> depth-- + EventType.START_TAG -> depth++ + else -> { + // no-op + } } } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt index 3a0ada363..d78d54084 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt @@ -16,12 +16,49 @@ package dev.sasikanth.rss.reader.core.network.parser import dev.sasikanth.rss.reader.core.model.remote.FeedPayload +import dev.sasikanth.rss.reader.di.scopes.AppScope +import dev.sasikanth.rss.reader.exceptions.XmlParsingError +import dev.sasikanth.rss.reader.util.DispatchersProvider import dev.sasikanth.rss.reader.util.decodeUrlEncodedString +import io.github.aakira.napier.LogLevel +import io.github.aakira.napier.log import io.ktor.http.URLBuilder import io.ktor.http.URLProtocol import io.ktor.http.set - -interface FeedParser { +import kotlinx.coroutines.withContext +import me.tatarka.inject.annotations.Inject +import org.kobjects.ktxml.api.XmlPullParserException +import org.kobjects.ktxml.mini.MiniXmlPullParser + +@Inject +@AppScope +class FeedParser(private val dispatchersProvider: DispatchersProvider) { + + suspend fun parse(feedContent: String, feedUrl: String): FeedPayload { + return try { + withContext(dispatchersProvider.io) { + // Currently MiniXmlPullParser fails to parse XML if it contains + // the tag in the first line. So we are removing it until + // the issue gets resolved. + // https://github.com/kobjects/ktxml/issues/5 + val xmlDeclarationPattern = Regex("<\\?xml .*\\?>") + val parser = + MiniXmlPullParser(source = xmlDeclarationPattern.replaceFirst(feedContent, "").iterator()) + + parser.nextTag() + + return@withContext when (parser.name) { + RSS_TAG -> RssContentParser.parse(feedUrl, parser) + ATOM_TAG -> AtomContentParser.parse(feedUrl, parser) + HTML_TAG -> throw HtmlContentException() + else -> throw UnsupportedOperationException("Unknown feed type: ${parser.name}") + } + } + } catch (e: XmlPullParserException) { + log(LogLevel.ERROR, throwable = e) { "Failed to parse the XML" } + throw XmlParsingError(e.stackTraceToString()) + } + } companion object { const val RSS_TAG = "rss" @@ -103,8 +140,6 @@ interface FeedParser { return pattern.containsMatchIn(url) } } - - suspend fun parse(xmlContent: String, feedUrl: String): FeedPayload } internal class HtmlContentException : Exception() diff --git a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidRssParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt similarity index 83% rename from core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidRssParser.kt rename to core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt index 21b7af326..a709bfdf0 100644 --- a/core/network/src/androidMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AndroidRssParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sasikanth Miriyampalli + * Copyright 2024 Sasikanth Miriyampalli * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,6 @@ package dev.sasikanth.rss.reader.core.network.parser -import android.net.Uri import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlOptions import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser import dev.sasikanth.rss.reader.core.model.remote.FeedPayload @@ -34,15 +33,16 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUB import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_CHANNEL import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_ITEM import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE +import io.ktor.http.Url import kotlinx.datetime.Clock -import org.xmlpull.v1.XmlPullParser +import org.kobjects.ktxml.api.EventType +import org.kobjects.ktxml.api.XmlPullParser -internal class AndroidRssParser(private val parser: XmlPullParser, private val feedUrl: String) : - Parser() { +internal object RssContentParser : ContentParser() { - override fun parse(): FeedPayload { + override fun parse(feedUrl: String, parser: XmlPullParser): FeedPayload { parser.nextTag() - parser.require(XmlPullParser.START_TAG, namespace, TAG_RSS_CHANNEL) + parser.require(EventType.START_TAG, parser.namespace, TAG_RSS_CHANNEL) val posts = mutableListOf() @@ -50,8 +50,8 @@ internal class AndroidRssParser(private val parser: XmlPullParser, private val f var link: String? = null var description: String? = null - while (parser.next() != XmlPullParser.END_TAG) { - if (parser.eventType != XmlPullParser.START_TAG) continue + while (parser.next() != EventType.END_TAG) { + if (parser.eventType != EventType.START_TAG) continue when (val name = parser.name) { TAG_TITLE -> { title = readTagText(name, parser) @@ -69,8 +69,14 @@ internal class AndroidRssParser(private val parser: XmlPullParser, private val f } } - val domain = Uri.parse(link!!) - val iconUrl = FeedParser.feedIcon(domain.host ?: domain.path!!) + val domain = Url(link!!) + val host = + if (domain.host != "localhost") { + domain.host + } else { + throw NullPointerException("Unable to get host domain") + } + val iconUrl = FeedParser.feedIcon(host) return FeedPayload( name = FeedParser.cleanText(title ?: link, decodeUrlEncoding = true)!!, @@ -83,7 +89,7 @@ internal class AndroidRssParser(private val parser: XmlPullParser, private val f } private fun readRssItem(parser: XmlPullParser, hostLink: String): PostPayload? { - parser.require(XmlPullParser.START_TAG, namespace, TAG_RSS_ITEM) + parser.require(EventType.START_TAG, parser.namespace, TAG_RSS_ITEM) var title: String? = null var link: String? = null @@ -92,8 +98,8 @@ internal class AndroidRssParser(private val parser: XmlPullParser, private val f var image: String? = null var commentsLink: String? = null - while (parser.next() != XmlPullParser.END_TAG) { - if (parser.eventType != XmlPullParser.START_TAG) continue + while (parser.next() != EventType.END_TAG) { + if (parser.eventType != EventType.START_TAG) continue val name = parser.name when { @@ -154,6 +160,6 @@ internal class AndroidRssParser(private val parser: XmlPullParser, private val f private fun hasRssImageUrl(name: String, parser: XmlPullParser) = (FeedParser.imageTags.contains(name) || (name == TAG_ENCLOSURE && - parser.getAttributeValue(namespace, ATTR_TYPE) == ATTR_VALUE_IMAGE)) && - !parser.getAttributeValue(namespace, ATTR_URL).isNullOrBlank() + parser.getAttributeValue(parser.namespace, ATTR_TYPE) == ATTR_VALUE_IMAGE)) && + !parser.getAttributeValue(parser.namespace, ATTR_URL).isNullOrBlank() } diff --git a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt b/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt index b393811c8..537c03b1b 100644 --- a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt +++ b/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/di/NetworkComponent.kt @@ -16,8 +16,6 @@ package dev.sasikanth.rss.reader.core.network.di -import dev.sasikanth.rss.reader.core.network.parser.FeedParser -import dev.sasikanth.rss.reader.core.network.parser.IOSFeedParser import dev.sasikanth.rss.reader.di.scopes.AppScope import io.ktor.client.HttpClient import io.ktor.client.engine.darwin.Darwin @@ -25,9 +23,6 @@ import me.tatarka.inject.annotations.Provides actual interface NetworkComponent { - val IOSFeedParser.bind: FeedParser - @Provides @AppScope get() = this - @Provides @AppScope fun providesHttpClient(): HttpClient { diff --git a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedType.kt b/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedType.kt deleted file mode 100644 index a3dc5b99e..000000000 --- a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedType.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * 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 - * - * http://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. - */ -package dev.sasikanth.rss.reader.core.network.parser - -enum class FeedType { - RSS, - ATOM, -} diff --git a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSAtomMapper.kt b/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSAtomMapper.kt deleted file mode 100644 index f28f3ca45..000000000 --- a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSAtomMapper.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * 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 - * - * http://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. - */ -package dev.sasikanth.rss.reader.core.network.parser - -import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlOptions -import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser -import dev.sasikanth.rss.reader.core.model.remote.FeedPayload -import dev.sasikanth.rss.reader.core.model.remote.PostPayload -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_CONTENT -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_IMAGE_URL -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUBLISHED -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_SUBTITLE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_UPDATED -import io.ktor.http.Url -import kotlinx.datetime.Clock - -internal fun PostPayload.Companion.mapAtomPost( - atomMap: Map, - hostLink: String -): PostPayload? { - val title = atomMap[TAG_TITLE] - val pubDate = atomMap[TAG_PUBLISHED] ?: atomMap[TAG_UPDATED] - val link = atomMap[TAG_LINK]?.trim() - val data = atomMap[TAG_CONTENT] - var imageUrl = atomMap[TAG_IMAGE_URL] - var content: String? = null - - KsoupHtmlParser( - handler = - HtmlContentParser { - if (imageUrl.isNullOrBlank()) imageUrl = it.imageUrl - content = it.content.ifBlank { data?.trim() } - }, - options = KsoupHtmlOptions(decodeEntities = false) - ) - .parseComplete(data.orEmpty()) - - if (title.isNullOrBlank() && content.isNullOrBlank()) { - return null - } - - val postPubDateInMillis = pubDate?.let { dateString -> dateString.dateStringToEpochMillis() } - - return PostPayload( - title = FeedParser.cleanText(title, decodeUrlEncoding = true).orEmpty(), - description = FeedParser.cleanTextCompact(content, decodeUrlEncoding = true).orEmpty(), - link = FeedParser.cleanText(link)!!, - imageUrl = FeedParser.safeUrl(hostLink, imageUrl), - date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), - commentsLink = null - ) -} - -internal fun FeedPayload.Companion.mapAtomFeed( - feedUrl: String, - atomMap: Map, - posts: List -): FeedPayload { - val link = atomMap[TAG_LINK]!!.trim() - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } - val iconUrl = FeedParser.feedIcon(host) - - return FeedPayload( - name = FeedParser.cleanText(atomMap[TAG_TITLE] ?: link, decodeUrlEncoding = true)!!, - description = FeedParser.cleanText(atomMap[TAG_SUBTITLE], decodeUrlEncoding = true).orEmpty(), - homepageLink = link, - link = feedUrl, - icon = iconUrl, - posts = posts - ) -} diff --git a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSFeedParser.kt b/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSFeedParser.kt deleted file mode 100644 index 369192c6d..000000000 --- a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSFeedParser.kt +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * 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 - * - * http://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. - */ -@file:Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE") - -package dev.sasikanth.rss.reader.core.network.parser - -import dev.sasikanth.rss.reader.core.model.remote.FeedPayload -import dev.sasikanth.rss.reader.core.model.remote.PostPayload -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATOM_TAG -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_HREF -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_REL -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_TYPE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_URL -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_VALUE_ALTERNATE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.HTML_TAG -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.RSS_TAG -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ATOM_ENTRY -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ATOM_FEED -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ENCLOSURE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_FEATURED_IMAGE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_FEED_IMAGE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_IMAGE_URL -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_CHANNEL -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_ITEM -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.imageTags -import dev.sasikanth.rss.reader.core.network.parser.FeedType.ATOM -import dev.sasikanth.rss.reader.core.network.parser.FeedType.RSS -import dev.sasikanth.rss.reader.exceptions.XmlParsingError -import dev.sasikanth.rss.reader.util.DispatchersProvider -import kotlin.collections.set -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.withContext -import me.tatarka.inject.annotations.Inject -import platform.Foundation.NSString -import platform.Foundation.NSUTF8StringEncoding -import platform.Foundation.NSXMLParser -import platform.Foundation.NSXMLParserDelegateProtocol -import platform.Foundation.dataUsingEncoding -import platform.darwin.NSObject - -@Inject -@Suppress("CAST_NEVER_SUCCEEDS") -class IOSFeedParser(private val dispatchersProvider: DispatchersProvider) : FeedParser { - - override suspend fun parse(xmlContent: String, feedUrl: String): FeedPayload { - return withContext(dispatchersProvider.io) { - suspendCoroutine { continuation -> - var feedPayload: FeedPayload? = null - val data = (xmlContent as NSString).dataUsingEncoding(NSUTF8StringEncoding)!! - val xmlFeedParser = - IOSXmlFeedParser(feedUrl) { parsedFeedPayload -> feedPayload = parsedFeedPayload } - - val parser = NSXMLParser(data).apply { delegate = xmlFeedParser } - val parserResult = parser.parse() - - val nullableFeedPayload = feedPayload - if (parserResult && nullableFeedPayload != null) { - continuation.resume(nullableFeedPayload) - } else if (!parserResult && parser.parserError() != null) { - continuation.resumeWithException(XmlParsingError(parser.parserError()?.description)) - } - } - } - } -} - -private class IOSXmlFeedParser( - private val feedUrl: String, - private val onEnd: (FeedPayload) -> Unit -) : NSObject(), NSXMLParserDelegateProtocol { - - private val posts = mutableListOf() - - private var feedType: FeedType? = null - private var currentChannelData: MutableMap = mutableMapOf() - private var currentItemData: MutableMap = mutableMapOf() - private var currentData: MutableMap? = null - private var currentElement: String? = null - - private val skippedTagsStack = mutableListOf() - - override fun parser(parser: NSXMLParser, foundCharacters: String) { - if (skippedTagsStack.isNotEmpty()) return - - val currentElement = currentElement ?: return - val currentData = currentData ?: return - - when { - !currentData.containsKey(TAG_IMAGE_URL) && currentElement == TAG_FEATURED_IMAGE -> { - currentData[TAG_IMAGE_URL] = foundCharacters - } - else -> { - currentData[currentElement] = (currentData[currentElement] ?: "") + foundCharacters - } - } - } - - override fun parser( - parser: NSXMLParser, - didStartElement: String, - namespaceURI: String?, - qualifiedName: String?, - attributes: Map - ) { - if (didStartElement == TAG_FEED_IMAGE) { - skippedTagsStack.add(TAG_FEATURED_IMAGE) - return - } - - if (feedType == null) { - feedType = - when (didStartElement) { - RSS_TAG -> RSS - ATOM_TAG -> ATOM - HTML_TAG -> throw HtmlContentException() - else -> throw UnsupportedOperationException("Unknown feed type: $didStartElement") - } - } - - currentElement = didStartElement - - when { - !currentItemData.containsKey(TAG_IMAGE_URL) && hasRssImageUrl(attributes) -> { - currentItemData[TAG_IMAGE_URL] = attributes[ATTR_URL] as String - } - hasPodcastRssUrl() -> { - currentItemData[TAG_LINK] = attributes[ATTR_URL] as String - } - hasAtomLink(attributes) -> { - if (currentChannelData[TAG_LINK].isNullOrBlank()) { - currentChannelData[TAG_LINK] = attributes[ATTR_HREF] as String - } - currentItemData[TAG_LINK] = attributes[ATTR_HREF] as String - } - } - - currentData = - when (currentElement) { - TAG_RSS_CHANNEL, - TAG_ATOM_FEED -> currentChannelData - TAG_RSS_ITEM, - TAG_ATOM_ENTRY -> currentItemData - else -> currentData - } - } - - override fun parser( - parser: NSXMLParser, - didEndElement: String, - namespaceURI: String?, - qualifiedName: String? - ) { - if (didEndElement == TAG_FEED_IMAGE) { - skippedTagsStack.remove(TAG_FEATURED_IMAGE) - } - - if (didEndElement == TAG_RSS_ITEM || didEndElement == TAG_ATOM_ENTRY) { - val hostLink = currentChannelData[TAG_LINK]!! - val post = - when (feedType) { - RSS -> PostPayload.mapRssPost(currentItemData, hostLink) - ATOM -> PostPayload.mapAtomPost(currentItemData, hostLink) - null -> null - } - - post?.let { posts.add(it) } - currentItemData.clear() - } - } - - override fun parserDidEndDocument(parser: NSXMLParser) { - val posts = posts.filterNotNull() - val payload = - when (feedType) { - RSS -> FeedPayload.mapRssFeed(feedUrl, currentChannelData, posts) - ATOM -> FeedPayload.mapAtomFeed(feedUrl, currentChannelData, posts) - null -> null - } - - if (payload != null) { - onEnd(payload) - } - } - - private fun hasPodcastRssUrl() = - currentElement == TAG_ENCLOSURE && currentItemData[TAG_LINK].isNullOrBlank() - - private fun hasRssImageUrl(attributes: Map) = - (imageTags.contains(currentElement) || - (currentElement == TAG_ENCLOSURE && attributes[ATTR_TYPE] == "image/jpeg")) && - attributes.containsKey(ATTR_URL) - - private fun hasAtomLink(attributes: Map) = - feedType == ATOM && - currentElement == TAG_LINK && - (attributes[ATTR_REL] == ATTR_VALUE_ALTERNATE || attributes[ATTR_REL] == null) -} diff --git a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSRssMapper.kt b/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSRssMapper.kt deleted file mode 100644 index 16b75b886..000000000 --- a/core/network/src/iosMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/IOSRssMapper.kt +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * 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 - * - * http://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. - */ -package dev.sasikanth.rss.reader.core.network.parser - -import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlOptions -import com.mohamedrejeb.ksoup.html.parser.KsoupHtmlParser -import dev.sasikanth.rss.reader.core.model.remote.FeedPayload -import dev.sasikanth.rss.reader.core.model.remote.PostPayload -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_COMMENTS -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_CONTENT_ENCODED -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_DESCRIPTION -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_IMAGE_URL -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUB_DATE -import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE -import io.ktor.http.Url -import kotlinx.datetime.Clock - -internal fun PostPayload.Companion.mapRssPost( - rssMap: Map, - hostLink: String -): PostPayload? { - val title = rssMap[TAG_TITLE] - val pubDate = rssMap[TAG_PUB_DATE] - val link = rssMap[TAG_LINK] - var description = rssMap[TAG_DESCRIPTION] - val encodedContent = rssMap[TAG_CONTENT_ENCODED] - var imageUrl: String? = rssMap[TAG_IMAGE_URL] - val commentsLink: String? = rssMap[TAG_COMMENTS] - - val descriptionToParse = - if (encodedContent.isNullOrBlank()) { - description - } else { - encodedContent - } - - KsoupHtmlParser( - handler = - HtmlContentParser { - if (imageUrl.isNullOrBlank()) imageUrl = it.imageUrl - description = it.content.ifBlank { descriptionToParse?.trim() } - }, - options = KsoupHtmlOptions(decodeEntities = false) - ) - .parseComplete(descriptionToParse.orEmpty()) - - if (title.isNullOrBlank() && description.isNullOrBlank()) { - return null - } - - val postPubDateInMillis = pubDate?.let { dateString -> dateString.dateStringToEpochMillis() } - - return PostPayload( - title = FeedParser.cleanText(title, decodeUrlEncoding = true).orEmpty(), - description = FeedParser.cleanTextCompact(description, decodeUrlEncoding = true).orEmpty(), - link = FeedParser.cleanText(link)!!, - imageUrl = FeedParser.safeUrl(hostLink, imageUrl), - date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), - commentsLink = commentsLink?.trim() - ) -} - -internal fun FeedPayload.Companion.mapRssFeed( - feedUrl: String, - rssMap: Map, - posts: List -): FeedPayload { - val link = rssMap[TAG_LINK]!!.trim() - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } - val iconUrl = FeedParser.feedIcon(host) - - return FeedPayload( - name = FeedParser.cleanText(rssMap[TAG_TITLE] ?: link, decodeUrlEncoding = true)!!, - description = FeedParser.cleanText(rssMap[TAG_DESCRIPTION], decodeUrlEncoding = true).orEmpty(), - homepageLink = link, - link = feedUrl, - icon = iconUrl, - posts = posts - ) -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3189e2462..9531df798 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ okio = "3.7.0" paging = "3.3.0-alpha02-0.4.0" stately = "2.0.6" xmlutil = "0.86.3" +ktxml = "0.2.3" [libraries] compose_runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } @@ -102,6 +103,7 @@ stately-isolate = { module = "co.touchlab:stately-isolate", version.ref = "state stately-iso-collections = { module = "co.touchlab:stately-iso-collections", version.ref = "stately" } xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "xmlutil" } xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } +ktxml = { module = "org.kobjects.ktxml:core", version.ref = "ktxml" } [plugins] android_application = { id = "com.android.application", version.ref = "android_gradle_plugin" } diff --git a/shared/src/androidInstrumentedTest/kotlin/dev/sasikanth/rss/reader/network/AndroidFeedParserTest.kt b/shared/src/androidInstrumentedTest/kotlin/dev/sasikanth/rss/reader/network/AndroidFeedParserTest.kt deleted file mode 100644 index c23d53309..000000000 --- a/shared/src/androidInstrumentedTest/kotlin/dev/sasikanth/rss/reader/network/AndroidFeedParserTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * 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 - * - * http://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. - */ -package dev.sasikanth.rss.reader.network - -import dev.sasikanth.rss.reader.TestDispatchersProvider -import dev.sasikanth.rss.reader.atomXmlContent -import dev.sasikanth.rss.reader.core.model.remote.FeedPayload -import dev.sasikanth.rss.reader.core.model.remote.PostPayload -import dev.sasikanth.rss.reader.core.network.parser.AndroidFeedParser -import dev.sasikanth.rss.reader.feedUrl -import dev.sasikanth.rss.reader.rssXmlContent -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlinx.coroutines.test.runTest - -class AndroidFeedParserTest { - - private val feedParser = AndroidFeedParser(dispatchersProvider = TestDispatchersProvider()) - - @Test - fun parsingRssFeedShouldWorkCorrectly() = runTest { - // given - val expectedFeedPayload = - FeedPayload( - name = "Feed title", - icon = "https://icon.horse/icon/example.com", - description = "Feed description", - link = feedUrl, - homepageLink = "https://example.com", - posts = - listOf( - PostPayload( - title = "Post with image", - link = "https://example.com/first-post", - description = "First post description.", - imageUrl = "https://example.com/first-post-media-url", - date = 1685005200000, - commentsLink = null - ), - PostPayload( - title = "Post without image", - link = "https://example.com/second-post", - description = "Second post description.", - imageUrl = null, - date = 1684999800000, - commentsLink = null - ), - PostPayload( - title = "Podcast post", - link = "https://example.com/third-post", - description = "Third post description.", - imageUrl = null, - date = 1684924200000, - commentsLink = null - ), - PostPayload( - title = "Post with enclosure image", - link = "https://example.com/fourth-post", - description = "Fourth post description.", - imageUrl = "https://example.com/enclosure-image", - date = 1684924200000, - commentsLink = null - ), - PostPayload( - title = "Post with description and encoded content", - link = "https://example.com/fifth-post", - description = "Fourth post description in HTML syntax.", - imageUrl = "https://example.com/encoded-image", - date = 1684924200000, - commentsLink = null - ), - PostPayload( - title = "Post with relative path image", - link = "https://example.com/post-with-relative-image", - description = "Relative image post description.", - imageUrl = "https://example.com/relative-media-url", - date = 1685005200000, - commentsLink = null - ), - PostPayload( - title = "Post with comments", - link = "https://example.com/post-with-comments", - description = "Really long post with comments.", - imageUrl = null, - date = 1685005200000, - commentsLink = "https://example/post-with-comments/comments" - ), - ) - ) - - // when - val payload = feedParser.parse(rssXmlContent, feedUrl) - - // then - assertEquals(expectedFeedPayload, payload) - } - - @Test - fun parsingAtomFeedShouldWorkCorrectly() = runTest { - // given - val expectedFeedPayload = - FeedPayload( - name = "Feed title", - icon = "https://icon.horse/icon/example.com", - description = "Feed description", - link = feedUrl, - homepageLink = "https://example.com", - posts = - listOf( - PostPayload( - title = "Post with image", - link = "https://example.com/first-post", - description = "Post summary with an image.", - imageUrl = "https://example.com/image.jpg", - date = 1685008800000, - commentsLink = null - ), - PostPayload( - title = "Second post", - link = "https://example.com/second-post", - description = "Post summary of the second post.", - imageUrl = null, - date = 1684917000000, - commentsLink = null - ), - PostPayload( - title = "Post without image", - link = "https://example.com/third-post", - description = "Post summary of the third post. click here.", - imageUrl = null, - date = 1684936800000, - commentsLink = null - ), - PostPayload( - title = "Post with relative image", - link = "https://example.com/relative-image-post", - description = "Post summary with an image.", - imageUrl = "https://example.com/resources/image.jpg", - date = 1685008800000, - commentsLink = null - ), - ) - ) - - // when - val payload = feedParser.parse(atomXmlContent, feedUrl) - - // then - assertEquals(expectedFeedPayload, payload) - } -} diff --git a/shared/src/iosTest/kotlin/dev/sasikanth/rss/reader/network/IOSFeedParserTest.kt b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt similarity index 93% rename from shared/src/iosTest/kotlin/dev/sasikanth/rss/reader/network/IOSFeedParserTest.kt rename to shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt index bc29da5c3..e5ff374a8 100644 --- a/shared/src/iosTest/kotlin/dev/sasikanth/rss/reader/network/IOSFeedParserTest.kt +++ b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt @@ -13,22 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.sasikanth.rss.reader.network +package dev.sasikanth.rss.reader -import dev.sasikanth.rss.reader.TestDispatchersProvider -import dev.sasikanth.rss.reader.atomXmlContent import dev.sasikanth.rss.reader.core.model.remote.FeedPayload import dev.sasikanth.rss.reader.core.model.remote.PostPayload -import dev.sasikanth.rss.reader.core.network.parser.IOSFeedParser -import dev.sasikanth.rss.reader.feedUrl -import dev.sasikanth.rss.reader.rssXmlContent +import dev.sasikanth.rss.reader.core.network.parser.FeedParser import kotlin.test.Test import kotlin.test.assertEquals import kotlinx.coroutines.test.runTest -class IOSFeedParserTest { +class FeedParserTest { - private val feedParser = IOSFeedParser(dispatchersProvider = TestDispatchersProvider()) + private val feedParser = FeedParser(dispatchersProvider = TestDispatchersProvider()) @Test fun parsingRssFeedShouldWorkCorrectly() = runTest { diff --git a/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/TestData.kt b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/TestData.kt index 73b00bb5d..db6f3c291 100644 --- a/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/TestData.kt +++ b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/TestData.kt @@ -18,6 +18,7 @@ package dev.sasikanth.rss.reader const val feedUrl = "https://example.com" const val rssXmlContent = """ + Feed title @@ -79,6 +80,7 @@ const val rssXmlContent = const val atomXmlContent = """ + Feed title Feed description