Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add rss 1.0 support #682

Merged
merged 2 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.delete
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -162,6 +164,22 @@ private fun presenter(id: Int?) =
}
}
}
state.defaultTitle.onSuccess {
DisposableEffect(it) {
if (titleText.text.isEmpty()) {
titleText.edit {
append(it)
}
}
onDispose {
if (titleText.text == it) {
titleText.edit {
delete(0, it.length)
}
}
}
}
}
LaunchedEffect(urlText.text) {
state.setUrl(urlText.text.toString())
}
Expand Down
3 changes: 2 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ ktorfit-converters-flow = { group = "de.jensklingenberg.ktorfit", name = "ktorfi

ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-serialization-kotlinx-xml = { group = "io.ktor", name = "ktor-serialization-kotlinx-xml", version.ref = "ktor" }
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
Expand Down Expand Up @@ -177,7 +178,7 @@ compose = ["ui", "ui-util", "ui-graphics", "ui-tooling", "ui-tooling-preview", "
navigation = ["navigation-compose"]
kotlinx = ["kotlinx-datetime", "kotlinx-immutable", "kotlinx-serialization-json", "kotlinx-coroutines-core"]
koin = ["koin-core", "koin-android", "koin-compose", "koin-androidx-compose"]
ktor = ["ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-client-logging"]
ktor = ["ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-client-logging", "ktor-serialization-kotlinx-xml"]
coil3 = ["coil3-compose", "coil3-svg", "coil3-ktor3"]
coil3-extensions = ["apng", "awebp", "vectordrawable-animated", "zoomable-image", "coil3-gif"]
accompanist = ["accompanist-permissions", "accompanist-drawablepainter"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@ package dev.dimension.flare.data.network.rss

import dev.dimension.flare.data.network.ktorClient
import dev.dimension.flare.data.network.rss.model.Feed
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.xml.xml
import nl.adaptivity.xmlutil.serialization.XML

internal object RssService {
private val xml by lazy {
XML {
defaultPolicy {
autoPolymorphic = true
ignoreNamespaces()
ignoreUnknownChildren()
}
defaultToGenericParser = true
}
}

suspend fun fetch(url: String): Feed = xml.decodeFromString(Feed.serializer(), ktorClient().get(url).bodyAsText())
suspend fun fetch(url: String): Feed =
ktorClient(config = {
install(ContentNegotiation) {
xml(xml, contentType = ContentType.Any)
}
}).get(url).body()
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import nl.adaptivity.xmlutil.serialization.XmlValue
@Serializable
internal sealed interface Feed {
@Serializable
@SerialName("feed")
@XmlSerialName("feed")
data class Atom(
@XmlSerialName("id")
@XmlElement(true)
Expand Down Expand Up @@ -243,14 +243,14 @@ internal sealed interface Feed {
}

@Serializable
@SerialName("rss")
@XmlSerialName("rss")
data class Rss20(
val version: String,
@XmlElement(true)
val channel: Channel,
) : Feed {
@Serializable
@SerialName("channel")
@XmlSerialName("channel")
data class Channel(
@XmlElement(true)
val title: String,
Expand Down Expand Up @@ -371,7 +371,7 @@ internal sealed interface Feed {
)

@Serializable
@SerialName("guid")
@XmlSerialName("guid")
data class Guid(
@XmlElement(false)
val isPermaLink: Boolean = false,
Expand All @@ -387,4 +387,40 @@ internal sealed interface Feed {
val title: String? = null,
)
}

@Serializable
@XmlSerialName("RDF", namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#", prefix = "rdf")
data class RDF(
@XmlElement(true)
val channel: Channel,
@XmlSerialName("item", namespace = "http://purl.org/rss/1.0/", prefix = "")
val items: List<Item> = emptyList(),
) : Feed {
@Serializable
@XmlSerialName("channel", namespace = "http://purl.org/rss/1.0/", prefix = "")
data class Channel(
@XmlElement(true)
val title: String,
@XmlElement(true)
val link: String,
@XmlElement(true)
val description: String,
@XmlElement(true)
@XmlSerialName(value = "date", namespace = "http://purl.org/dc/elements/1.1/", prefix = "dc")
val date: String? = null,
)

@Serializable
data class Item(
@XmlElement(true)
val title: String,
@XmlElement(true)
val link: String,
@XmlElement(true)
val description: String,
@XmlElement(true)
@XmlSerialName(value = "date", namespace = "http://purl.org/dc/elements/1.1/", prefix = "dc")
val date: String? = null,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,17 @@ internal fun Feed.render(): List<UiTimeline> =
when (this) {
is Feed.Atom -> renderAtom()
is Feed.Rss20 -> renderRss20()
is Feed.RDF -> renderRdf()
}

internal val Feed.title: String
get() =
when (this) {
is Feed.Atom -> this.title.value
is Feed.Rss20 -> this.channel.title
is Feed.RDF -> this.channel.title
}

private fun Feed.Atom.renderAtom(): List<UiTimeline> =
this.entries.map {
val descHtml =
Expand Down Expand Up @@ -51,3 +60,23 @@ private fun Feed.Rss20.renderRss20(): List<UiTimeline> =
),
)
}

private fun Feed.RDF.renderRdf(): List<UiTimeline> =
this.items.map {
val descHtml =
it.description.let {
Ksoup.parse(it)
}
val img = descHtml.select("img").firstOrNull()
UiTimeline(
topMessage = null,
content =
UiTimeline.ItemContent.Feed(
title = it.title,
description = descHtml.text(),
url = it.link,
image = img?.attr("src"),
createdAt = it.date?.let { input -> runCatching { Instant.parse(input) }.getOrNull() }?.toUi(),
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import dev.dimension.flare.data.network.rss.RssService
import dev.dimension.flare.ui.model.UiState
import dev.dimension.flare.ui.model.collectAsUiState
import dev.dimension.flare.ui.model.flatMap
import dev.dimension.flare.ui.model.map
import dev.dimension.flare.ui.model.mapper.title
import dev.dimension.flare.ui.presenter.PresenterBase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
Expand All @@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.flow
public class CheckRssSourcePresenter : PresenterBase<CheckRssSourcePresenter.State>() {
public interface State {
public val isValid: UiState<Boolean>
public val defaultTitle: UiState<String>

public fun setUrl(value: String)
}
Expand All @@ -28,7 +31,7 @@ public class CheckRssSourcePresenter : PresenterBase<CheckRssSourcePresenter.Sta
@Composable
override fun body(): State {
var url by remember { mutableStateOf("") }
val isValid =
val feedData =
remember {
snapshotFlow { url }
.debounce(500)
Expand All @@ -38,16 +41,31 @@ public class CheckRssSourcePresenter : PresenterBase<CheckRssSourcePresenter.Sta
emit(UiState.Loading())
RssService.fetch(it)
}.onSuccess {
emit(UiState.Success(true))
emit(UiState.Success(it))
}.onFailure {
emit(UiState.Success(false))
emit(UiState.Error(it))
}
}
}
}.collectAsUiState().value.flatMap { it }

val isValid =
remember(feedData) {
feedData.map {
true
}
}

val defaultTitle =
remember(feedData) {
feedData.map {
it.title
}
}

return object : State {
override val isValid = isValid
override val defaultTitle = defaultTitle

override fun setUrl(value: String) {
url = value
Expand Down
Loading