From 37e7aaa5d37aec1a580b7992bfc374352213030f Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sat, 22 Feb 2025 05:55:01 +0530 Subject: [PATCH 01/14] Refactor `observePosts` function in the `HomePresenter` (#803) --- .idea/studiobot.xml | 2 +- .../rss/reader/home/HomePresenter.kt | 114 ++++++++---------- 2 files changed, 51 insertions(+), 65 deletions(-) diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml index 9298202cb..539e3b805 100644 --- a/.idea/studiobot.xml +++ b/.idea/studiobot.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt index f5818c3f9..ac8bbf04a 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/HomePresenter.kt @@ -56,8 +56,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest @@ -218,17 +220,7 @@ class HomePresenter( private fun markPostsAsRead(source: Source?) { coroutineScope.launch { - val postsAfter = - when (_state.value.postsType) { - PostsType.ALL, - PostsType.UNREAD -> Instant.DISTANT_PAST - PostsType.TODAY -> { - getTodayStartInstant() - } - PostsType.LAST_24_HOURS -> { - getLast24HourStart() - } - } + val postsAfter = getPostsAfter(_state.value.postsType) when (source) { is Feed -> { @@ -290,17 +282,7 @@ class HomePresenter( Pair(activeSource, postsType) } .flatMapLatest { (activeSource, postsType) -> - val postsAfter = - when (postsType) { - PostsType.ALL, - PostsType.UNREAD -> Instant.DISTANT_PAST - PostsType.TODAY -> { - getTodayStartInstant() - } - PostsType.LAST_24_HOURS -> { - getLast24HourStart() - } - } + val postsAfter = getPostsAfter(postsType) rssRepository.hasUnreadPostsInSource( sourceId = activeSource?.id, @@ -326,36 +308,26 @@ class HomePresenter( } } .flatMapLatest { (activeSource, postsType) -> - val unreadOnly = - when (postsType) { - PostsType.ALL, - PostsType.TODAY, - PostsType.LAST_24_HOURS -> null - PostsType.UNREAD -> true - } - - val postsAfter = - when (postsType) { - PostsType.ALL, - PostsType.UNREAD -> Instant.DISTANT_PAST - PostsType.TODAY -> { - getTodayStartInstant() - } - PostsType.LAST_24_HOURS -> { - getLast24HourStart() - } - } + val unreadOnly = getUnreadOnly(postsType) + val postsAfter = getPostsAfter(postsType) loadFeaturedPostsItems( activeSource = activeSource, unreadOnly = unreadOnly, postsAfter = postsAfter ) - .onEach { featuredPosts -> + .transformLatest { featuredPosts -> _state.update { it.copy(featuredPosts = featuredPosts.toImmutableList()) } + + emit(NTuple4(activeSource, postsAfter, unreadOnly, featuredPosts)) + + val postsWithSeedColors = calculateSeedColors(featuredPosts) + + _state.update { it.copy(featuredPosts = postsWithSeedColors) } + } + .distinctUntilChangedBy { (_, _, _, featuredPosts) -> + featuredPosts.map { it.postWithMetadata.id } } - .distinctUntilChangedBy { it.map { featuredPost -> featuredPost.postWithMetadata.id } } - .map { featuredPosts -> NTuple4(activeSource, postsAfter, unreadOnly, featuredPosts) } } .onEach { (activeSource, postsAfter, unreadOnly, featuredPosts) -> val posts = @@ -372,23 +344,41 @@ class HomePresenter( _state.update { it.copy(posts = posts, loadingState = HomeLoadingState.Idle) } } - .transformLatest { (_, _, _, featuredPosts) -> - val featuredPostsWithSeedColor = - featuredPosts.map { featuredPost -> - val seedColor = - withContext(dispatchersProvider.default) { - seedColorExtractor.calculateSeedColor(featuredPost.postWithMetadata.imageUrl) - } - - featuredPost.copy(seedColor = seedColor) - } - - _state.update { it.copy(featuredPosts = featuredPostsWithSeedColor.toImmutableList()) } - emit(Unit) - } .launchIn(coroutineScope) } + private suspend fun calculateSeedColors(featuredPosts: List) = + featuredPosts + .map { post -> + post.copy( + seedColor = + withContext(dispatchersProvider.default) { + seedColorExtractor.calculateSeedColor(post.postWithMetadata.imageUrl) + } + ) + } + .toImmutableList() + + private fun getPostsAfter(postsType: PostsType) = + when (postsType) { + PostsType.ALL, + PostsType.UNREAD -> Instant.DISTANT_PAST + PostsType.TODAY -> { + getTodayStartInstant() + } + PostsType.LAST_24_HOURS -> { + getLast24HourStart() + } + } + + private fun getUnreadOnly(postsType: PostsType) = + when (postsType) { + PostsType.ALL, + PostsType.TODAY, + PostsType.LAST_24_HOURS -> null + PostsType.UNREAD -> true + } + private fun loadFeaturedPostsItems( activeSource: Source?, unreadOnly: Boolean?, @@ -400,15 +390,11 @@ class HomePresenter( unreadOnly = unreadOnly, after = postsAfter ) - .map { featuredPosts -> + .mapLatest { featuredPosts -> featuredPosts.map { postWithMetadata -> - val seedColor = - withContext(dispatchersProvider.default) { - seedColorExtractor.cached(postWithMetadata.imageUrl) - } FeaturedPostItem( postWithMetadata = postWithMetadata, - seedColor = seedColor, + seedColor = null, ) } } From 3ecacf378ae7fc5ad97da9e210f1b6392501ebc1 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sat, 22 Feb 2025 10:02:28 +0530 Subject: [PATCH 02/14] Handle relative redirect locations in header when fetching RSS feeds (#804) --- .../core/network/fetcher/FeedFetcher.kt | 19 +++--- .../core/network/parser/AtomContentParser.kt | 23 ++----- .../reader/core/network/parser/FeedParser.kt | 26 -------- .../core/network/parser/RDFContentParser.kt | 23 ++----- .../core/network/parser/RSSContentParser.kt | 25 +++----- .../rss/reader/core/network/utils/UrlUtils.kt | 64 +++++++++++++++++++ 6 files changed, 97 insertions(+), 83 deletions(-) create mode 100644 core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt 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 8e9caf19c..d93659135 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 @@ -22,6 +22,7 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_HR import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_TYPE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.RSS_MEDIA_TYPE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_LINK +import dev.sasikanth.rss.reader.core.network.utils.UrlUtils import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse @@ -73,7 +74,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe HttpStatusCode.SeeOther, HttpStatusCode.TemporaryRedirect, HttpStatusCode.PermanentRedirect -> { - handleHttpRedirect(response, transformedUrl.toString(), redirectCount) + handleHttpRedirect(response, transformedUrl, redirectCount) } else -> { FeedFetchResult.HttpStatusError(statusCode = response.status) @@ -110,15 +111,17 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe private suspend fun handleHttpRedirect( response: HttpResponse, - url: String, + url: Url, redirectCount: Int ): FeedFetchResult { - val newUrl = response.headers["Location"] - return if (newUrl != url && !newUrl.isNullOrBlank()) { - fetch(url = newUrl, transformUrl = false, redirectCount = redirectCount + 1) - } else { - FeedFetchResult.Error(Exception("Failed to fetch the feed")) + val headerLocation = response.headers["Location"] + val redirectToUrl = UrlUtils.safeUrl(host = url.host, url = headerLocation) + + if (redirectToUrl == url.toString() || redirectToUrl.isNullOrBlank()) { + return FeedFetchResult.Error(Exception("Failed to fetch the feed")) } + + return fetch(url = redirectToUrl, transformUrl = true, redirectCount = redirectCount + 1) } private fun transformUrl(url: String, transformUrl: Boolean): Url { @@ -159,6 +162,6 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe val host = URLBuilder(originalUrl).build().host val rootUrl = "https://$host" - return FeedParser.safeUrl(rootUrl, link) + return UrlUtils.safeUrl(rootUrl, link) } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt index 5aa35f295..22ac98d8e 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt @@ -30,9 +30,9 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_SUB import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_SUMMARY 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 dev.sasikanth.rss.reader.core.network.utils.UrlUtils import dev.sasikanth.rss.reader.util.dateStringToEpochMillis import dev.sasikanth.rss.reader.util.decodeHTMLString -import io.ktor.http.Url import kotlinx.datetime.Clock import org.kobjects.ktxml.api.EventType import org.kobjects.ktxml.api.XmlPullParser @@ -65,30 +65,21 @@ internal object AtomContentParser : ContentParser() { description = parser.nextText() } TAG_ATOM_ENTRY -> { - posts.add(readAtomEntry(parser, link)) + val host = UrlUtils.extractHost(link ?: feedUrl) + posts.add(readAtomEntry(parser, host)) } else -> parser.skip() } } - if (link.isNullOrBlank()) { - link = feedUrl - } - - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } + val host = UrlUtils.extractHost(link ?: feedUrl) val iconUrl = FeedParser.feedIcon(host) return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), description = FeedParser.cleanText(description).orEmpty().decodeHTMLString(), icon = iconUrl, - homepageLink = link, + homepageLink = link ?: feedUrl, link = feedUrl, posts = posts.filterNotNull() ) @@ -138,7 +129,7 @@ internal object AtomContentParser : ContentParser() { } } - val postPubDateInMillis = date?.let { dateString -> dateString.dateStringToEpochMillis() } + val postPubDateInMillis = date?.dateStringToEpochMillis() if (title.isNullOrBlank() && content.isNullOrBlank()) { return null @@ -149,7 +140,7 @@ internal object AtomContentParser : ContentParser() { title = FeedParser.cleanText(title).orEmpty().decodeHTMLString(), description = content.orEmpty().decodeHTMLString(), rawContent = rawContent, - imageUrl = FeedParser.safeUrl(hostLink, image), + imageUrl = UrlUtils.safeUrl(hostLink, image), date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), commentsLink = null ) 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 1a4e1e1a1..36930e6f7 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 @@ -19,8 +19,6 @@ import co.touchlab.kermit.Logger import dev.sasikanth.rss.reader.core.model.remote.FeedPayload import dev.sasikanth.rss.reader.exceptions.XmlParsingError import dev.sasikanth.rss.reader.util.DispatchersProvider -import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol import io.ktor.http.set import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.core.readBytes @@ -111,30 +109,6 @@ class FeedParser(private val dispatchersProvider: DispatchersProvider) { fun feedIcon(host: String): String { return "https://icon.horse/icon/$host" } - - fun safeUrl(host: String?, url: String?): String? { - if (host.isNullOrBlank()) return null - - return if (!url.isNullOrBlank()) { - if (isAbsoluteUrl(url)) { - URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.buildString() - } else { - URLBuilder(host) - .apply { - set(path = url) - protocol = URLProtocol.HTTPS - } - .buildString() - } - } else { - null - } - } - - private fun isAbsoluteUrl(url: String): Boolean { - val pattern = """^[a-zA-Z][a-zA-Z0-9\+\-\.]*:""".toRegex() - return pattern.containsMatchIn(url) - } } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt index cf736ea47..af9eb19e9 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt @@ -26,9 +26,9 @@ 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 dev.sasikanth.rss.reader.core.network.utils.UrlUtils import dev.sasikanth.rss.reader.util.dateStringToEpochMillis import dev.sasikanth.rss.reader.util.decodeHTMLString -import io.ktor.http.Url import kotlinx.datetime.Clock import org.kobjects.ktxml.api.EventType import org.kobjects.ktxml.api.XmlPullParser @@ -72,30 +72,21 @@ internal object RDFContentParser : ContentParser() { when (parser.name) { TAG_RSS_ITEM -> { - posts.add(readRssItem(parser, link)) + val host = UrlUtils.extractHost(link ?: feedUrl) + posts.add(readRssItem(parser, host)) } else -> parser.skip() } } - if (link.isNullOrBlank()) { - link = feedUrl - } - - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } + val host = UrlUtils.extractHost(link ?: feedUrl) val iconUrl = FeedParser.feedIcon(host) return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), description = FeedParser.cleanText(description).orEmpty().decodeHTMLString(), icon = iconUrl, - homepageLink = link, + homepageLink = link ?: feedUrl, link = feedUrl, posts = posts.filterNotNull() ) @@ -137,7 +128,7 @@ internal object RDFContentParser : ContentParser() { } } - val postPubDateInMillis = date?.let { dateString -> dateString.dateStringToEpochMillis() } + val postPubDateInMillis = date?.dateStringToEpochMillis() if (title.isNullOrBlank() && description.isNullOrBlank()) { return null @@ -148,7 +139,7 @@ internal object RDFContentParser : ContentParser() { title = FeedParser.cleanText(title).orEmpty().decodeHTMLString(), description = description.orEmpty().decodeHTMLString(), rawContent = rawContent, - imageUrl = FeedParser.safeUrl(hostLink, image), + imageUrl = UrlUtils.safeUrl(hostLink, image), date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), commentsLink = commentsLink?.trim() ) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt index fcff82ee1..974e42f80 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt @@ -32,9 +32,9 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS 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 dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_URL +import dev.sasikanth.rss.reader.core.network.utils.UrlUtils import dev.sasikanth.rss.reader.util.dateStringToEpochMillis import dev.sasikanth.rss.reader.util.decodeHTMLString -import io.ktor.http.Url import kotlinx.datetime.Clock import org.kobjects.ktxml.api.EventType import org.kobjects.ktxml.api.XmlPullParser @@ -54,7 +54,7 @@ internal object RSSContentParser : ContentParser() { while (parser.next() != EventType.END_TAG) { if (parser.eventType != EventType.START_TAG) continue - when (val name = parser.name) { + when (parser.name) { TAG_TITLE -> { title = parser.nextText() } @@ -69,30 +69,21 @@ internal object RSSContentParser : ContentParser() { description = parser.nextText() } TAG_RSS_ITEM -> { - posts.add(readRssItem(parser, link)) + val host = UrlUtils.extractHost(link ?: feedUrl) + posts.add(readRssItem(parser, host)) } else -> parser.skip() } } - if (link.isNullOrBlank()) { - link = feedUrl - } - - val domain = Url(link) - val host = - if (domain.host != "localhost") { - domain.host - } else { - throw NullPointerException("Unable to get host domain") - } + val host = UrlUtils.extractHost(link ?: feedUrl) val iconUrl = FeedParser.feedIcon(host) return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), description = FeedParser.cleanText(description).orEmpty().decodeHTMLString(), icon = iconUrl, - homepageLink = link, + homepageLink = link ?: feedUrl, link = feedUrl, posts = posts.filterNotNull() ) @@ -146,7 +137,7 @@ internal object RSSContentParser : ContentParser() { } } - val postPubDateInMillis = date?.let { dateString -> dateString.dateStringToEpochMillis() } + val postPubDateInMillis = date?.dateStringToEpochMillis() if (title.isNullOrBlank() && description.isNullOrBlank()) { return null @@ -157,7 +148,7 @@ internal object RSSContentParser : ContentParser() { title = FeedParser.cleanText(title).orEmpty().decodeHTMLString(), description = description.orEmpty().decodeHTMLString(), rawContent = rawContent, - imageUrl = FeedParser.safeUrl(hostLink, image), + imageUrl = UrlUtils.safeUrl(hostLink, image), date = postPubDateInMillis ?: Clock.System.now().toEpochMilliseconds(), commentsLink = commentsLink?.trim() ) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt new file mode 100644 index 000000000..54a9fd6f2 --- /dev/null +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 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.utils + +import io.ktor.http.URLBuilder +import io.ktor.http.URLProtocol +import io.ktor.http.Url +import io.ktor.http.set + +object UrlUtils { + + fun extractHost(urlString: String): String { + val host = + if (urlString.startsWith("http://") || urlString.startsWith("https://")) { + Url(urlString).host + } else { + urlString + } + + return if (host == "localhost") { + urlString + } else { + host + } + } + + fun safeUrl(host: String?, url: String?): String? { + if (host.isNullOrBlank()) return null + + return if (!url.isNullOrBlank()) { + if (isAbsoluteUrl(url)) { + URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.buildString() + } else { + URLBuilder() + .apply { + set(host = host, path = url) + protocol = URLProtocol.HTTPS + } + .buildString() + } + } else { + null + } + } + + private fun isAbsoluteUrl(url: String): Boolean { + val pattern = """^[a-zA-Z][a-zA-Z0-9\+\-\.]*:""".toRegex() + return pattern.containsMatchIn(url) + } +} From 8cdc1555187848099b30a02c334390ed2e582ddd Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sat, 22 Feb 2025 13:02:46 +0530 Subject: [PATCH 03/14] Improve feed content parsing (#805) * Enable relaxed mode when parsing feeds * Skip items in feeds if they don't have link or title & description * Use XML encoding when present while parsing the feed --- .../core/network/parser/AtomContentParser.kt | 2 +- .../reader/core/network/parser/FeedParser.kt | 31 +++++++++++++++++-- .../core/network/parser/RDFContentParser.kt | 2 +- .../core/network/parser/RSSContentParser.kt | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt index 22ac98d8e..cb3f1033c 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt @@ -131,7 +131,7 @@ internal object AtomContentParser : ContentParser() { val postPubDateInMillis = date?.dateStringToEpochMillis() - if (title.isNullOrBlank() && content.isNullOrBlank()) { + if (link.isNullOrBlank() || (title.isNullOrBlank() && content.isNullOrBlank())) { return null } 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 36930e6f7..943fea497 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 @@ -23,6 +23,7 @@ import io.ktor.http.set import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.core.readBytes import korlibs.io.lang.Charset +import korlibs.io.lang.Charsets import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.runBlocking @@ -41,7 +42,11 @@ class FeedParser(private val dispatchersProvider: DispatchersProvider) { ): FeedPayload { return try { withContext(dispatchersProvider.io) { - val parser = MiniXmlPullParser(source = content.toCharIterator(charset)) + val parser = + MiniXmlPullParser( + source = content.toCharIterator(charset), + relaxed = true, + ) parser.nextTag() @@ -120,6 +125,7 @@ private fun ByteReadChannel.toCharIterator( private val DEFAULT_BUFFER_SIZE = 1024L + private var encodingCharset: Charset? = null private var currentIndex = 0 private var currentBuffer = String() @@ -128,13 +134,34 @@ private fun ByteReadChannel.toCharIterator( if (this@toCharIterator.isClosedForRead) return false val packet = runBlocking(context) { this@toCharIterator.readRemaining(DEFAULT_BUFFER_SIZE) } - currentBuffer = buildString { charset.decode(this, packet.readBytes()) } + val bytes = packet.readBytes() + val encodingRegex = """""".toRegex() + if (encodingCharset == null) { + val encodingContent = buildString { Charsets.UTF8.decode(this, bytes) } + encodingCharset = findEncodingCharset(encodingRegex, encodingContent, charset) + } + + currentBuffer = buildString { (encodingCharset ?: charset).decode(this, bytes) } packet.release() currentIndex = 0 return currentBuffer.isNotEmpty() } + private fun findEncodingCharset( + encodingRegex: Regex, + encodingContent: String, + fallbackCharset: Charset, + ) = + (encodingRegex.find(encodingContent)?.groupValues?.get(1)?.let { encoding -> + try { + Charset.forName(encoding) + } catch (e: Exception) { + null + } + } + ?: fallbackCharset) + override fun nextChar(): Char { if (!hasNext()) throw NoSuchElementException() return currentBuffer[currentIndex++] diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt index af9eb19e9..f5979cf26 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt @@ -130,7 +130,7 @@ internal object RDFContentParser : ContentParser() { val postPubDateInMillis = date?.dateStringToEpochMillis() - if (title.isNullOrBlank() && description.isNullOrBlank()) { + if (link.isNullOrBlank() || (title.isNullOrBlank() && description.isNullOrBlank())) { return null } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt index 974e42f80..d760c9c9c 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt @@ -139,7 +139,7 @@ internal object RSSContentParser : ContentParser() { val postPubDateInMillis = date?.dateStringToEpochMillis() - if (title.isNullOrBlank() && description.isNullOrBlank()) { + if (link.isNullOrBlank() || (title.isNullOrBlank() && description.isNullOrBlank())) { return null } From f2e814a0addd824258e3c562e4a6da14f534b402 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 23 Feb 2025 05:54:55 +0530 Subject: [PATCH 04/14] Fix screen stack going back to home screen on config changes (#807) fixes #726 --- .../kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt index ec0357e30..78f24e586 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt @@ -132,7 +132,9 @@ class AppPresenter( // are finished and we can navigate to next screen scope.launch { withContext(dispatchersProvider.io) { rssRepository.numberOfFeeds().firstOrNull() } - navigation.replaceAll(Config.Home) + if (screenStack.active.instance is Screen.Placeholder) { + navigation.replaceAll(Config.Home) + } } } From 3b1cf81ca3888e19ae27489016e7b73fd45fb1c4 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 23 Feb 2025 06:51:23 +0530 Subject: [PATCH 05/14] Add support for non-HTTPs feeds (#808) fixes #493 --- .../src/androidMain/AndroidManifest.xml | 2 + .../res/xml/network_security_config.xml | 23 ++++++++ .../reader/data/repository/RssRepository.kt | 19 ++----- .../core/network/di/NetworkComponent.kt | 12 +++- .../core/network/fetcher/FeedFetcher.kt | 55 ++++++++----------- .../core/network/post/FullArticleFetcher.kt | 9 +-- .../rss/reader/core/network/utils/UrlUtils.kt | 10 +--- iosApp/iosApp/Info.plist | 5 ++ .../sasikanth/rss/reader/FeedParserTest.kt | 4 +- 9 files changed, 73 insertions(+), 66 deletions(-) create mode 100644 androidApp/src/androidMain/res/xml/network_security_config.xml diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml index 292753a8f..1b3e23af4 100644 --- a/androidApp/src/androidMain/AndroidManifest.xml +++ b/androidApp/src/androidMain/AndroidManifest.xml @@ -22,6 +22,8 @@ android:fullBackupContent="false" android:dataExtractionRules="@xml/data_extraction_rules" android:enableOnBackInvokedCallback="true" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="tiramisu" android:largeHeap="true"> diff --git a/androidApp/src/androidMain/res/xml/network_security_config.xml b/androidApp/src/androidMain/res/xml/network_security_config.xml new file mode 100644 index 000000000..451657817 --- /dev/null +++ b/androidApp/src/androidMain/res/xml/network_security_config.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt index 65a2d580c..4bc1621d9 100644 --- a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt +++ b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt @@ -74,10 +74,9 @@ class RssRepository( feedLink: String, title: String? = null, feedLastCleanUpAt: Instant? = null, - transformUrl: Boolean = true, ): FeedAddResult { return withContext(ioDispatcher) { - when (val feedFetchResult = feedFetcher.fetch(url = feedLink, transformUrl = transformUrl)) { + when (val feedFetchResult = feedFetcher.fetch(url = feedLink)) { is FeedFetchResult.Success -> { return@withContext try { val feedPayload = feedFetchResult.feedPayload @@ -140,13 +139,7 @@ class RssRepository( val feedsChunk = feedQueries.feeds().executeAsList().chunked(UPDATE_CHUNKS) feedsChunk.map { feeds -> feeds.map { feed -> - launch { - addFeed( - feedLink = feed.link, - transformUrl = false, - feedLastCleanUpAt = feed.lastCleanUpAt - ) - } + launch { addFeed(feedLink = feed.link, feedLastCleanUpAt = feed.lastCleanUpAt) } } } } @@ -158,7 +151,7 @@ class RssRepository( withContext(ioDispatcher) { val feed = feedQueries.feed(selectedFeedId).executeAsOneOrNull() if (feed != null) { - addFeed(feedLink = feed.link, transformUrl = false, feedLastCleanUpAt = feed.lastCleanUpAt) + addFeed(feedLink = feed.link, feedLastCleanUpAt = feed.lastCleanUpAt) } } } @@ -168,11 +161,7 @@ class RssRepository( feedIds.forEach { feedId -> val feed = feedQueries.feed(feedId).executeAsOneOrNull() if (feed != null) { - addFeed( - feedLink = feed.link, - transformUrl = false, - feedLastCleanUpAt = feed.lastCleanUpAt - ) + addFeed(feedLink = feed.link, feedLastCleanUpAt = feed.lastCleanUpAt) } } } 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 fd10a7fb2..465375338 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 @@ -20,12 +20,22 @@ import dev.sasikanth.rss.reader.di.scopes.AppScope import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import me.tatarka.inject.annotations.Provides +import okhttp3.Protocol actual interface NetworkComponent { @Provides @AppScope fun providesHttpClient(): HttpClient { - return httpClient(engine = OkHttp, config = { config { retryOnConnectionFailure(true) } }) + return httpClient( + engine = OkHttp, + config = { + config { + retryOnConnectionFailure(true) + + protocols(listOf(Protocol.HTTP_1_1, Protocol.HTTP_2)) + } + } + ) } } 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 d93659135..5bb112950 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 @@ -31,9 +31,9 @@ import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol import io.ktor.http.Url import io.ktor.http.contentType +import io.ktor.http.isRelativePath import korlibs.io.lang.Charset import korlibs.io.lang.Charsets import me.tatarka.inject.annotations.Inject @@ -45,13 +45,12 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe private const val MAX_REDIRECTS_ALLOWED = 5 } - suspend fun fetch(url: String, transformUrl: Boolean = true): FeedFetchResult { - return fetch(url, transformUrl, redirectCount = 0) + suspend fun fetch(url: String): FeedFetchResult { + return fetch(url, redirectCount = 0) } private suspend fun fetch( url: String, - transformUrl: Boolean, redirectCount: Int, ): FeedFetchResult { if (redirectCount >= MAX_REDIRECTS_ALLOWED) { @@ -59,9 +58,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe } return try { - // We are mainly doing this to avoid creating duplicates while refreshing feeds - // after the app update - val transformedUrl = transformUrl(url, transformUrl) + val transformedUrl = buildFeedUrl(url) val response = httpClient.get(transformedUrl.toString()) when (response.status) { @@ -85,6 +82,21 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe } } + private fun buildFeedUrl(urlString: String): Url { + val url = Url(urlString) + + return if (url.isRelativePath) { + URLBuilder() + .apply { + host = urlString + protocol = url.protocol + } + .build() + } else { + url + } + } + private suspend fun parseContent( response: HttpResponse, url: String, @@ -103,7 +115,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe val feedUrl = fetchFeedLinkFromHtmlIfExists(response.bodyAsText(), url) if (feedUrl != url && !feedUrl.isNullOrBlank()) { - return fetch(url = feedUrl, transformUrl = false, redirectCount = redirectCount + 1) + return fetch(url = feedUrl, redirectCount = redirectCount + 1) } throw UnsupportedOperationException() @@ -121,27 +133,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe return FeedFetchResult.Error(Exception("Failed to fetch the feed")) } - return fetch(url = redirectToUrl, transformUrl = true, redirectCount = redirectCount + 1) - } - - private fun transformUrl(url: String, transformUrl: Boolean): Url { - return if (transformUrl) { - // Currently Ktor Url parses relative URLs, - // if it fails to properly parse the given URL, it - // default to localhost. - // - // This will cause the network call to fail, - // so we are setting the host manually - // https://youtrack.jetbrains.com/issue/KTOR-360 - URLBuilder() - .apply { - protocol = URLProtocol.HTTPS - host = url.replace(Regex("^https?://"), "") - } - .build() - } else { - URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.build() - } + return fetch(url = redirectToUrl, redirectCount = redirectCount + 1) } private fun fetchFeedLinkFromHtmlIfExists(htmlContent: String, originalUrl: String): String? { @@ -159,9 +151,8 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe } ?: return null val link = linkElement.attr(ATTR_HREF) - val host = URLBuilder(originalUrl).build().host - val rootUrl = "https://$host" + val host = UrlUtils.extractHost(originalUrl) - return UrlUtils.safeUrl(rootUrl, link) + return UrlUtils.safeUrl(host, link) } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt index 0b825213c..c4d444796 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt @@ -23,9 +23,6 @@ import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode -import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol -import io.ktor.http.Url import io.ktor.http.contentType import kotlinx.coroutines.withContext import me.tatarka.inject.annotations.Inject @@ -40,7 +37,7 @@ class FullArticleFetcher( suspend fun fetch(link: String): Result { return withContext(dispatchersProvider.io) { try { - val response = httpClient.get(transformUrlToHttps(link)) + val response = httpClient.get(link) if ( response.status == HttpStatusCode.OK && response.contentType()?.withoutParameters() == ContentType.Text.Html @@ -55,8 +52,4 @@ class FullArticleFetcher( return@withContext Result.failure(IllegalArgumentException("Failed to fetch the post")) } } - - private fun transformUrlToHttps(url: String): Url { - return URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.build() - } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt index 54a9fd6f2..b8a32ecc1 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/utils/UrlUtils.kt @@ -17,7 +17,6 @@ package dev.sasikanth.rss.reader.core.network.utils import io.ktor.http.URLBuilder -import io.ktor.http.URLProtocol import io.ktor.http.Url import io.ktor.http.set @@ -43,14 +42,9 @@ object UrlUtils { return if (!url.isNullOrBlank()) { if (isAbsoluteUrl(url)) { - URLBuilder(url).apply { protocol = URLProtocol.HTTPS }.buildString() + URLBuilder(url).buildString() } else { - URLBuilder() - .apply { - set(host = host, path = url) - protocol = URLProtocol.HTTPS - } - .buildString() + URLBuilder().apply { set(host = host, path = url) }.buildString() } } else { null diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index aacb7e3db..87f95e964 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -69,5 +69,10 @@ UIUserInterfaceStyle Automatic + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt index 0d3324697..89f08a743 100644 --- a/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt +++ b/shared/src/commonTest/kotlin/dev/sasikanth/rss/reader/FeedParserTest.kt @@ -96,7 +96,7 @@ class FeedParserTest { link = "https://example.com/post-with-relative-image", description = "Relative image post description.", rawContent = "Relative image post description.", - imageUrl = "https://example.com/relative-media-url", + imageUrl = "http://example.com/relative-media-url", date = 1685005200000, commentsLink = null ), @@ -228,7 +228,7 @@ class FeedParserTest {

Post summary with an image.

""" .trimIndent(), - imageUrl = "https://example.com/resources/image.jpg", + imageUrl = "http://example.com/resources/image.jpg", date = 1685008800000, commentsLink = null ), From 571d2c6db349102f99395144daa4c466d42abf1d Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 23 Feb 2025 07:28:07 +0530 Subject: [PATCH 06/14] Animate item updates in the home screen (#810) Will fine tune the animations later after discussing with Ed when we are starting the v2 --- .../kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt index 1311a6e4d..d53a0016c 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt @@ -93,6 +93,7 @@ internal fun PostsList( if (featuredPosts.isNotEmpty()) { item { FeaturedSection( + modifier = Modifier.animateItem(), paddingValues = paddingValues, pagerState = featuredPostsPagerState, featuredPosts = featuredPosts, @@ -110,6 +111,7 @@ internal fun PostsList( val post = posts[index] if (post != null) { PostListItem( + modifier = Modifier.animateItem(), item = post, reduceReadItemAlpha = true, onClick = { onPostClicked(post) }, @@ -140,12 +142,14 @@ fun PostListItem( onPostCommentsClick: () -> Unit, onPostSourceClick: () -> Unit, togglePostReadClick: () -> Unit, + modifier: Modifier = Modifier, reduceReadItemAlpha: Boolean = false, postMetadataConfig: PostMetadataConfig = PostMetadataConfig.DEFAULT, ) { Column( modifier = - Modifier.clickable(onClick = onClick) + Modifier.then(modifier) + .clickable(onClick = onClick) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(postListPadding) .alpha( From 8fb534ff808c95501f7244481c36eef14321ae16 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 23 Feb 2025 11:28:09 +0530 Subject: [PATCH 07/14] Change feed edit action label to settings (#812) --- .../ui/expanded/BottomSheetExpandedContent.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt index c9b31e424..af92b2997 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/expanded/BottomSheetExpandedContent.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.outlined.ViewAgenda import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Search @@ -73,6 +74,7 @@ import dev.sasikanth.rss.reader.components.ContextActionItem import dev.sasikanth.rss.reader.components.ContextActionsBottomBar import dev.sasikanth.rss.reader.core.model.local.FeedGroup import dev.sasikanth.rss.reader.core.model.local.FeedsViewMode +import dev.sasikanth.rss.reader.core.model.local.SourceType import dev.sasikanth.rss.reader.feeds.FeedsEvent import dev.sasikanth.rss.reader.feeds.FeedsPresenter import dev.sasikanth.rss.reader.feeds.ui.BottomSheetExpandedBottomBar @@ -151,14 +153,14 @@ internal fun BottomSheetExpandedContent( ) { val areSelectedFeedsPinned = state.selectedSources.all { it.pinnedAt != null } - val label = + val pinActionLabel = if (areSelectedFeedsPinned) LocalStrings.current.actionUnpin else LocalStrings.current.actionPin ContextActionItem( modifier = Modifier.weight(1f), icon = TwineIcons.Pin, - label = label, + label = pinActionLabel, onClick = { if (areSelectedFeedsPinned) { feedsPresenter.dispatch(FeedsEvent.UnPinSelectedSources) @@ -184,10 +186,23 @@ internal fun BottomSheetExpandedContent( ) if (state.selectedSources.size == 1) { + val editIcon = + if (state.selectedSources.first().sourceType == SourceType.FeedGroup) { + Icons.Filled.Edit + } else { + Icons.Filled.Tune + } + val editLabel = + if (state.selectedSources.first().sourceType == SourceType.FeedGroup) { + LocalStrings.current.edit + } else { + LocalStrings.current.settings + } + ContextActionItem( modifier = Modifier.weight(1f), - icon = Icons.Filled.Edit, - label = LocalStrings.current.edit, + icon = editIcon, + label = editLabel, onClick = { feedsPresenter.dispatch( FeedsEvent.OnEditSourceClicked(state.selectedSources.first()) From 324375b54e45c38b3e0f1cec85e0e160b1581037 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 23 Feb 2025 12:20:12 +0530 Subject: [PATCH 08/14] Load source article if post doesn't contain any content (#813) * Load full article in reader view if there is no description * Allow auto HTTP redirects when fetching the full article --- .../rss/reader/core/network/post/FullArticleFetcher.kt | 3 ++- .../kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt index c4d444796..2b659fe5b 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/FullArticleFetcher.kt @@ -37,7 +37,8 @@ class FullArticleFetcher( suspend fun fetch(link: String): Result { return withContext(dispatchersProvider.io) { try { - val response = httpClient.get(link) + val response = httpClient.config { followRedirects = true }.get(link) + if ( response.status == HttpStatusCode.OK && response.contentType()?.withoutParameters() == ContentType.Text.Html diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt index 46706dcd6..fed2609cc 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ReaderPresenter.kt @@ -142,7 +142,8 @@ class ReaderPresenter( ) } - if (feed.alwaysFetchSourceArticle) { + val hasContent = post.description.isNotBlank() || post.rawContent.isNullOrBlank().not() + if (feed.alwaysFetchSourceArticle || hasContent.not()) { loadSourceArticle() } else { loadRssContent() From 7e84ece84c3dca322fee5af370518d830061a4eb Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sun, 23 Feb 2025 12:22:49 +0530 Subject: [PATCH 09/14] Remove `animateItem` from posts list --- .../kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt index d53a0016c..688edc70c 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/PostList.kt @@ -93,7 +93,6 @@ internal fun PostsList( if (featuredPosts.isNotEmpty()) { item { FeaturedSection( - modifier = Modifier.animateItem(), paddingValues = paddingValues, pagerState = featuredPostsPagerState, featuredPosts = featuredPosts, @@ -111,7 +110,6 @@ internal fun PostsList( val post = posts[index] if (post != null) { PostListItem( - modifier = Modifier.animateItem(), item = post, reduceReadItemAlpha = true, onClick = { onPostClicked(post) }, From a4f861c0403339cf7ae455dd515543e8d5b5c8a9 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 23 Feb 2025 16:36:21 +0530 Subject: [PATCH 10/14] Bump Ktor and Ksoup dependencies, and replace okio with kotlinx-io (#816) --- core/network/build.gradle.kts | 2 + .../core/network/fetcher/FeedFetcher.kt | 13 ++-- .../reader/core/network/parser/FeedParser.kt | 7 +- gradle/libs.versions.toml | 13 ++-- shared/build.gradle.kts | 2 +- .../rss/reader/favicons/FavIconFetcher.kt | 71 ++++++++++++++++--- .../rss/reader/favicons/FavIconImageLoader.kt | 2 +- 7 files changed, 85 insertions(+), 25 deletions(-) diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 1f3f0b5b6..e16dc7655 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -36,10 +36,12 @@ kotlin { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines) + implementation(libs.kotlinx.io) implementation(libs.kotlininject.runtime) implementation(libs.ktor.core) implementation(libs.ktor.client.logging) implementation(libs.ksoup) + implementation(libs.ksoup.kotlinx.io) implementation(libs.ktxml) implementation(libs.kermit) implementation(libs.crashkios.bugsnag) 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 5bb112950..c5702eed9 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 @@ -16,6 +16,7 @@ package dev.sasikanth.rss.reader.core.network.fetcher import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.parseSource import dev.sasikanth.rss.reader.core.network.parser.FeedParser import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATOM_MEDIA_TYPE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_HREF @@ -27,13 +28,14 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel -import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder import io.ktor.http.Url import io.ktor.http.contentType import io.ktor.http.isRelativePath +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.asSource import korlibs.io.lang.Charset import korlibs.io.lang.Charsets import me.tatarka.inject.annotations.Inject @@ -112,7 +114,7 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe return FeedFetchResult.Success(feedPayload) } - val feedUrl = fetchFeedLinkFromHtmlIfExists(response.bodyAsText(), url) + val feedUrl = fetchFeedLinkFromHtmlIfExists(response.bodyAsChannel(), url) if (feedUrl != url && !feedUrl.isNullOrBlank()) { return fetch(url = feedUrl, redirectCount = redirectCount + 1) @@ -136,10 +138,13 @@ class FeedFetcher(private val httpClient: HttpClient, private val feedParser: Fe return fetch(url = redirectToUrl, redirectCount = redirectCount + 1) } - private fun fetchFeedLinkFromHtmlIfExists(htmlContent: String, originalUrl: String): String? { + private fun fetchFeedLinkFromHtmlIfExists( + htmlContent: ByteReadChannel, + originalUrl: String + ): String? { val document = try { - Ksoup.parse(htmlContent) + Ksoup.parseSource(htmlContent.asSource()) } catch (t: Throwable) { return null } 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 943fea497..a125fb792 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 @@ -21,13 +21,14 @@ import dev.sasikanth.rss.reader.exceptions.XmlParsingError import dev.sasikanth.rss.reader.util.DispatchersProvider import io.ktor.http.set import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.core.readBytes +import io.ktor.utils.io.readRemaining import korlibs.io.lang.Charset import korlibs.io.lang.Charsets import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.io.readByteArray import me.tatarka.inject.annotations.Inject import org.kobjects.ktxml.api.XmlPullParserException import org.kobjects.ktxml.mini.MiniXmlPullParser @@ -134,7 +135,7 @@ private fun ByteReadChannel.toCharIterator( if (this@toCharIterator.isClosedForRead) return false val packet = runBlocking(context) { this@toCharIterator.readRemaining(DEFAULT_BUFFER_SIZE) } - val bytes = packet.readBytes() + val bytes = packet.readByteArray() val encodingRegex = """""".toRegex() if (encodingCharset == null) { val encodingContent = buildString { Charsets.UTF8.decode(this, bytes) } @@ -143,7 +144,7 @@ private fun ByteReadChannel.toCharIterator( currentBuffer = buildString { (encodingCharset ?: charset).decode(this, bytes) } - packet.release() + packet.close() currentIndex = 0 return currentBuffer.isNotEmpty() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 869611e11..fc10ab40a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,11 +9,12 @@ android_sdk_target = "35" android_sdk_min = "26" sqldelight = "2.0.2" -ktor = "2.3.13" +ktor = "3.1.0" kotlinx_coroutines = "1.10.1" kotlinx_date_time = "0.6.2" kotlinx_immutable_collections = "0.3.8" kotlinx_serialization_json = "1.8.0" +kotlinx_io = "0.6.0" decompose = "3.0.0" essenty = "2.4.0" androidx_activity = "1.10.0" @@ -32,13 +33,12 @@ ktfmt = "0.44" kotlininject = "0.7.2" ksp = "2.1.10-1.0.30" material_color_utilities = "1.0.0-alpha01" -ksoup = "0.1.2" +ksoup = "0.2.2" sqliteAndroid = "3.45.0" windowSizeClass = "0.5.0" desugarJdk = "2.1.4" lyricist = "1.7.0" atomicfu = "0.27.0" -okio = "3.10.2" paging = "3.3.0-alpha02-0.5.1" stately = "2.1.0" xmlutil = "0.90.3" @@ -73,6 +73,7 @@ kotlinx_datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. kotlinx_immutable_collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable_collections" } kotlinx_atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } kotlinx_serialization_json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx_serialization_json" } +kotlinx_io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx_io" } sqldelight_driver_android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight_driver_native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } sqldelight_extensions_coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } @@ -95,18 +96,18 @@ androidx_datastore_preferences = { module = "androidx.datastore:datastore-prefer androidx_browser = { module = "androidx.browser:browser", version.ref = "androidx_browser" } androidx_annotation= { module = "androidx.annotation:annotation", version.ref = "androidx_annotation" } coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } -coil_network = { module = "io.coil-kt.coil3:coil-network-ktor2", version.ref = "coil" } +coil_network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil_svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlininject-compiler = { module = 'me.tatarka.inject:kotlin-inject-compiler-ksp', version.ref = 'kotlininject' } kotlininject-runtime = { module = 'me.tatarka.inject:kotlin-inject-runtime', version.ref = 'kotlininject' } material_color_utilities = { module = "dev.sasikanth:material-color-utilities", version.ref = "material_color_utilities" } ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } +ksoup-kotlinx-io = { module = "com.fleeksoft.ksoup:ksoup-kotlinx", version.ref = "ksoup" } sqliteAndroid = { module = "com.github.requery:sqlite-android", version.ref = "sqliteAndroid" } windowSizeClass = { module = "dev.chrisbanes.material3:material3-window-size-class-multiplatform", version.ref = "windowSizeClass" } desugarJdk = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdk" } lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist" } lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" } -okio = { module = "com.squareup.okio:okio", version.ref = "okio" } paging-common = { module = "app.cash.paging:paging-common", version.ref = "paging" } paging-compose = { module = "app.cash.paging:paging-compose-common", version.ref = "paging" } stately-isolate = { module = "co.touchlab:stately-isolate", version.ref = "stately" } @@ -140,6 +141,6 @@ bugsnag = { id = "com.bugsnag.android.gradle", version.ref = "bugsnag-plugin" } [bundles] compose = [ "compose_runtime", "compose_foundation", "compose_material", "compose_material3", "compose_resources", "compose_ui", "compose_ui_util", "compose_material_icons_extended" ] -kotlinx = [ "kotlinx_coroutines", "kotlinx_datetime", "kotlinx_immutable_collections", "kotlinx_serialization_json" ] +kotlinx = [ "kotlinx_coroutines", "kotlinx_datetime", "kotlinx_immutable_collections", "kotlinx_serialization_json", "kotlinx_io" ] androidx_test = [ "androidx_test_runner", "androidx_test_rules" ] xmlutil = [ "xmlutil-core", "xmlutil-serialization" ] diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index d7ced248b..be8b2d3b1 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -94,10 +94,10 @@ kotlin { implementation(libs.androidx.collection) implementation(libs.material.color.utilities) implementation(libs.ksoup) + implementation(libs.ksoup.kotlinx.io) implementation(libs.windowSizeClass) api(libs.androidx.datastore.okio) api(libs.androidx.datastore.preferences) - api(libs.okio) implementation(libs.paging.common) implementation(libs.paging.compose) implementation(libs.stately.isolate) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt index 7e17fbfe9..5e6150cf0 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt @@ -44,8 +44,14 @@ import coil3.request.Options import coil3.util.MimeTypeMap import com.fleeksoft.ksoup.Ksoup import com.fleeksoft.ksoup.nodes.Document -import com.fleeksoft.ksoup.ported.BufferReader -import okio.Buffer +import com.fleeksoft.ksoup.parseSource +import kotlin.math.min +import kotlinx.io.Buffer +import kotlinx.io.EOFException +import kotlinx.io.RawSource +import kotlinx.io.Source +import kotlinx.io.UnsafeIoApi +import kotlinx.io.unsafe.UnsafeBufferOperations import okio.FileSystem import okio.IOException @@ -92,11 +98,7 @@ class FavIconFetcher( val responseBodyBuffer = responseBody.readBuffer() val document = - Ksoup.parse( - bufferReader = BufferReader(responseBodyBuffer), - baseUri = url, - charsetName = null - ) + Ksoup.parseSource(source = responseBodyBuffer, baseUri = url, charsetName = null) val favIconUrl = parseFaviconUrl(document) ?: fallbackFaviconUrl(url) return@executeNetworkRequest networkFetcher(favIconUrl).fetch() @@ -232,10 +234,10 @@ class FavIconFetcher( } catch (_: Exception) {} } - private suspend fun NetworkResponseBody.readBuffer(): Buffer = use { body -> - val buffer = Buffer() + private suspend fun NetworkResponseBody.readBuffer(): RawSource = use { body -> + val buffer = okio.Buffer() body.writeTo(buffer) - return buffer + return buffer.asKotlinxIoRawSource() } private val httpMethodKey = Extras.Key(default = HTTP_METHOD_GET) @@ -293,3 +295,52 @@ class FavIconFetcher( } } } + +// TODO: Remove after Okio adapters are available in kotlinx-io library +/** + * Returns a [kotlinx.io.RawSource] backed by this [okio.Source]. + * + * Closing one of these sources will also close another one. + */ +public fun okio.Source.asKotlinxIoRawSource(): RawSource = + object : RawSource { + private val buffer = + okio.Buffer() // TODO: optimization - reuse BufferedSource's buffer if possible + + override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = withOkio2KxIOExceptionMapping { + val readBytes = this@asKotlinxIoRawSource.read(buffer, byteCount) + if (readBytes == -1L) return -1L + + var remaining = readBytes + while (remaining > 0) { + @OptIn(UnsafeIoApi::class) + UnsafeBufferOperations.writeToTail(sink, 1) { data, from, to -> + val toRead = min((to - from).toLong(), remaining).toInt() + val read = buffer.read(data, from, toRead) + check(read != -1) { "Buffer was exhausted before reading $toRead bytes from it." } + remaining -= read + read + } + } + + return readBytes + } + + override fun close() = withOkio2KxIOExceptionMapping { this@asKotlinxIoRawSource.close() } + } + +internal inline fun withOkio2KxIOExceptionMapping(block: () -> T): T { + try { + return block() + } catch ( + bypassIOE: + kotlinx.io.IOException) { // on JVM, kotlinx.io.IOException and okio.IOException are the same + throw bypassIOE + } catch (bypassEOF: kotlinx.io.EOFException) { // see above + throw bypassEOF + } catch (eofe: okio.EOFException) { + throw kotlinx.io.IOException(eofe.message, eofe) + } catch (ioe: okio.IOException) { + throw kotlinx.io.IOException(ioe.message, ioe) + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt index fcf168e2c..1cf51e0be 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt @@ -20,7 +20,7 @@ import coil3.ImageLoader import coil3.PlatformContext import coil3.annotation.ExperimentalCoilApi import coil3.network.CacheStrategy -import coil3.network.ktor2.asNetworkClient +import coil3.network.ktor3.asNetworkClient import io.ktor.client.HttpClient import kotlinx.atomicfu.atomic import kotlinx.atomicfu.updateAndGet From daea94185a20b3afeba85d004d0a04c355ae475e Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 23 Feb 2025 18:07:05 +0530 Subject: [PATCH 11/14] Add setting to disable auto update/sync (#818) --- .../rss/reader/FeedsRefreshWorker.kt | 7 ++- .../sasikanth/rss/reader/ReaderApplication.kt | 3 +- .../data/repository/SettingsRepository.kt | 12 +++++ iosApp/iosApp/AppDelegate.swift | 7 +++ .../resources/strings/DeTwineStrings.kt | 2 + .../resources/strings/EnTwineStrings.kt | 2 + .../resources/strings/TrTwineStrings.kt | 2 + .../reader/resources/strings/TwineStrings.kt | 2 + .../resources/strings/ZhTwineStrings.kt | 2 + .../rss/reader/settings/SettingsEvent.kt | 2 + .../rss/reader/settings/SettingsPresenter.kt | 10 ++++ .../rss/reader/settings/SettingsState.kt | 2 + .../rss/reader/settings/ui/SettingsScreen.kt | 48 +++++++++++++++++++ 13 files changed, 99 insertions(+), 2 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt index 239e96325..014efe560 100644 --- a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt +++ b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/FeedsRefreshWorker.kt @@ -25,16 +25,19 @@ import androidx.work.WorkerParameters import co.touchlab.crashkios.bugsnag.BugsnagKotlin import com.bugsnag.android.Bugsnag import dev.sasikanth.rss.reader.data.repository.RssRepository +import dev.sasikanth.rss.reader.data.repository.SettingsRepository import dev.sasikanth.rss.reader.refresh.LastUpdatedAt import java.lang.Exception import java.time.Duration import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first class FeedsRefreshWorker( context: Context, workerParameters: WorkerParameters, private val rssRepository: RssRepository, - private val lastUpdatedAt: LastUpdatedAt + private val lastUpdatedAt: LastUpdatedAt, + private val settingsRepository: SettingsRepository, ) : CoroutineWorker(context, workerParameters) { companion object { @@ -55,6 +58,8 @@ class FeedsRefreshWorker( } override suspend fun doWork(): Result { + if (settingsRepository.enableAutoSync.first().not()) return Result.failure() + return if (lastUpdatedAt.hasExpired()) { try { rssRepository.updateFeeds() diff --git a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt index 719ef8e84..c41b9d6b0 100644 --- a/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt +++ b/androidApp/src/androidMain/kotlin/dev/sasikanth/rss/reader/ReaderApplication.kt @@ -59,7 +59,8 @@ class ReaderApplication : Application(), Configuration.Provider { context = appContext, workerParameters = workerParameters, rssRepository = appComponent.rssRepository, - lastUpdatedAt = appComponent.lastUpdatedAt + lastUpdatedAt = appComponent.lastUpdatedAt, + settingsRepository = appComponent.settingsRepository, ) } PostsCleanUpWorker::class.qualifiedName -> { diff --git a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt index 5ddb5b3f5..68a30e7d3 100644 --- a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt +++ b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt @@ -40,6 +40,7 @@ class SettingsRepository(private val dataStore: DataStore) { private val feedsViewModeKey = stringPreferencesKey("pref_feeds_view_mode") private val feedsSortOrderKey = stringPreferencesKey("pref_feeds_sort_order") private val appThemeModeKey = stringPreferencesKey("pref_app_theme_mode_v2") + private val enableAutoSyncKey = booleanPreferencesKey("enable_auto_sync") val browserType: Flow = dataStore.data.map { preferences -> @@ -75,6 +76,13 @@ class SettingsRepository(private val dataStore: DataStore) { mapToAppThemeMode(preferences[appThemeModeKey]) ?: AppThemeMode.Dark } + val enableAutoSync: Flow = + dataStore.data.map { preferences -> preferences[enableAutoSyncKey] ?: true } + + suspend fun enableAutoSyncImmediate(): Boolean { + return enableAutoSync.first() + } + suspend fun updateFeedsSortOrder(value: FeedsOrderBy) { dataStore.edit { preferences -> preferences[feedsSortOrderKey] = value.name } } @@ -111,6 +119,10 @@ class SettingsRepository(private val dataStore: DataStore) { dataStore.edit { preferences -> preferences[appThemeModeKey] = value.name } } + suspend fun toggleAutoSync(value: Boolean) { + dataStore.edit { preferences -> preferences[enableAutoSyncKey] = value } + } + private fun mapToAppThemeMode(pref: String?): AppThemeMode? { if (pref.isNullOrBlank()) return null return AppThemeMode.valueOf(pref) diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift index abc13b3df..7a6ac5781 100644 --- a/iosApp/iosApp/AppDelegate.swift +++ b/iosApp/iosApp/AppDelegate.swift @@ -94,6 +94,13 @@ class AppDelegate: NSObject, UIApplicationDelegate { scheduledRefreshFeeds(earliest: Date(timeIntervalSinceNow: 60 * 60)) // 1 hour Task(priority: .background) { do { + let isAutoSyncEnabled = try await applicationComponent.settingsRepository.enableAutoSyncImmediate().boolValue + + if !isAutoSyncEnabled { + task.setTaskCompleted(success: false) + return + } + let hasLastUpdatedAtExpired = try await applicationComponent.lastUpdatedAt.hasExpired().boolValue if hasLastUpdatedAtExpired { try await applicationComponent.rssRepository.updateFeeds() diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt index 1696105d1..cf10353be 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt @@ -169,4 +169,6 @@ val DeTwineStrings = databaseMaintainenceTitle = "Please wait...", databaseMaintainenceSubtitle = "Performing database maintainence, don't close the app", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 97abd75bf..552bea253 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -176,4 +176,6 @@ val EnTwineStrings = databaseMaintainenceTitle = "Please wait...", databaseMaintainenceSubtitle = "Performing database maintainence, don't close the app", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt index 6b78194c3..f0e11e693 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt @@ -165,4 +165,6 @@ val TrTwineStrings = databaseMaintainenceTitle = "Lütfen bekleyin...", databaseMaintainenceSubtitle = "Veritabanı bakımı gerçekleştiriliyor, uygulamayı kapatmayın", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index 5bd85a663..c6eb4c02b 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -156,6 +156,8 @@ data class TwineStrings( val databaseMaintainenceTitle: String, val databaseMaintainenceSubtitle: String, val cdLoadFullArticle: String, + val enableAutoSyncTitle: String, + val enableAutoSyncDesc: String, ) object Locales { diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt index 6121863ea..69be89ebd 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt @@ -156,4 +156,6 @@ val ZhTwineStrings = databaseMaintainenceTitle = "请稍候...", databaseMaintainenceSubtitle = "正在进行数据库维护,请勿关闭应用", cdLoadFullArticle = "Load full article", + enableAutoSyncTitle = "Enable auto sync", + enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt index 4439d3dea..862a96772 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt @@ -29,6 +29,8 @@ sealed interface SettingsEvent { data class ToggleShowReaderView(val value: Boolean) : SettingsEvent + data class ToggleAutoSync(val value: Boolean) : SettingsEvent + data object AboutClicked : SettingsEvent data object ImportOpmlClicked : SettingsEvent diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt index 458315f32..5ce982ecf 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt @@ -110,6 +110,7 @@ class SettingsPresenter( settingsRepository.postsDeletionPeriod, settingsRepository.showReaderView, settingsRepository.appThemeMode, + settingsRepository.enableAutoSync, rssRepository.hasFeeds() ) { browserType, @@ -117,6 +118,7 @@ class SettingsPresenter( postsDeletionPeriod, showReaderView, appThemeMode, + enableAutoSync, hasFeeds -> Settings( browserType = browserType, @@ -125,6 +127,7 @@ class SettingsPresenter( postsDeletionPeriod = postsDeletionPeriod, showReaderView = showReaderView, appThemeMode = appThemeMode, + enableAutoSync = enableAutoSync, ) } .onEach { settings -> @@ -136,6 +139,7 @@ class SettingsPresenter( postsDeletionPeriod = settings.postsDeletionPeriod, showReaderView = settings.showReaderView, appThemeMode = settings.appThemeMode, + enableAutoSync = settings.enableAutoSync ) } } @@ -154,6 +158,7 @@ class SettingsPresenter( is SettingsEvent.UpdateBrowserType -> updateBrowserType(event.browserType) is SettingsEvent.ToggleShowUnreadPostsCount -> toggleShowUnreadPostsCount(event.value) is SettingsEvent.ToggleShowReaderView -> toggleShowReaderView(event.value) + is SettingsEvent.ToggleAutoSync -> toggleAutoSync(event.value) SettingsEvent.AboutClicked -> { // no-op } @@ -165,6 +170,10 @@ class SettingsPresenter( } } + private fun toggleAutoSync(value: Boolean) { + coroutineScope.launch { settingsRepository.toggleAutoSync(value) } + } + private fun onAppThemeModeChanged(appThemeMode: AppThemeMode) { coroutineScope.launch { settingsRepository.updateAppTheme(appThemeMode) } } @@ -210,4 +219,5 @@ private data class Settings( val postsDeletionPeriod: Period, val showReaderView: Boolean, val appThemeMode: AppThemeMode, + val enableAutoSync: Boolean, ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt index 143b80458..7e45293c3 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt @@ -32,6 +32,7 @@ internal data class SettingsState( val postsDeletionPeriod: Period?, val showReaderView: Boolean, val appThemeMode: AppThemeMode, + val enableAutoSync: Boolean, ) { companion object { @@ -46,6 +47,7 @@ internal data class SettingsState( postsDeletionPeriod = null, showReaderView = false, appThemeMode = AppThemeMode.Auto, + enableAutoSync = true, ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt index 73712ee3b..e4d95d9b2 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt @@ -221,6 +221,17 @@ internal fun SettingsScreen( item { Divider(24.dp) } + item { + AutoSyncSettingItem( + enableAutoSync = state.enableAutoSync, + onValueChanged = { newValue -> + settingsPresenter.dispatch(SettingsEvent.ToggleAutoSync(newValue)) + } + ) + } + + item { Divider(24.dp) } + item { PostsDeletionPeriodSettingItem( postsDeletionPeriod = state.postsDeletionPeriod, @@ -399,6 +410,43 @@ private fun PostsDeletionPeriodSettingItem( } } +@Composable +private fun AutoSyncSettingItem(enableAutoSync: Boolean, onValueChanged: (Boolean) -> Unit) { + var checked by remember(enableAutoSync) { mutableStateOf(enableAutoSync) } + Box( + modifier = + Modifier.clickable { + checked = !checked + onValueChanged(!enableAutoSync) + } + ) { + Row( + modifier = Modifier.padding(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + LocalStrings.current.enableAutoSyncTitle, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.textEmphasisHigh + ) + Text( + LocalStrings.current.enableAutoSyncDesc, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.textEmphasisMed + ) + } + + Spacer(Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { checked -> onValueChanged(checked) }, + ) + } + } +} + @Composable private fun UnreadPostsCountSettingItem( showUnreadCountEnabled: Boolean, From 135f2403ec6b700404f85d840fbaf66ad9bf91e3 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Mon, 24 Feb 2025 06:47:40 +0530 Subject: [PATCH 12/14] Add global setting to toggle between displaying website fav icon or the feed icon (#820) * Fetch feed icon when parsing the feeds part of #785 * Add preference to show feed fav icon * Add `showFeedFavIcon` column to `Feed` table This along with the global setting will be used to determine if we use the fav icon or the feed icon * Rename `FeedFavIcon` composable to `FeedIcon` * Load `showFeedFavIcon` setting in the `AppPresenter` * Provide `showFeedFavIcon` setting as a composition local While it's not recommended to pass a dependency like this, this setting is akin to a theme setting imo where we switch between the source of the feed icon. So doing this approach for simplicity and having to load the setting and make changes at screen level * Add feed icon links to `FeedGroup` * Switch between feed fav icon and feed icon based on show feed fav icon setting * Load show feed fav icon setting in `SettingsPresenter` * Display show feed fav icon setting in `SettingsScreen` --- .../reader/data/repository/RssRepository.kt | 34 ++++++++--- .../data/repository/SettingsRepository.kt | 8 +++ .../src/commonMain/sqldelight/databases/20.db | Bin 0 -> 114688 bytes .../rss/reader/data/database/Feed.sq | 12 ++-- .../rss/reader/data/database/FeedGroup.sq | 12 ++++ .../rss/reader/data/database/Source.sq | 12 ++++ .../commonMain/sqldelight/migrations/19.sqm | 1 + .../rss/reader/core/model/local/Feed.kt | 1 + .../rss/reader/core/model/local/FeedGroup.kt | 1 + .../core/network/parser/AtomContentParser.kt | 9 ++- .../reader/core/network/parser/FeedParser.kt | 6 +- .../core/network/parser/RDFContentParser.kt | 18 +++++- .../core/network/parser/RSSContentParser.kt | 26 +++++++- .../resources/strings/DeTwineStrings.kt | 7 ++- .../resources/strings/EnTwineStrings.kt | 7 ++- .../resources/strings/TrTwineStrings.kt | 9 ++- .../reader/resources/strings/TwineStrings.kt | 2 + .../resources/strings/ZhTwineStrings.kt | 7 ++- .../dev/sasikanth/rss/reader/app/App.kt | 2 + .../sasikanth/rss/reader/app/AppPresenter.kt | 12 +++- .../dev/sasikanth/rss/reader/app/AppState.kt | 11 +++- .../image/{FeedFavIcon.kt => FeedIcon.kt} | 39 +++++++----- .../rss/reader/feed/ui/FeedInfoBottomSheet.kt | 10 ++- .../feeds/ui/BottomSheetCollapsedContent.kt | 1 + .../rss/reader/feeds/ui/FeedBottomBarItem.kt | 11 +++- .../reader/feeds/ui/FeedGroupBottomBarItem.kt | 9 ++- .../rss/reader/feeds/ui/FeedGroupIconGrid.kt | 4 +- .../rss/reader/feeds/ui/FeedGroupItem.kt | 10 ++- .../rss/reader/feeds/ui/FeedListItem.kt | 10 ++- .../rss/reader/home/ui/HomeTopAppBar.kt | 16 +++-- .../rss/reader/settings/SettingsEvent.kt | 2 + .../rss/reader/settings/SettingsPresenter.kt | 22 +++++-- .../rss/reader/settings/SettingsState.kt | 2 + .../rss/reader/settings/ui/SettingsScreen.kt | 57 ++++++++++++++++-- .../utils/LocalShowFeedFavIconSetting.kt | 21 +++++++ 35 files changed, 332 insertions(+), 79 deletions(-) create mode 100644 core/data/src/commonMain/sqldelight/databases/20.db create mode 100644 core/data/src/commonMain/sqldelight/migrations/19.sqm rename shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/{FeedFavIcon.kt => FeedIcon.kt} (66%) create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/LocalShowFeedFavIconSetting.kt diff --git a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt index 4bc1621d9..8492e2dfe 100644 --- a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt +++ b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/RssRepository.kt @@ -241,7 +241,8 @@ class RssRepository( pinnedAt: Instant?, lastCleanUpAt: Instant?, alwaysFetchSourceArticle: Boolean, - pinnedPosition: Double -> + pinnedPosition: Double, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -253,7 +254,8 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, alwaysFetchSourceArticle = alwaysFetchSourceArticle, - pinnedPosition = pinnedPosition + pinnedPosition = pinnedPosition, + showFeedFavIcon = showFeedFavIcon, ) } ) @@ -270,6 +272,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIconLinks: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant?, @@ -279,6 +282,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIconLinks.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -334,7 +338,8 @@ class RssRepository( pinnedAt: Instant?, lastCleanUpAt: Instant?, alwaysFetchSourceArticle: Boolean, - numberOfUnreadPosts: Long -> + numberOfUnreadPosts: Long, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -346,7 +351,8 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, alwaysFetchSourceArticle = alwaysFetchSourceArticle, - numberOfUnreadPosts = numberOfUnreadPosts + numberOfUnreadPosts = numberOfUnreadPosts, + showFeedFavIcon = showFeedFavIcon, ) } ) @@ -372,7 +378,8 @@ class RssRepository( pinnedAt: Instant?, lastCleanUpAt: Instant?, alwaysFetchSourceArticle: Boolean, - numberOfUnreadPosts: Long -> + numberOfUnreadPosts: Long, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -384,7 +391,8 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, alwaysFetchSourceArticle = alwaysFetchSourceArticle, - numberOfUnreadPosts = numberOfUnreadPosts + numberOfUnreadPosts = numberOfUnreadPosts, + showFeedFavIcon = showFeedFavIcon, ) } ) @@ -616,6 +624,7 @@ class RssRepository( numberOfUnreadPosts: Long, feedIds: List?, feedHomepageLinks: String?, + feedIcons: String?, updatedAt: Instant?, pinnedPosition: Double -> if (type == "group") { @@ -625,6 +634,7 @@ class RssRepository( feedIds = feedIds?.filterNot { it.isBlank() }.orEmpty(), feedHomepageLinks = feedHomepageLinks?.split(",")?.filterNot { it.isBlank() }.orEmpty(), + feedIconLinks = feedIcons?.split(",")?.filterNot { it.isBlank() }.orEmpty(), createdAt = createdAt!!, updatedAt = updatedAt!!, pinnedAt = pinnedAt, @@ -680,6 +690,7 @@ class RssRepository( numberOfUnreadPosts: Long, feedIds: List?, feedHomepageLinks: String?, + feedIcons: String?, updatedAt: Instant? -> if (type == "group") { FeedGroup( @@ -688,6 +699,7 @@ class RssRepository( feedIds = feedIds?.filterNot { it.isBlank() }.orEmpty(), feedHomepageLinks = feedHomepageLinks?.split(",")?.filterNot { it.isBlank() }.orEmpty(), + feedIconLinks = feedIcons?.split(",")?.filterNot { it.isBlank() }.orEmpty(), createdAt = createdAt, updatedAt = updatedAt!!, pinnedAt = pinnedAt, @@ -727,6 +739,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIcons: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant?, @@ -736,6 +749,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIcons.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -761,6 +775,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIcons: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant? -> @@ -769,6 +784,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIcons.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -788,6 +804,7 @@ class RssRepository( name: String, feedIds: List, feedHomepageLinks: String, + feedIcons: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant? -> @@ -796,6 +813,7 @@ class RssRepository( name = name, feedIds = feedIds.filterNot { it.isBlank() }, feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, + feedIconLinks = feedIcons.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -830,7 +848,8 @@ class RssRepository( createdAt: Instant, pinnedAt: Instant?, lastCleanUpAt: Instant?, - numberOfUnreadPosts: Long -> + numberOfUnreadPosts: Long, + showFeedFavIcon: Boolean -> Feed( id = id, name = name, @@ -842,6 +861,7 @@ class RssRepository( pinnedAt = pinnedAt, lastCleanUpAt = lastCleanUpAt, numberOfUnreadPosts = numberOfUnreadPosts, + showFeedFavIcon = showFeedFavIcon, ) } ) diff --git a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt index 68a30e7d3..17b2f5b0a 100644 --- a/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt +++ b/core/data/src/commonMain/kotlin/dev/sasikanth/rss/reader/data/repository/SettingsRepository.kt @@ -41,6 +41,7 @@ class SettingsRepository(private val dataStore: DataStore) { private val feedsSortOrderKey = stringPreferencesKey("pref_feeds_sort_order") private val appThemeModeKey = stringPreferencesKey("pref_app_theme_mode_v2") private val enableAutoSyncKey = booleanPreferencesKey("enable_auto_sync") + private val showFeedFavIconKey = booleanPreferencesKey("show_feed_fav_icon") val browserType: Flow = dataStore.data.map { preferences -> @@ -79,6 +80,9 @@ class SettingsRepository(private val dataStore: DataStore) { val enableAutoSync: Flow = dataStore.data.map { preferences -> preferences[enableAutoSyncKey] ?: true } + val showFeedFavIcon: Flow = + dataStore.data.map { preferences -> preferences[showFeedFavIconKey] ?: true } + suspend fun enableAutoSyncImmediate(): Boolean { return enableAutoSync.first() } @@ -123,6 +127,10 @@ class SettingsRepository(private val dataStore: DataStore) { dataStore.edit { preferences -> preferences[enableAutoSyncKey] = value } } + suspend fun toggleShowFeedFavIcon(value: Boolean) { + dataStore.edit { preferences -> preferences[showFeedFavIconKey] = value } + } + private fun mapToAppThemeMode(pref: String?): AppThemeMode? { if (pref.isNullOrBlank()) return null return AppThemeMode.valueOf(pref) diff --git a/core/data/src/commonMain/sqldelight/databases/20.db b/core/data/src/commonMain/sqldelight/databases/20.db new file mode 100644 index 0000000000000000000000000000000000000000..16a9c2340247c221a5aa6605e6b8914e4ac3b30e GIT binary patch literal 114688 zcmeI5%WoUk6^A)`kP;>j*>x=QijP-&4lw9SY;!5b^ z!{5zP=jHFH&*|mwPOL1vy?A~8(ZZ?77vXPb_h;Xj`DW(hnZ)#$Q{PWrllG+3p|66( z+4Xm0;rLrI=|Jl=)knSVK(99yU9C6OL8EThoy=C`OigBmQck|ZM)hLllBj3vG@Rw+ zYBqWM1tEH1i>VK~`~8Mm*P4##Wxwd5UY6)}qrT8g9+uBU5^`+rNm=h}J3DIsfvPs^ z16AoacIyxHK|PbN$(4GcRFx|=YBT4kRSKJ%a)piS9u;G`r8JhNrq0m~d9zTmD=Vz9 zSt?hEN3HCZwyrf(tfREm6l-any(D`#Q{0xTY`vo%Toi5mr_oL|qR-W+T*@83bK0;d zIo6`>tsHm5u`M#Xn-&=#hAgrwZHruJSXHhGW7suD+LGSv_6?catu@U$t!b!#?o=eP z8Jj!&FhKncInR03b8=BWmj0gpoIZ`_K}Dm7EHEqcmGUjOC)|p5TTKpMD5{@~#4~Hs zVNArNFm|Wk-S5?VTBpNfh=;bOYuygDY*NDsPk06ou(T3`)(vS}rm&>4%oAZv`Y2#h zMYpG0ddhTsWidn2JyAwa-a8?3?GtB;5f&}_r4RM8qz_wgb1588#-tsectiJvK*5f} zrbw=9VKE%P5R()~7$t(SBoK+HUz*dw)=(tRllDR+aV0kQ>8crcVr^=4JG$D@>-#-g zjHt?#Xe|IEdJSbsp6Qz2;(1I9VPES}P^6gFR(90wev4Og!70{Pl_n`f@+jg;B2n&l zyL)Y=zo+snxyI7Q;t^%cmuyx?k!fN+Gp+n{n++qiv2xL)oHan`+32?09LS)^Ihzgc zD$@`n&B-RNY(S!?qmjfVzVce@lpwj^u`E%WLA2d{e5{?}PosI+T#DUkZ;cMn`RKj* zNa8|l?(=DPj97z$HFkRZ1brRvTRTdHRphN=CTs2<)+}P{uy(lAv7(PMAjQ0nw8W`= zBO*_x#G+UdTN33~f#*h&ySK{M(kx%zUHg(VIn|mWNJUT3i4k_BiDnUER5BEzl2<9J zaVtij9p%gok+i$0arL-%_G)gr)MyjwL}M*Y#gcxs5{blPG3m2qUAf;9*-kMyXQg~* zqbQ36UFU-N##NK=)L5y^zqgA;wpA(I%2e*MoATWhZ?`0Sdr^y#o}R%}?PB8}(^n3% z#!@8OsK2#>*cIZq#Lp2j9ZW2>s3*bni*$oET61zfvt2aIp>YmqI-YpM@*ULZdzm_l z#cO;Q+IIO-hv+z`FCM>neh4UK`N4)9!y`9IX=|orHdD=Ja&k0zel8rp8k0g|nIF90 z;-}-fvaff=XWD7?dYb;8e=;>2iLb3mM^)iLk|}mKyAbyH(yl&^L&c%R=QR>hXbwi; zuI|fIXXLS zME>hGH)@>m(faYAC?EZl#s>Z9xZUlx)gIqGi=(4@EcQ`gTMFOQTdLC8?sMffu-gDgyth(gic&v{@Y^z#LB|ki`VBLEu4yc5&m{|fA)=; zZ)QH8Nlbq^_5IW}X-_&G`s%49?#9CLw_?(P)@iDbdfkCuZz{T4Z>obv-L5;Ct;m_0 z%nGHPe1{G9`d`dJ+Ocv;%+1zGMV6DR+2rjPg!%(p)%u{j-*2dOt?8)0>{oxNmnC}L zs4p~=hvhSogdCfDQr7$0&W_rDpsLOKKvnvU-TDK4P|xIRa;07mBQwx zTw&w7N5xo9lg852)H%8#Zx%{+WrY*0pAeb(FT6VlAz+mt^l|iraFP zt#{Oei=vJHG}@^~^tl?9OS!{$P8${_$6B<#mE*2HwnavF(<0-;kVRIdZIKHNtI9QD z47PA;WtI6RDMfH=BcxFvHjER^O#_sgH`@MQk>vVVw@$}Jjt=plNO=>vd z3D4jGmR4fWx*=`L6qZz$c_OSy9|cUR==O9=Pnk~9M@^K`llM-DT>HeCVuVGDe(6KK zEa}4*+*}IBlQC(>C*II~AyBZRuql%3T38InFT^Cp5k`q%ED1y+>X+tpur(CP^Q65H zNnDA|eY$D}o>>1H-Hxty^!k2}79*-MC0Yx>h+ackl4rW6w|E}YV%yhx6cj0@wUr%p zyWir~TyTo@Ri#M^kvxjHl1P;M-R@pn>F=pLORllBv3Nuo^Cg?rQDmB!&rB=-+-Ac_ zZLD1MC}#~2dN#W4HU}~&a?WOhyUH}gNOQ7@D;tpL>1ZTziLboYIweT%cPvZPW)N+6 zA0KOH_|s@!HkV>|+FPRobUu1-K9aZ)oBMp)9V6DDV2zy~KS5u|`__(9VHJ6+n8}*E zhc%1XI;~>5Ip2o*x2AS$?n~$MDEaQrem+ znaxzQnVcL=o}UZHug0X1Smp=sw={lqRrd9+_)I&kUQg5C^G~K`Bk{F0>8L6^NHWDX zY8S#DU)t5jai}=7_`F6U3eCX?yc~+IHZ!`99T7A3lb}{(D|Lp_2=76FlgkusCp+G_ zcsZI(hr@AsEr^zFb3JVrBS&YajmUr9=0=S(K3YE>6y>9z(%7IM9k;vPw%X&HXK{2? zkHtO;Y)j#rdP`M0+dVIFrFEb@9^_TMv0D|ljZ9zH8ZFi5PzV1HaTN6(>~;_G9CTiJ zNXu!kKk|q0AH(Nn|1$gL%%5l8p8k6J*Hd3lU6=kU-QdIiFTeV~jKtGv z>Co9)ty?ya`v=;NSj5eHw^8$oCr;1%JDgwN^K828VUpc0)NYiwYphbfUC2FrEfT+S zMLIlVZ|ByW&2D3$eIR!Dp{hjitmdHee3!Bl)xGJn!3S)kSl&o}^r|uMS>L?eGP+I- z*+Nw>2hVE`3ZA!dnV;R#{QFn@`%3a~=4>Q>;evE{-8aA1G#>t#p!vs_g6HL}=hp}7 z4n1wq)qXq0dOMwN%FW!}oE)OxW0ClqZ;qs|=!zQwKe#9_2J1o1!N%WjbsKvI_rry? zNc^?e<__z_hP!4n=XtkKscrKuv}Pab%s^n<)k0~LcugY z+PiydhX>TPSLtHVSK6;ell@<~BVMe}p7{3H#Jvps9iATXtL}(*3owIsMAb_0tjCV{ z$FCUkz9hiACozL}L{%(!-X9S0JlYfPXm?M<19<4;3&C^#)M$U%9qsOEDSU@Np9$9E z*wOysOGdC8S?-#_RWzsZ2D__$WU$jg_i4d>*nLqvWjtDT--H96n^N{!2Aj`KT?y%X z%xd0|ty`|~Ol0hHQSQVLr(=f z7W*FcxQzW#PkREMZ5*Ck@jiHU%_Cgod60Sx13mry4_-;ah$k?vS>1}d9=!exuQKqO z!FtCb(nTaZUNcxs{LxM;=FkG>T15T*{n1X{M?||zNaa(58Xc z45QyCK7+3r=>9*o{IAgRcl :postsAfter GROUP BY f.id @@ -80,7 +82,8 @@ SELECT f.pinnedAt, f.lastCleanUpAt, f.alwaysFetchSourceArticle, - COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts + COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, + f.showFeedFavIcon FROM feed f LEFT JOIN post p ON f.id = p.sourceId AND p.date > :postsAfter WHERE f.id = :id @@ -122,7 +125,8 @@ SELECT f.createdAt, f.pinnedAt, f.lastCleanUpAt, - 0 AS numberOfUnreadPosts + 0 AS numberOfUnreadPosts, + f.showFeedFavIcon FROM feed f WHERE f.id IN :feedIds ORDER BY diff --git a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq index 2a4fb2420..fdf6fb7ab 100644 --- a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq +++ b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/FeedGroup.sq @@ -30,6 +30,10 @@ SELECT FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(feedGroup.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, createdAt, updatedAt, pinnedAt, @@ -46,6 +50,10 @@ SELECT FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(feedGroup.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, createdAt, updatedAt, pinnedAt, @@ -83,6 +91,10 @@ SELECT FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(feedGroup.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, createdAt, updatedAt, pinnedAt diff --git a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq index 3b16d5628..f72e74521 100644 --- a/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq +++ b/core/data/src/commonMain/sqldelight/dev/sasikanth/rss/reader/data/database/Source.sq @@ -22,6 +22,7 @@ SELECT lastCleanUpAt , numberOfUnreadPosts, feedIds, + feedHomepageLinks, feedIcons, updatedAt, pinnedPosition @@ -37,6 +38,7 @@ FROM ( f.lastCleanUpAt, COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, NULL AS feedIds, + NULL AS feedHomepageLinks, NULL AS feedIcons, f.createdAt, f.pinnedAt, @@ -63,6 +65,10 @@ FROM ( FROM feed WHERE INSTR(fg.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(fg.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, fg.createdAt, fg.pinnedAt, fg.updatedAt, @@ -88,6 +94,7 @@ SELECT lastCleanUpAt , numberOfUnreadPosts, feedIds, + feedHomepageLinks, feedIcons, updatedAt FROM ( @@ -102,6 +109,7 @@ FROM ( f.lastCleanUpAt, COUNT(CASE WHEN p.read = 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, NULL AS feedIds, + NULL AS feedHomepageLinks, NULL AS feedIcons, f.createdAt, f.pinnedAt, @@ -127,6 +135,10 @@ FROM ( FROM feed WHERE INSTR(fg.feedIds, feed.id) LIMIT 4), '') AS feedHomepageLinks, + COALESCE((SELECT GROUP_CONCAT(feed.icon) + FROM feed + WHERE INSTR(fg.feedIds, feed.id) + LIMIT 4), '') AS feedIconLinks, fg.createdAt, fg.pinnedAt, fg.updatedAt diff --git a/core/data/src/commonMain/sqldelight/migrations/19.sqm b/core/data/src/commonMain/sqldelight/migrations/19.sqm new file mode 100644 index 000000000..87fd2b965 --- /dev/null +++ b/core/data/src/commonMain/sqldelight/migrations/19.sqm @@ -0,0 +1 @@ +ALTER TABLE feed ADD COLUMN showFeedFavIcon INTEGER NOT NULL DEFAULT 1; diff --git a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt index 6dcd5d487..ba7518edc 100644 --- a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt +++ b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/Feed.kt @@ -33,4 +33,5 @@ data class Feed( val alwaysFetchSourceArticle: Boolean = false, override val sourceType: SourceType = SourceType.Feed, override val pinnedPosition: Double = 0.0, + val showFeedFavIcon: Boolean = true, ) : Source diff --git a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt index 58a8f74bf..2d17d6e0f 100644 --- a/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt +++ b/core/model/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/model/local/FeedGroup.kt @@ -23,6 +23,7 @@ data class FeedGroup( val name: String, val feedIds: List, val feedHomepageLinks: List, + val feedIconLinks: List, val numberOfUnreadPosts: Long = 0, val createdAt: Instant, val updatedAt: Instant, diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt index cb3f1033c..e9ed092a1 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/AtomContentParser.kt @@ -24,6 +24,7 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.ATTR_VA 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_CONTENT +import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_ICON 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 @@ -47,6 +48,7 @@ internal object AtomContentParser : ContentParser() { var title: String? = null var description: String? = null var link: String? = null + var iconUrl: String? = null while (parser.next() != EventType.END_TAG) { if (parser.eventType != EventType.START_TAG) continue @@ -68,12 +70,17 @@ internal object AtomContentParser : ContentParser() { val host = UrlUtils.extractHost(link ?: feedUrl) posts.add(readAtomEntry(parser, host)) } + TAG_ICON -> { + iconUrl = parser.nextText() + } else -> parser.skip() } } val host = UrlUtils.extractHost(link ?: feedUrl) - val iconUrl = FeedParser.feedIcon(host) + if (iconUrl.isNullOrBlank()) { + iconUrl = FeedParser.fallbackFeedIcon(host) + } return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), 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 a125fb792..8b4f2c61e 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 @@ -19,7 +19,6 @@ import co.touchlab.kermit.Logger import dev.sasikanth.rss.reader.core.model.remote.FeedPayload import dev.sasikanth.rss.reader.exceptions.XmlParsingError import dev.sasikanth.rss.reader.util.DispatchersProvider -import io.ktor.http.set import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readRemaining import korlibs.io.lang.Charset @@ -99,20 +98,21 @@ class FeedParser(private val dispatchersProvider: DispatchersProvider) { internal const val TAG_UPDATED = "updated" internal const val TAG_FEATURED_IMAGE = "featuredImage" internal const val TAG_COMMENTS = "comments" - internal const val TAG_IMAGE_URL = "imageUrl" internal const val TAG_FEED_IMAGE = "image" + internal const val TAG_ICON = "icon" internal const val ATTR_URL = "url" internal const val ATTR_TYPE = "type" internal const val ATTR_REL = "rel" internal const val ATTR_HREF = "href" + internal const val ATTR_RDF_RESOURCE = "rdf:resource" internal const val ATTR_VALUE_ALTERNATE = "alternate" internal const val ATTR_VALUE_IMAGE = "image/jpeg" fun cleanText(text: String?) = text?.replace(htmlTag, "")?.replace(blankLine, "")?.trim() - fun feedIcon(host: String): String { + fun fallbackFeedIcon(host: String): String { return "https://icon.horse/icon/$host" } } diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt index f5979cf26..de8dc8862 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RDFContentParser.kt @@ -18,9 +18,11 @@ 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.ATTR_RDF_RESOURCE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_CONTENT_ENCODED import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_DC_DATE import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_DESCRIPTION +import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_FEED_IMAGE 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_RSS_CHANNEL @@ -44,6 +46,7 @@ internal object RDFContentParser : ContentParser() { var title: String? = null var link: String? = null var description: String? = null + var iconUrl: String? = null // Parse channel while (parser.next() != EventType.END_TAG) { @@ -63,6 +66,9 @@ internal object RDFContentParser : ContentParser() { TAG_DESCRIPTION -> { description = parser.nextText() } + TAG_FEED_IMAGE -> { + iconUrl = readFeedIcon(parser) + } else -> parser.skip() } } @@ -80,7 +86,9 @@ internal object RDFContentParser : ContentParser() { } val host = UrlUtils.extractHost(link ?: feedUrl) - val iconUrl = FeedParser.feedIcon(host) + if (iconUrl.isNullOrBlank()) { + iconUrl = FeedParser.fallbackFeedIcon(host) + } return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), @@ -92,6 +100,14 @@ internal object RDFContentParser : ContentParser() { ) } + private fun readFeedIcon(parser: XmlPullParser): String? { + parser.require(EventType.START_TAG, parser.namespace, TAG_FEED_IMAGE) + val link = parser.getAttributeValue(parser.namespace, ATTR_RDF_RESOURCE) + parser.nextTag() + parser.require(EventType.END_TAG, parser.namespace, TAG_FEED_IMAGE) + return link + } + private fun readRssItem(parser: XmlPullParser, hostLink: String?): PostPayload? { parser.require(EventType.START_TAG, parser.namespace, TAG_RSS_ITEM) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt index d760c9c9c..ea3f32bbb 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RSSContentParser.kt @@ -26,6 +26,7 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_CON import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_DESCRIPTION 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_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_RSS_CHANNEL @@ -50,6 +51,7 @@ internal object RSSContentParser : ContentParser() { var title: String? = null var link: String? = null var description: String? = null + var iconUrl: String? = null while (parser.next() != EventType.END_TAG) { if (parser.eventType != EventType.START_TAG) continue @@ -72,12 +74,17 @@ internal object RSSContentParser : ContentParser() { val host = UrlUtils.extractHost(link ?: feedUrl) posts.add(readRssItem(parser, host)) } + TAG_FEED_IMAGE -> { + iconUrl = readFeedIcon(parser) + } else -> parser.skip() } } val host = UrlUtils.extractHost(link ?: feedUrl) - val iconUrl = FeedParser.feedIcon(host) + if (iconUrl.isNullOrBlank()) { + iconUrl = FeedParser.fallbackFeedIcon(host) + } return FeedPayload( name = FeedParser.cleanText(title ?: link)!!.decodeHTMLString(), @@ -89,6 +96,23 @@ internal object RSSContentParser : ContentParser() { ) } + private fun readFeedIcon(parser: XmlPullParser): String? { + parser.require(EventType.START_TAG, parser.namespace, TAG_FEED_IMAGE) + + var imageUrl: String? = null + + while (parser.next() != EventType.END_TAG) { + if (parser.eventType != EventType.START_TAG) continue + if (parser.name == TAG_URL) { + imageUrl = parser.nextText() + } else { + parser.skip() + } + } + + return imageUrl + } + private fun readRssItem(parser: XmlPullParser, hostLink: String?): PostPayload? { parser.require(EventType.START_TAG, parser.namespace, TAG_RSS_ITEM) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt index cf10353be..627ed1293 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/DeTwineStrings.kt @@ -53,8 +53,8 @@ val DeTwineStrings = settings = "Einstellungen", moreMenuOptions = "Weitere Menüoptionen", settingsHeaderBehaviour = "Verhalten", - settingsHeaderFeedback = "Feedback und Fehlerberichte", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "Feedback und Fehlerberichte", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "Verwende den In-App-Browser", settingsBrowserTypeSubtitle = @@ -99,8 +99,8 @@ val DeTwineStrings = feedsSearchHint = "Filter", allFeeds = "Feeds", pinnedFeeds = "Angepinnt", - openWebsite = "Website öffnen", markAllAsRead = "Alle als gelesen markieren", + openWebsite = "Website öffnen", noNewPosts = "Keine neuen Beiträge", noNewPostsSubtitle = "Schauen Sie später noch einmal vorbei oder ziehen Sie nach unten, um nach neuen Inhalten zu suchen", @@ -171,4 +171,7 @@ val DeTwineStrings = cdLoadFullArticle = "Load full article", enableAutoSyncTitle = "Enable auto sync", enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 552bea253..da115dcc9 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -64,8 +64,8 @@ val EnTwineStrings = settings = "Settings", moreMenuOptions = "More menu options", settingsHeaderBehaviour = "Behavior", - settingsHeaderFeedback = "Feedback & bug reports", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "Feedback & bug reports", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "Use in-app browser", settingsBrowserTypeSubtitle = "When turned off, links will open in your default browser.", @@ -107,8 +107,8 @@ val EnTwineStrings = feedsSearchHint = "Filter", allFeeds = "Feeds", pinnedFeeds = "Pinned", - openWebsite = "Open Website", markAllAsRead = "Mark All as Read", + openWebsite = "Open Website", noNewPosts = "No new content", noNewPostsSubtitle = "Check back later or pull down to check for new content now", postsAll = "All articles", @@ -178,4 +178,7 @@ val EnTwineStrings = cdLoadFullArticle = "Load full article", enableAutoSyncTitle = "Enable auto sync", enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt index f0e11e693..be379fcc9 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TrTwineStrings.kt @@ -50,8 +50,8 @@ val TrTwineStrings = settings = "Ayarlar", moreMenuOptions = "Daha fazla menü seçeneği", settingsHeaderBehaviour = "Davranış", - settingsHeaderFeedback = "Geri bildirim & Hata raporları", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "Geri bildirim & Hata raporları", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "Uygulama içi tarayıcıyı kullan", settingsBrowserTypeSubtitle = @@ -95,8 +95,8 @@ val TrTwineStrings = feedsSearchHint = "Filtre", allFeeds = "Yayınlar", pinnedFeeds = "Sabitlenmiş", - openWebsite = "Web sitesini aç", markAllAsRead = "Tümünü okundu olarak işaretle", + openWebsite = "Web sitesini aç", noNewPosts = "Yeni gönderi yok", noNewPostsSubtitle = "Daha sonra tekrar kontrol edin veya yeni içeriği şimdi kontrol etmek için aşağı çekin", @@ -140,9 +140,9 @@ val TrTwineStrings = feedsBottomBarNewFeed = "Yeni Yayın", actionPin = "Sabitle", actionUnpin = "Sabitlemeyi Kaldır", + actionDelete = "Sil", actionAddTo = "Şuraya ekle", actionMoveTo = "Şuraya taşı", - actionDelete = "Sil", actionUngroup = "Gruplandırılmamış", createGroup = "Grup oluştur", createFeed = "İçerik ekle", @@ -167,4 +167,7 @@ val TrTwineStrings = cdLoadFullArticle = "Load full article", enableAutoSyncTitle = "Enable auto sync", enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index c6eb4c02b..449befe5b 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -158,6 +158,8 @@ data class TwineStrings( val cdLoadFullArticle: String, val enableAutoSyncTitle: String, val enableAutoSyncDesc: String, + val showFeedFavIconTitle: String, + val showFeedFavIconDesc: String, ) object Locales { diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt index 69be89ebd..41180d665 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/ZhTwineStrings.kt @@ -47,8 +47,8 @@ val ZhTwineStrings = settings = "设置", moreMenuOptions = "更多选项", settingsHeaderBehaviour = "行为", - settingsHeaderFeedback = "反馈与错误报告", settingsHeaderOpml = "OPML", + settingsHeaderFeedback = "反馈与错误报告", settingsHeaderTheme = "Theme", settingsBrowserTypeTitle = "使用内置浏览器", settingsBrowserTypeSubtitle = "如果禁用,链接将在默认浏览器中打开。", @@ -89,8 +89,8 @@ val ZhTwineStrings = feedsSearchHint = "筛选", allFeeds = "所有订阅", pinnedFeeds = "已置顶的订阅", - openWebsite = "打开网站", markAllAsRead = "全部标记为已读", + openWebsite = "打开网站", noNewPosts = "暂无新内容", noNewPostsSubtitle = "请稍后检查,或下拉以检查是否有新的内容。", postsAll = "所有文章", @@ -158,4 +158,7 @@ val ZhTwineStrings = cdLoadFullArticle = "Load full article", enableAutoSyncTitle = "Enable auto sync", enableAutoSyncDesc = "When turned-on, feeds will be updated in the background", + showFeedFavIconTitle = "Show feed fav icon", + showFeedFavIconDesc = + "When turned-off, the feed icon will be displayed instead of the website's favicon" ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt index e80776cd2..4c9a84655 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt @@ -58,6 +58,7 @@ import dev.sasikanth.rss.reader.ui.darkAppColorScheme import dev.sasikanth.rss.reader.ui.lightAppColorScheme import dev.sasikanth.rss.reader.ui.rememberDynamicColorState import dev.sasikanth.rss.reader.util.DispatchersProvider +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting import dev.sasikanth.rss.reader.utils.LocalWindowSizeClass import me.tatarka.inject.annotations.Assisted import me.tatarka.inject.annotations.Inject @@ -89,6 +90,7 @@ fun App( LocalShareHandler provides shareHandler, LocalLinkHandler provides linkHandler, LocalDynamicColorState provides dynamicColorState, + LocalShowFeedFavIconSetting provides appState.showFeedFavIcon ) { val isSystemInDarkTheme = isSystemInDarkTheme() val useDarkTheme = diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt index 78f24e586..3c8c6078d 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt @@ -59,6 +59,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn @@ -288,8 +289,15 @@ class AppPresenter( ) init { - settingsRepository.appThemeMode - .onEach { appThemeMode -> _state.update { it.copy(appThemeMode = appThemeMode) } } + combine( + settingsRepository.appThemeMode, + settingsRepository.showFeedFavIcon, + ) { appThemeMode, showFeedFavIcon -> + Pair(appThemeMode, showFeedFavIcon) + } + .onEach { (appThemeMode, showFeedFavIcon) -> + _state.update { it.copy(appThemeMode = appThemeMode, showFeedFavIcon = showFeedFavIcon) } + } .launchIn(coroutineScope) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt index 83795635b..5b1d6e66f 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppState.kt @@ -18,9 +18,16 @@ package dev.sasikanth.rss.reader.app import dev.sasikanth.rss.reader.data.repository.AppThemeMode -data class AppState(val appThemeMode: AppThemeMode) { +data class AppState( + val appThemeMode: AppThemeMode, + val showFeedFavIcon: Boolean, +) { companion object { - val DEFAULT = AppState(appThemeMode = AppThemeMode.Auto) + val DEFAULT = + AppState( + appThemeMode = AppThemeMode.Auto, + showFeedFavIcon = true, + ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedIcon.kt similarity index 66% rename from shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt rename to shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedIcon.kt index 6d172a0f8..54fe3525b 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedIcon.kt @@ -18,7 +18,6 @@ package dev.sasikanth.rss.reader.components.image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.RssFeed import androidx.compose.material.icons.rounded.RssFeed import androidx.compose.material3.Icon import androidx.compose.runtime.Composable @@ -32,29 +31,41 @@ import coil3.size.Dimension import coil3.size.Size import dev.sasikanth.rss.reader.favicons.FavIconImageLoader import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @Composable -internal fun FeedFavIcon( +internal fun FeedIcon( url: String, contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, size: Size = Size(Dimension.Undefined, 500) ) { + val showFeedFavIcon = LocalShowFeedFavIconSetting.current Box(modifier.background(Color.White)) { - val context = LocalPlatformContext.current - val imageRequest = ImageRequest.Builder(context).data(url).diskCacheKey(url).size(size).build() - val imageLoader = FavIconImageLoader.get(context) + if (showFeedFavIcon) { + val context = LocalPlatformContext.current + val imageRequest = + ImageRequest.Builder(context).data(url).diskCacheKey(url).size(size).build() + val imageLoader = FavIconImageLoader.get(context) - SubcomposeAsyncImage( - model = imageRequest, - contentDescription = contentDescription, - modifier = Modifier.matchParentSize(), - contentScale = contentScale, - imageLoader = imageLoader, - error = { PlaceHolderIcon() }, - loading = { PlaceHolderIcon() } - ) + SubcomposeAsyncImage( + model = imageRequest, + contentDescription = contentDescription, + modifier = Modifier.matchParentSize(), + contentScale = contentScale, + imageLoader = imageLoader, + error = { PlaceHolderIcon() }, + loading = { PlaceHolderIcon() } + ) + } else { + AsyncImage( + modifier = Modifier.matchParentSize(), + url = url, + contentDescription = contentDescription, + backgroundColor = null, + ) + } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt index acf8f9ce5..5d4c8baf7 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feed/ui/FeedInfoBottomSheet.kt @@ -76,7 +76,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.sasikanth.rss.reader.components.ConfirmFeedDeleteDialog import dev.sasikanth.rss.reader.components.Switch -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.feed.FeedEffect import dev.sasikanth.rss.reader.feed.FeedEvent @@ -91,6 +91,7 @@ import dev.sasikanth.rss.reader.share.LocalShareHandler import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.ui.SYSTEM_SCRIM import dev.sasikanth.rss.reader.utils.KeyboardState +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay @@ -245,8 +246,11 @@ private fun FeedLabelInput( .padding(8.dp) .fillMaxWidth() ) { - FeedFavIcon( - url = feed.homepageLink, + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val feedIcon = if (showFeedFavIcon) feed.homepageLink else feed.icon + + FeedIcon( + url = feedIcon, contentDescription = feed.name, modifier = Modifier.requiredSize(56.dp).clip(RoundedCornerShape(16.dp)), ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt index 59fe7b5cd..14e204a7c 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/BottomSheetCollapsedContent.kt @@ -97,6 +97,7 @@ internal fun BottomSheetCollapsedContent( FeedBottomBarItem( badgeCount = source.numberOfUnreadPosts, homePageUrl = source.homepageLink, + feedIconUrl = source.icon, canShowUnreadPostsCount = canShowUnreadPostsCount, onClick = { onSourceClick(source) }, selected = activeSource?.id == source.id diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt index 3e5641d20..6c89f606e 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedBottomBarItem.kt @@ -35,14 +35,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.utils.Constants.BADGE_COUNT_TRIM_LIMIT +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @Composable internal fun FeedBottomBarItem( badgeCount: Long, homePageUrl: String, + feedIconUrl: String, canShowUnreadPostsCount: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -51,8 +53,11 @@ internal fun FeedBottomBarItem( Box(modifier = modifier) { Box(contentAlignment = Alignment.Center) { SelectionIndicator(selected = selected, animationProgress = 1f) - FeedFavIcon( - url = homePageUrl, + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val feedIcon = if (showFeedFavIcon) homePageUrl else feedIconUrl + + FeedIcon( + url = feedIcon, contentDescription = null, modifier = Modifier.requiredSize(56.dp).clip(RoundedCornerShape(16.dp)).clickable(onClick = onClick) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt index 7b9f0d285..eea52b091 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupBottomBarItem.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import dev.sasikanth.rss.reader.core.model.local.FeedGroup import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.utils.Constants.BADGE_COUNT_TRIM_LIMIT +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @Composable internal fun FeedGroupBottomBarItem( @@ -57,22 +58,24 @@ internal fun FeedGroupBottomBarItem( .padding(8.dp), contentAlignment = Alignment.Center ) { + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val icons = if (showFeedFavIcon) feedGroup.feedHomepageLinks else feedGroup.feedIconLinks val iconSize = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 18.dp } else { 20.dp } val iconSpacing = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 4.dp } else { 0.dp } FeedGroupIconGrid( - icons = feedGroup.feedHomepageLinks, + icons = icons, iconSize = iconSize, iconShape = CircleShape, verticalArrangement = Arrangement.spacedBy(iconSpacing), diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt index 9afda1191..3b077ade7 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupIconGrid.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.ui.AppTheme @Composable @@ -103,7 +103,7 @@ internal fun FeedGroupIconGrid( @Composable private fun FeedIcon(icon: String?, iconSize: Dp, iconShape: Shape, modifier: Modifier = Modifier) { if (!icon.isNullOrBlank()) { - FeedFavIcon( + FeedIcon( url = icon, contentDescription = null, modifier = diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt index 13229567c..ff3e78c91 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedGroupItem.kt @@ -51,6 +51,7 @@ import dev.sasikanth.rss.reader.resources.icons.RadioUnselected import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @OptIn(ExperimentalFoundationApi::class) @Composable @@ -98,15 +99,18 @@ internal fun FeedGroupItem( .padding(8.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val icons = if (showFeedFavIcon) feedGroup.feedHomepageLinks else feedGroup.feedIconLinks + val iconSize = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 17.dp } else { 19.dp } val iconSpacing = - if (feedGroup.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 2.dp } else { 0.dp @@ -114,7 +118,7 @@ internal fun FeedGroupItem( FeedGroupIconGrid( modifier = Modifier.requiredSize(36.dp), - icons = feedGroup.feedHomepageLinks, + icons = icons, iconSize = iconSize, iconShape = CircleShape, verticalArrangement = Arrangement.spacedBy(iconSpacing), diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt index 03b118bff..b6ca0e891 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/feeds/ui/FeedListItem.kt @@ -43,12 +43,13 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.resources.icons.RadioSelected import dev.sasikanth.rss.reader.resources.icons.RadioUnselected import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting @OptIn(ExperimentalFoundationApi::class) @Composable @@ -95,8 +96,11 @@ internal fun FeedListItem( ) ) { Row(modifier = Modifier.padding(all = 8.dp), verticalAlignment = Alignment.CenterVertically) { - FeedFavIcon( - url = feed.homepageLink, + val showFeedFavIcon = LocalShowFeedFavIconSetting.current + val icon = if (showFeedFavIcon) feed.homepageLink else feed.icon + + FeedIcon( + url = icon, contentDescription = null, modifier = Modifier.requiredSize(36.dp).clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt index 4ad8a0d23..fe298f868 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/HomeTopAppBar.kt @@ -62,7 +62,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import dev.sasikanth.rss.reader.components.DropdownMenu import dev.sasikanth.rss.reader.components.DropdownMenuItem -import dev.sasikanth.rss.reader.components.image.FeedFavIcon +import dev.sasikanth.rss.reader.components.image.FeedIcon import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.core.model.local.FeedGroup import dev.sasikanth.rss.reader.core.model.local.PostsType @@ -73,6 +73,7 @@ import dev.sasikanth.rss.reader.resources.icons.Tune import dev.sasikanth.rss.reader.resources.icons.TwineIcons import dev.sasikanth.rss.reader.resources.strings.LocalStrings import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.LocalShowFeedFavIconSetting private const val APP_BAR_OPAQUE_THRESHOLD = 200f @@ -189,24 +190,26 @@ private fun SourceIcon(source: Source?, modifier: Modifier = Modifier) { Spacer(Modifier.requiredWidth(16.dp)) } + val showFeedFavIcon = LocalShowFeedFavIconSetting.current when (source) { is FeedGroup -> { + val icons = if (showFeedFavIcon) source.feedHomepageLinks else source.feedIconLinks val iconSize = - if (source.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 18.dp } else { 20.dp } val iconSpacing = - if (source.feedHomepageLinks.size > 2) { + if (icons.size > 2) { 4.dp } else { 0.dp } FeedGroupIconGrid( - icons = source.feedHomepageLinks, + icons = icons, iconSize = iconSize, iconShape = RoundedCornerShape(percent = 30), horizontalArrangement = Arrangement.spacedBy(iconSpacing), @@ -214,8 +217,9 @@ private fun SourceIcon(source: Source?, modifier: Modifier = Modifier) { ) } is Feed -> { - FeedFavIcon( - url = source.homepageLink, + val icon = if (showFeedFavIcon) source.homepageLink else source.icon + FeedIcon( + url = icon, contentDescription = null, modifier = Modifier.clip(MaterialTheme.shapes.small).requiredSize(24.dp) ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt index 862a96772..44d7c51dd 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsEvent.kt @@ -31,6 +31,8 @@ sealed interface SettingsEvent { data class ToggleAutoSync(val value: Boolean) : SettingsEvent + data class ToggleShowFeedFavIcon(val value: Boolean) : SettingsEvent + data object AboutClicked : SettingsEvent data object ImportOpmlClicked : SettingsEvent diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt index 5ce982ecf..4a2ef8c59 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsPresenter.kt @@ -111,7 +111,7 @@ class SettingsPresenter( settingsRepository.showReaderView, settingsRepository.appThemeMode, settingsRepository.enableAutoSync, - rssRepository.hasFeeds() + settingsRepository.showFeedFavIcon ) { browserType, showUnreadPostsCount, @@ -119,15 +119,15 @@ class SettingsPresenter( showReaderView, appThemeMode, enableAutoSync, - hasFeeds -> + showFeedFavIcon -> Settings( browserType = browserType, showUnreadPostsCount = showUnreadPostsCount, - hasFeeds = hasFeeds, postsDeletionPeriod = postsDeletionPeriod, showReaderView = showReaderView, appThemeMode = appThemeMode, enableAutoSync = enableAutoSync, + showFeedFavIcon = showFeedFavIcon, ) } .onEach { settings -> @@ -135,16 +135,21 @@ class SettingsPresenter( it.copy( browserType = settings.browserType, showUnreadPostsCount = settings.showUnreadPostsCount, - hasFeeds = settings.hasFeeds, postsDeletionPeriod = settings.postsDeletionPeriod, showReaderView = settings.showReaderView, appThemeMode = settings.appThemeMode, - enableAutoSync = settings.enableAutoSync + enableAutoSync = settings.enableAutoSync, + showFeedFavIcon = settings.showFeedFavIcon, ) } } .launchIn(coroutineScope) + rssRepository + .hasFeeds() + .onEach { hasFeeds -> _state.update { it.copy(hasFeeds = hasFeeds) } } + .launchIn(coroutineScope) + opmlManager.result .onEach { result -> _state.update { it.copy(opmlResult = result) } } .launchIn(coroutineScope) @@ -159,6 +164,7 @@ class SettingsPresenter( is SettingsEvent.ToggleShowUnreadPostsCount -> toggleShowUnreadPostsCount(event.value) is SettingsEvent.ToggleShowReaderView -> toggleShowReaderView(event.value) is SettingsEvent.ToggleAutoSync -> toggleAutoSync(event.value) + is SettingsEvent.ToggleShowFeedFavIcon -> toggleShowFeedFavIcon(event.value) SettingsEvent.AboutClicked -> { // no-op } @@ -170,6 +176,10 @@ class SettingsPresenter( } } + private fun toggleShowFeedFavIcon(value: Boolean) { + coroutineScope.launch { settingsRepository.toggleShowFeedFavIcon(value) } + } + private fun toggleAutoSync(value: Boolean) { coroutineScope.launch { settingsRepository.toggleAutoSync(value) } } @@ -215,9 +225,9 @@ class SettingsPresenter( private data class Settings( val browserType: BrowserType, val showUnreadPostsCount: Boolean, - val hasFeeds: Boolean, val postsDeletionPeriod: Period, val showReaderView: Boolean, val appThemeMode: AppThemeMode, val enableAutoSync: Boolean, + val showFeedFavIcon: Boolean, ) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt index 7e45293c3..68a40b4bb 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/SettingsState.kt @@ -33,6 +33,7 @@ internal data class SettingsState( val showReaderView: Boolean, val appThemeMode: AppThemeMode, val enableAutoSync: Boolean, + val showFeedFavIcon: Boolean, ) { companion object { @@ -48,6 +49,7 @@ internal data class SettingsState( showReaderView = false, appThemeMode = AppThemeMode.Auto, enableAutoSync = true, + showFeedFavIcon = true, ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt index e4d95d9b2..254f57e32 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/settings/ui/SettingsScreen.kt @@ -17,7 +17,6 @@ package dev.sasikanth.rss.reader.settings.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -100,11 +99,6 @@ internal fun SettingsScreen( val state by settingsPresenter.state.collectAsState() val layoutDirection = LocalLayoutDirection.current val linkHandler = LocalLinkHandler.current - val isSystemInDarkMode = - when (state.appThemeMode) { - AppThemeMode.Dark -> true - else -> isSystemInDarkTheme() - } Scaffold( modifier = modifier, @@ -232,6 +226,17 @@ internal fun SettingsScreen( item { Divider(24.dp) } + item { + ShowFeedFavIconSettingItem( + showFeedFavIcon = state.showFeedFavIcon, + onValueChanged = { newValue -> + settingsPresenter.dispatch(SettingsEvent.ToggleShowFeedFavIcon(newValue)) + } + ) + } + + item { Divider(24.dp) } + item { PostsDeletionPeriodSettingItem( postsDeletionPeriod = state.postsDeletionPeriod, @@ -410,6 +415,46 @@ private fun PostsDeletionPeriodSettingItem( } } +@Composable +private fun ShowFeedFavIconSettingItem( + showFeedFavIcon: Boolean, + onValueChanged: (Boolean) -> Unit +) { + var checked by remember(showFeedFavIcon) { mutableStateOf(showFeedFavIcon) } + Box( + modifier = + Modifier.clickable { + checked = !checked + onValueChanged(!showFeedFavIcon) + } + ) { + Row( + modifier = Modifier.padding(start = 24.dp, top = 16.dp, end = 24.dp, bottom = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + LocalStrings.current.showFeedFavIconTitle, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.colorScheme.textEmphasisHigh + ) + Text( + LocalStrings.current.showFeedFavIconDesc, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.textEmphasisMed + ) + } + + Spacer(Modifier.width(16.dp)) + + Switch( + checked = checked, + onCheckedChange = { checked -> onValueChanged(checked) }, + ) + } + } +} + @Composable private fun AutoSyncSettingItem(enableAutoSync: Boolean, onValueChanged: (Boolean) -> Unit) { var checked by remember(enableAutoSync) { mutableStateOf(enableAutoSync) } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/LocalShowFeedFavIconSetting.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/LocalShowFeedFavIconSetting.kt new file mode 100644 index 000000000..106d904ba --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/LocalShowFeedFavIconSetting.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025 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.utils + +import androidx.compose.runtime.compositionLocalOf + +internal val LocalShowFeedFavIconSetting = compositionLocalOf { true } From 530beb95783c1a8754e33e27a2ffc170732b6da0 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Mon, 24 Feb 2025 07:31:21 +0530 Subject: [PATCH 13/14] Limit context item label to 1 line (#822) --- .../rss/reader/components/ContextActionsBottomBar.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt index 2045da673..003b76b7d 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ContextActionsBottomBar.kt @@ -116,7 +116,12 @@ internal fun ContextActionItem( Spacer(Modifier.requiredHeight(4.dp)) - Text(text = label, style = MaterialTheme.typography.labelLarge, color = color) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = color, + maxLines = 1, + ) } } } From 178ccf5a86b7a0022ee932d2724989343348b704 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Mon, 24 Feb 2025 10:08:14 +0530 Subject: [PATCH 14/14] Display featured section blurred background in light mode (#825) --- .../rss/reader/home/ui/FeaturedSection.kt | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt index 6921c399c..5e7eab1f8 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/home/ui/FeaturedSection.kt @@ -186,26 +186,25 @@ internal fun FeaturedSection( val featuredPost = featuredPosts.getOrNull(page) if (featuredPost != null) { Box { - if (useDarkTheme) { - FeaturedSectionBackground( - modifier = - Modifier.matchParentSize().layout { measurable, constraints -> - val topPadding = contentPadding.calculateTopPadding().roundToPx() - val horizontalContentPadding = - contentPadding.calculateStartPadding(layoutDirection) + - contentPadding.calculateEndPadding(layoutDirection) - val fullWidth = constraints.maxWidth + horizontalContentPadding.roundToPx() - val placeable = measurable.measure(constraints.copy(maxWidth = fullWidth)) + FeaturedSectionBackground( + modifier = + Modifier.matchParentSize().layout { measurable, constraints -> + val topPadding = contentPadding.calculateTopPadding().roundToPx() + val horizontalContentPadding = + contentPadding.calculateStartPadding(layoutDirection) + + contentPadding.calculateEndPadding(layoutDirection) + val fullWidth = constraints.maxWidth + horizontalContentPadding.roundToPx() + val placeable = measurable.measure(constraints.copy(maxWidth = fullWidth)) - layout(placeable.width, placeable.height) { - placeable.place(0, topPadding.unaryMinus()) - } - }, - state = pagerState, - page = page, - featuredPost = featuredPost, - ) - } + layout(placeable.width, placeable.height) { + placeable.place(0, topPadding.unaryMinus()) + } + }, + state = pagerState, + page = page, + featuredPost = featuredPost, + useDarkTheme = useDarkTheme, + ) val postWithMetadata = featuredPost.postWithMetadata FeaturedPostItem( @@ -231,21 +230,27 @@ private fun FeaturedSectionBackground( state: PagerState, page: Int, featuredPost: FeaturedPostItem, + useDarkTheme: Boolean, modifier: Modifier = Modifier, ) { Box(modifier) { val gradientOverlayModifier = Modifier.drawWithCache { + val gradientColor = if (useDarkTheme) Color.Black else Color.White val radialGradient = Brush.radialGradient( colors = - listOf(Color.Black, Color.Black.copy(alpha = 0.0f), Color.Black.copy(alpha = 0.0f)), + listOf( + gradientColor, + gradientColor.copy(alpha = 0.0f), + gradientColor.copy(alpha = 0.0f) + ), center = Offset(x = this.size.width, y = 40f) ) val linearGradient = Brush.verticalGradient( - colors = listOf(Color.Black, Color.Black.copy(alpha = 0.0f)), + colors = listOf(gradientColor, gradientColor.copy(alpha = 0.0f)), ) onDrawWithContent {