From 55a8d77b148d5e1551054edd931b1136d4026240 Mon Sep 17 00:00:00 2001 From: Sasikanth Date: Sun, 11 Aug 2024 04:29:50 +0530 Subject: [PATCH] Add support for loading fav icons using Coil based on feed homepage link (#710) --- .../reader/data/repository/RssRepository.kt | 26 +- .../rss/reader/data/database/FeedGroup.sq | 12 +- .../rss/reader/data/database/Source.sq | 8 +- .../rss/reader/core/model/local/FeedGroup.kt | 2 +- .../rss/reader/components/image/AsyncImage.kt | 1 - .../reader/components/image/FeedFavIcon.kt | 68 ++++ .../rss/reader/favicons/FavIconFetcher.kt | 295 ++++++++++++++++++ .../rss/reader/favicons/FavIconImageLoader.kt | 60 ++++ .../rss/reader/feed/ui/FeedInfoBottomSheet.kt | 18 +- .../feeds/ui/BottomSheetCollapsedContent.kt | 2 +- .../rss/reader/feeds/ui/FeedBottomBarItem.kt | 24 +- .../reader/feeds/ui/FeedGroupBottomBarItem.kt | 6 +- .../rss/reader/feeds/ui/FeedGroupIconGrid.kt | 4 +- .../rss/reader/feeds/ui/FeedGroupItem.kt | 6 +- .../rss/reader/feeds/ui/FeedListItem.kt | 21 +- .../rss/reader/home/ui/HomeTopAppBar.kt | 13 +- 16 files changed, 484 insertions(+), 82 deletions(-) create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.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 ade735176..12dae853c 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 @@ -280,7 +280,7 @@ class RssRepository( id: String, name: String, feedIds: List, - feedIcons: String, + feedHomepageLinks: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant?, @@ -289,7 +289,7 @@ class RssRepository( id = id, name = name, feedIds = feedIds.filterNot { it.isBlank() }, - feedIcons = feedIcons.split(",").filterNot { it.isBlank() }, + feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -626,7 +626,7 @@ class RssRepository( lastCleanUpAt: Instant?, numberOfUnreadPosts: Long, feedIds: List?, - feedIcons: String?, + feedHomepageLinks: String?, updatedAt: Instant?, pinnedPosition: Double -> if (type == "group") { @@ -634,7 +634,8 @@ class RssRepository( id = id, name = name, feedIds = feedIds?.filterNot { it.isBlank() }.orEmpty(), - feedIcons = feedIcons?.split(",")?.filterNot { it.isBlank() }.orEmpty(), + feedHomepageLinks = + feedHomepageLinks?.split(",")?.filterNot { it.isBlank() }.orEmpty(), createdAt = createdAt!!, updatedAt = updatedAt!!, pinnedAt = pinnedAt, @@ -689,14 +690,15 @@ class RssRepository( lastCleanUpAt: Instant?, numberOfUnreadPosts: Long, feedIds: List?, - feedIcons: String?, + feedHomepageLinks: String?, updatedAt: Instant? -> if (type == "group") { FeedGroup( id = id, name = name, feedIds = feedIds?.filterNot { it.isBlank() }.orEmpty(), - feedIcons = feedIcons?.split(",")?.filterNot { it.isBlank() }.orEmpty(), + feedHomepageLinks = + feedHomepageLinks?.split(",")?.filterNot { it.isBlank() }.orEmpty(), createdAt = createdAt, updatedAt = updatedAt!!, pinnedAt = pinnedAt, @@ -735,7 +737,7 @@ class RssRepository( id: String, name: String, feedIds: List, - feedIcons: String, + feedHomepageLinks: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant?, @@ -744,7 +746,7 @@ class RssRepository( id = id, name = name, feedIds = feedIds.filterNot { it.isBlank() }, - feedIcons = feedIcons.split(",").filterNot { it.isBlank() }, + feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -769,7 +771,7 @@ class RssRepository( id: String, name: String, feedIds: List, - feedIcons: String, + feedHomepageLinks: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant? -> @@ -777,7 +779,7 @@ class RssRepository( id = id, name = name, feedIds = feedIds.filterNot { it.isBlank() }, - feedIcons = feedIcons.split(",").filterNot { it.isBlank() }, + feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, @@ -796,7 +798,7 @@ class RssRepository( id: String, name: String, feedIds: List, - feedIcons: String, + feedHomepageLinks: String, createdAt: Instant, updatedAt: Instant, pinnedAt: Instant? -> @@ -804,7 +806,7 @@ class RssRepository( id = id, name = name, feedIds = feedIds.filterNot { it.isBlank() }, - feedIcons = feedIcons.split(",").filterNot { it.isBlank() }, + feedHomepageLinks = feedHomepageLinks.split(",").filterNot { it.isBlank() }, createdAt = createdAt, updatedAt = updatedAt, pinnedAt = pinnedAt, 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 b488ab0b4..2a4fb2420 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 @@ -26,10 +26,10 @@ SELECT id, name, feedIds, - COALESCE((SELECT GROUP_CONCAT(feed.icon) + COALESCE((SELECT GROUP_CONCAT(feed.homepageLink) FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) - LIMIT 4), '') AS feedIcons, + LIMIT 4), '') AS feedHomepageLinks, createdAt, updatedAt, pinnedAt, @@ -42,10 +42,10 @@ SELECT id, name, feedIds, - COALESCE((SELECT GROUP_CONCAT(feed.icon) + COALESCE((SELECT GROUP_CONCAT(feed.homepageLink) FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) - LIMIT 4), '') AS feedIcons, + LIMIT 4), '') AS feedHomepageLinks, createdAt, updatedAt, pinnedAt, @@ -79,10 +79,10 @@ SELECT id, name, feedIds, - COALESCE((SELECT GROUP_CONCAT(feed.icon) + COALESCE((SELECT GROUP_CONCAT(feed.homepageLink) FROM feed WHERE INSTR(feedGroup.feedIds, feed.id) - LIMIT 4), '') AS feedIcons, + LIMIT 4), '') AS feedHomepageLinks, 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 b146425e5..3b16d5628 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 @@ -59,10 +59,10 @@ FROM ( NULL AS lastCleanUpAt, COUNT(CASE WHEN p.read == 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, fg.feedIds, - COALESCE((SELECT GROUP_CONCAT(feed.icon) + COALESCE((SELECT GROUP_CONCAT(feed.homepageLink) FROM feed WHERE INSTR(fg.feedIds, feed.id) - LIMIT 4), '') AS feedIcons, + LIMIT 4), '') AS feedHomepageLinks, fg.createdAt, fg.pinnedAt, fg.updatedAt, @@ -123,10 +123,10 @@ FROM ( NULL AS lastCleanUpAt, COUNT(CASE WHEN p.read == 0 THEN 1 ELSE NULL END) AS numberOfUnreadPosts, fg.feedIds, - COALESCE((SELECT GROUP_CONCAT(feed.icon) + COALESCE((SELECT GROUP_CONCAT(feed.homepageLink) FROM feed WHERE INSTR(fg.feedIds, feed.id) - LIMIT 4), '') AS feedIcons, + LIMIT 4), '') AS feedHomepageLinks, fg.createdAt, fg.pinnedAt, fg.updatedAt 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 e34d9a70f..58a8f74bf 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 @@ -22,7 +22,7 @@ data class FeedGroup( override val id: String, val name: String, val feedIds: List, - val feedIcons: List, + val feedHomepageLinks: List, val numberOfUnreadPosts: Long = 0, val createdAt: Instant, val updatedAt: Instant, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/AsyncImage.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/AsyncImage.kt index e54d1fcbc..c2bf021d8 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/AsyncImage.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/AsyncImage.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.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale 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/FeedFavIcon.kt new file mode 100644 index 000000000..6d172a0f8 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/FeedFavIcon.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.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 +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import coil3.compose.LocalPlatformContext +import coil3.compose.SubcomposeAsyncImage +import coil3.request.ImageRequest +import coil3.size.Dimension +import coil3.size.Size +import dev.sasikanth.rss.reader.favicons.FavIconImageLoader +import dev.sasikanth.rss.reader.ui.AppTheme + +@Composable +internal fun FeedFavIcon( + url: String, + contentDescription: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + size: Size = Size(Dimension.Undefined, 500) +) { + 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) + + SubcomposeAsyncImage( + model = imageRequest, + contentDescription = contentDescription, + modifier = Modifier.matchParentSize(), + contentScale = contentScale, + imageLoader = imageLoader, + error = { PlaceHolderIcon() }, + loading = { PlaceHolderIcon() } + ) + } +} + +@Composable +private fun PlaceHolderIcon() { + Icon( + imageVector = Icons.Rounded.RssFeed, + contentDescription = null, + tint = AppTheme.colorScheme.tintedBackground, + ) +} 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 new file mode 100644 index 000000000..af4a33a27 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconFetcher.kt @@ -0,0 +1,295 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoilApi::class) + +package dev.sasikanth.rss.reader.favicons + +import coil3.Extras +import coil3.ImageLoader +import coil3.Uri +import coil3.annotation.ExperimentalCoilApi +import coil3.annotation.InternalCoilApi +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.disk.DiskCache +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.getExtra +import coil3.network.CacheResponse +import coil3.network.CacheStrategy +import coil3.network.HttpException +import coil3.network.NetworkClient +import coil3.network.NetworkFetcher +import coil3.network.NetworkRequest +import coil3.network.NetworkRequestBody +import coil3.network.NetworkResponse +import coil3.network.NetworkResponseBody +import coil3.network.httpHeaders +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 okio.FileSystem +import okio.IOException + +private const val CACHE_CONTROL = "Cache-Control" +private const val CONTENT_TYPE = "Content-Type" +private const val HTTP_METHOD_GET = "GET" +private const val MIME_TYPE_TEXT_PLAIN = "text/plain" + +@OptIn(InternalCoilApi::class) +class FavIconFetcher( + private val url: String, + private val options: Options, + private val networkClient: Lazy, + private val diskCache: Lazy, + private val cacheStrategy: Lazy, + private val networkFetcher: (url: String) -> NetworkFetcher, +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val snapshot = readFromDiskCache() + try { + // Fast path: fetch the fav icon from the disk cache without performing a network request. + var output: CacheStrategy.Output? = null + if (snapshot != null) { + var cacheResponse = snapshot.toCacheResponse() + if (cacheResponse != null) { + val input = CacheStrategy.Input(cacheResponse, newRequest(), options) + output = cacheStrategy.value.compute(input) + cacheResponse = output.cacheResponse + } + if (cacheResponse != null) { + return SourceFetchResult( + source = snapshot.toImageSource(), + mimeType = getMimeType(url, cacheResponse.responseHeaders[CONTENT_TYPE]), + dataSource = DataSource.DISK, + ) + } + } + + // Slow path: fetch the fav icon by parsing response HTML + val networkRequest = output?.networkRequest ?: newRequest() + return executeNetworkRequest(networkRequest) { response -> + // Write the response to the disk cache then open a new snapshot. + val responseBody = checkNotNull(response.body) { "body == null" } + val responseBodyBuffer = responseBody.readBuffer() + + val document = + Ksoup.parse( + bufferReader = BufferReader(responseBodyBuffer), + baseUri = url, + charsetName = null + ) + val favIconUrl = parseFaviconUrl(document) ?: fallbackFaviconUrl(url) + + return@executeNetworkRequest networkFetcher(favIconUrl).fetch() + } + } catch (e: Exception) { + snapshot?.closeQuietly() + throw e + } + } + + private fun parseFaviconUrl(document: Document): String? { + val faviconUrl = + linkRelTag(document, "apple-touch-icon") + ?: linkRelTag(document, "apple-touch-icon-precomposed") + ?: linkRelTag(document, "shortcut icon") ?: linkRelTag(document, "icon") + + return faviconUrl + } + + private fun fallbackFaviconUrl(url: String): String { + // Setting size as 180px, since that's the most commonly used apple touch icon size in the HTML, + // if a icon is not found, it will fallback to default fav icon + return "https://www.google.com/s2/favicons?domain=${url}&sz=180" + } + + /** From Unfurl https://github.com/saket/unfurl */ + private fun linkRelTag(document: Document, rel: String): String? { + val elements = document.head().select("link[rel=$rel]") + var largestSizeUrl = elements.firstOrNull()?.attr("abs:href") ?: return null + var largestSize = 0 + + for (element in elements) { + // Some websites have multiple icons for different sizes. Find the largest one. + val sizes = element.attr("sizes") + if (sizes.contains("x")) { + val size = sizes.split("x")[0].toInt() + if (size > largestSize) { + largestSize = size + largestSizeUrl = element.attr("abs:href") + } + } + } + return largestSizeUrl + } + + private fun readFromDiskCache(): DiskCache.Snapshot? { + return if (options.diskCachePolicy.readEnabled) { + diskCache.value?.openSnapshot(diskCacheKey) + } else { + null + } + } + + private fun newRequest(url: String? = null): NetworkRequest { + val headers = options.httpHeaders.newBuilder() + val diskRead = options.diskCachePolicy.readEnabled + val networkRead = options.networkCachePolicy.readEnabled + when { + !networkRead && diskRead -> { + headers[CACHE_CONTROL] = "only-if-cached, max-stale=2147483647" + } + networkRead && !diskRead -> + if (options.diskCachePolicy.writeEnabled) { + headers[CACHE_CONTROL] = "no-cache" + } else { + headers[CACHE_CONTROL] = "no-cache, no-store" + } + !networkRead && !diskRead -> { + // This causes the request to fail with a 504 Unsatisfiable Request. + headers[CACHE_CONTROL] = "no-cache, only-if-cached" + } + } + + return NetworkRequest( + url = url ?: this.url, + method = options.httpMethod, + headers = headers.build(), + body = options.httpBody, + ) + } + + private suspend fun executeNetworkRequest( + request: NetworkRequest, + block: suspend (NetworkResponse) -> T, + ): T { + return networkClient.value.executeRequest(request) { response -> + if (response.code !in 200 until 300 && response.code != 304) { + throw HttpException(response) + } + block(response) + } + } + + /** + * Parse the response's `content-type` header. + * + * "text/plain" is often used as a default/fallback MIME type. Attempt to guess a better MIME type + * from the file extension. + */ + @InternalCoilApi + private fun getMimeType(url: String, contentType: String?): String? { + if (contentType == null || contentType.startsWith(MIME_TYPE_TEXT_PLAIN)) { + MimeTypeMap.getMimeTypeFromUrl(url)?.let { + return it + } + } + return contentType?.substringBefore(';') + } + + private fun DiskCache.Snapshot.toCacheResponse(): CacheResponse? { + return try { + fileSystem.read(metadata) { CacheResponse(this) } + } catch (_: IOException) { + // If we can't parse the metadata, ignore this entry. + null + } + } + + private fun DiskCache.Snapshot.toImageSource(): ImageSource { + return ImageSource( + file = data, + fileSystem = fileSystem, + diskCacheKey = diskCacheKey, + closeable = this, + ) + } + + private fun AutoCloseable.closeQuietly() { + try { + close() + } catch (e: RuntimeException) { + throw e + } catch (_: Exception) {} + } + + private suspend fun NetworkResponseBody.readBuffer(): Buffer = use { body -> + val buffer = Buffer() + body.writeTo(buffer) + return buffer + } + + private val httpMethodKey = Extras.Key(default = HTTP_METHOD_GET) + private val httpBodyKey = Extras.Key(default = null) + + private val Options.httpMethod: String + get() = getExtra(httpMethodKey) + + private val Options.httpBody: NetworkRequestBody? + get() = getExtra(httpBodyKey) + + private val diskCacheKey: String + get() = options.diskCacheKey ?: url + + private val fileSystem: FileSystem + get() = diskCache.value?.fileSystem ?: options.fileSystem + + class Factory( + networkClient: () -> NetworkClient, + cacheStrategy: () -> CacheStrategy, + ) : Fetcher.Factory { + + private val networkClientLazy = lazy(networkClient) + private val cacheStrategyLazy = lazy(cacheStrategy) + + override fun create( + data: Uri, + options: Options, + imageLoader: ImageLoader, + ): Fetcher? { + if (!isApplicable(data)) return null + val diskCacheLazy = lazy { imageLoader.diskCache } + + return FavIconFetcher( + url = data.toString(), + options = options, + networkClient = networkClientLazy, + diskCache = diskCacheLazy, + cacheStrategy = cacheStrategyLazy, + networkFetcher = { url -> + NetworkFetcher( + url = url, + options = options.copy(diskCacheKey = data.toString()), + networkClient = networkClientLazy, + diskCache = diskCacheLazy, + cacheStrategy = cacheStrategyLazy, + ) + } + ) + } + + private fun isApplicable(data: Uri): Boolean { + return data.scheme == "http" || data.scheme == "https" + } + } +} 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 new file mode 100644 index 000000000..9313edc36 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/favicons/FavIconImageLoader.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.favicons + +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.annotation.ExperimentalCoilApi +import coil3.network.CacheStrategy +import coil3.network.ktor2.asNetworkClient +import io.ktor.client.HttpClient +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.updateAndGet + +object FavIconImageLoader { + + private val reference = atomic(null) + + fun get(context: PlatformContext): ImageLoader { + return reference.value ?: newImageLoader(context) + } + + @OptIn(ExperimentalCoilApi::class) + private fun newImageLoader(context: PlatformContext): ImageLoader { + var imageLoader: ImageLoader? = null + + return reference.updateAndGet { value -> + when { + value is ImageLoader -> value + imageLoader != null -> imageLoader + else -> { + ImageLoader.Builder(context) + .components { + add( + FavIconFetcher.Factory( + networkClient = { HttpClient().asNetworkClient() }, + cacheStrategy = { CacheStrategy() } + ) + ) + } + .build() + .also { imageLoader = it } + } + } + } as ImageLoader + } +} 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 a9d587bee..16c75629f 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 @@ -19,7 +19,6 @@ package dev.sasikanth.rss.reader.feed.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -78,7 +77,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.AsyncImage +import dev.sasikanth.rss.reader.components.image.FeedFavIcon import dev.sasikanth.rss.reader.core.model.local.Feed import dev.sasikanth.rss.reader.feed.FeedEffect import dev.sasikanth.rss.reader.feed.FeedEvent @@ -246,16 +245,11 @@ private fun FeedLabelInput( .padding(8.dp) .fillMaxWidth() ) { - Box( - Modifier.requiredSize(56.dp).background(Color.White, RoundedCornerShape(16.dp)).padding(8.dp) - ) { - AsyncImage( - url = feed.icon, - contentDescription = feed.name, - modifier = - Modifier.requiredSize(48.dp).clip(RoundedCornerShape(12.dp)).align(Alignment.Center) - ) - } + FeedFavIcon( + url = feed.homepageLink, + contentDescription = feed.name, + modifier = Modifier.requiredSize(56.dp).clip(RoundedCornerShape(16.dp)), + ) Spacer(Modifier.requiredWidth(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 7b5833659..59fe7b5cd 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 @@ -96,7 +96,7 @@ internal fun BottomSheetCollapsedContent( is Feed -> { FeedBottomBarItem( badgeCount = source.numberOfUnreadPosts, - iconUrl = source.icon, + homePageUrl = source.homepageLink, 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 74098d7af..3e5641d20 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 @@ -33,17 +33,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import dev.sasikanth.rss.reader.components.image.AsyncImage +import dev.sasikanth.rss.reader.components.image.FeedFavIcon import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.utils.Constants.BADGE_COUNT_TRIM_LIMIT @Composable internal fun FeedBottomBarItem( badgeCount: Long, - iconUrl: String, + homePageUrl: String, canShowUnreadPostsCount: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -52,19 +51,12 @@ internal fun FeedBottomBarItem( Box(modifier = modifier) { Box(contentAlignment = Alignment.Center) { SelectionIndicator(selected = selected, animationProgress = 1f) - Box( - modifier = Modifier.requiredSize(56.dp).background(Color.White, RoundedCornerShape(16.dp)), - contentAlignment = Alignment.Center - ) { - AsyncImage( - url = iconUrl, - contentDescription = null, - modifier = - Modifier.requiredSize(48.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick) - ) - } + FeedFavIcon( + url = homePageUrl, + contentDescription = null, + modifier = + Modifier.requiredSize(56.dp).clip(RoundedCornerShape(16.dp)).clickable(onClick = onClick) + ) } if (badgeCount > 0 && canShowUnreadPostsCount) { 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 17f6d2253..7b9f0d285 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 @@ -58,21 +58,21 @@ internal fun FeedGroupBottomBarItem( contentAlignment = Alignment.Center ) { val iconSize = - if (feedGroup.feedIcons.size > 2) { + if (feedGroup.feedHomepageLinks.size > 2) { 18.dp } else { 20.dp } val iconSpacing = - if (feedGroup.feedIcons.size > 2) { + if (feedGroup.feedHomepageLinks.size > 2) { 4.dp } else { 0.dp } FeedGroupIconGrid( - icons = feedGroup.feedIcons, + icons = feedGroup.feedHomepageLinks, 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 06ef4c17d..9afda1191 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.AsyncImage +import dev.sasikanth.rss.reader.components.image.FeedFavIcon 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()) { - AsyncImage( + FeedFavIcon( 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 989b7a6ab..13229567c 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 @@ -99,14 +99,14 @@ internal fun FeedGroupItem( ) { Row(verticalAlignment = Alignment.CenterVertically) { val iconSize = - if (feedGroup.feedIcons.size > 2) { + if (feedGroup.feedHomepageLinks.size > 2) { 17.dp } else { 19.dp } val iconSpacing = - if (feedGroup.feedIcons.size > 2) { + if (feedGroup.feedHomepageLinks.size > 2) { 2.dp } else { 0.dp @@ -114,7 +114,7 @@ internal fun FeedGroupItem( FeedGroupIconGrid( modifier = Modifier.requiredSize(36.dp), - icons = feedGroup.feedIcons, + icons = feedGroup.feedHomepageLinks, 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 9615dc37f..03b118bff 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 @@ -38,13 +38,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.AsyncImage +import dev.sasikanth.rss.reader.components.image.FeedFavIcon 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 @@ -96,18 +95,12 @@ internal fun FeedListItem( ) ) { Row(modifier = Modifier.padding(all = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier.requiredSize(36.dp).background(Color.White, RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center - ) { - AsyncImage( - url = feed.icon, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = - Modifier.requiredSize(28.dp).clip(RoundedCornerShape(4.dp)).align(Alignment.Center), - ) - } + FeedFavIcon( + url = feed.homepageLink, + contentDescription = null, + modifier = Modifier.requiredSize(36.dp).clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) Spacer(Modifier.requiredWidth(12.dp)) 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 f1302bc0b..b174812b9 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.AsyncImage +import dev.sasikanth.rss.reader.components.image.FeedFavIcon 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 @@ -192,21 +192,21 @@ private fun SourceIcon(source: Source?, modifier: Modifier = Modifier) { when (source) { is FeedGroup -> { val iconSize = - if (source.feedIcons.size > 2) { + if (source.feedHomepageLinks.size > 2) { 18.dp } else { 20.dp } val iconSpacing = - if (source.feedIcons.size > 2) { + if (source.feedHomepageLinks.size > 2) { 4.dp } else { 0.dp } FeedGroupIconGrid( - icons = source.feedIcons, + icons = source.feedHomepageLinks, iconSize = iconSize, iconShape = RoundedCornerShape(percent = 30), horizontalArrangement = Arrangement.spacedBy(iconSpacing), @@ -214,10 +214,9 @@ private fun SourceIcon(source: Source?, modifier: Modifier = Modifier) { ) } is Feed -> { - AsyncImage( - url = source.icon, + FeedFavIcon( + url = source.homepageLink, contentDescription = null, - backgroundColor = Color.White, modifier = Modifier.clip(MaterialTheme.shapes.small).requiredSize(24.dp) ) }