From f0cb0868780e520d535a0e5d24a5e3918f73dc54 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Thu, 1 Feb 2024 07:34:13 +0530 Subject: [PATCH 1/3] Stop reporting post source fetch exceptions to Sentry It's just too much noise whenever a post fails to fetch the source. I cannot do anything about it, it depends on the source page. So no point reporting these errors. --- .../rss/reader/core/network/post/PostSourceFetcher.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/PostSourceFetcher.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/PostSourceFetcher.kt index 458867ff0..1dc7e4c17 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/PostSourceFetcher.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/post/PostSourceFetcher.kt @@ -22,7 +22,6 @@ import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.HttpStatusCode -import io.sentry.kotlin.multiplatform.Sentry import kotlinx.coroutines.withContext import me.tatarka.inject.annotations.Inject @@ -38,14 +37,13 @@ class PostSourceFetcher( val response = httpClient.get(link) if (response.status == HttpStatusCode.OK) { try { - response.bodyAsText() + return@withContext response.bodyAsText() } catch (e: Exception) { - Sentry.captureException(e) - null + // no-op } - } else { - null } + + return@withContext null } } } From 6cfaf0d986f7e87a9bc07c8863447aefd9162e5e Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Thu, 1 Feb 2024 08:23:30 +0530 Subject: [PATCH 2/3] Move webview related usages into when block in reader screen --- .../rss/reader/reader/ui/ReaderScreen.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt index 89db02428..12832e989 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/reader/ui/ReaderScreen.kt @@ -76,7 +76,6 @@ internal fun ReaderScreen(presenter: ReaderPresenter, modifier: Modifier = Modif val coroutineScope = rememberCoroutineScope() val linkHandler = LocalLinkHandler.current val sharedHandler = LocalShareHandler.current - val navigator = rememberWebViewNavigator() Scaffold( modifier = modifier, @@ -182,16 +181,6 @@ internal fun ReaderScreen(presenter: ReaderPresenter, modifier: Modifier = Modif containerColor = AppTheme.colorScheme.surfaceContainerLowest, contentColor = Color.Unspecified ) { paddingValues -> - val jsBridge = rememberWebViewJsBridge() - - LaunchedEffect(jsBridge) { - jsBridge.register( - ReaderLinkHandler( - openLink = { link -> coroutineScope.launch { linkHandler.openLink(link) } } - ) - ) - } - when { state.content == null -> { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -202,6 +191,17 @@ internal fun ReaderScreen(presenter: ReaderPresenter, modifier: Modifier = Modif } } state.hasContent -> { + val navigator = rememberWebViewNavigator() + val jsBridge = rememberWebViewJsBridge() + + LaunchedEffect(jsBridge) { + jsBridge.register( + ReaderLinkHandler( + openLink = { link -> coroutineScope.launch { linkHandler.openLink(link) } } + ) + ) + } + val codeBackgroundColor = StringUtils.hexFromArgb(AppTheme.colorScheme.surfaceContainerHighest.toArgb()) val textColor = StringUtils.hexFromArgb(AppTheme.colorScheme.onSurface.toArgb()) From dd2a89bdcadf0abb022f0135edd79eb396c479a2 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Thu, 1 Feb 2024 12:40:40 +0530 Subject: [PATCH 3/3] Migrate to Coil3 Multiplatform (#268) * Bump coil dependency to v3.0.0-alpha03 Also added the coil network package, since that's required to load network images now * Provide coil `ImageLoader` via `ImageLoaderComponent` * Add coil extension to convert coil `Image` to compose `ImageBitmap` * Add coil SVG dependency * Migrate to Coil3 multiplatform implementation * Load image in background in `DynamicContentTheme` --- gradle/libs.versions.toml | 6 +- shared/build.gradle.kts | 4 +- .../rss/reader/components/ImageLoader.kt | 52 ----- ...ent.kt => ImageLoaderComponent.android.kt} | 17 +- .../rss/reader/utils/CoilExt.android.kt | 29 +++ .../dev/sasikanth/rss/reader/app/App.kt | 13 +- .../reader/components/DynamicContentTheme.kt | 48 +++-- .../rss/reader/components/image/AsyncImage.kt | 29 ++- .../reader/components/image/ImageLoader.kt | 66 ------ .../rss/reader/di/ImageLoaderComponent.kt | 28 ++- .../reader/di/SharedApplicationComponent.kt | 7 +- .../rss/reader/home/ui/FeaturedSection.kt | 4 +- .../sasikanth/rss/reader/utils/CoilExt.kt} | 16 +- .../rss/reader/components/IOSImageLoader.kt | 200 ------------------ .../rss/reader/di/ImageLoaderComponent.ios.kt | 48 +++++ .../sasikanth/rss/reader/utils/CoilExt.ios.kt | 28 +++ 16 files changed, 217 insertions(+), 378 deletions(-) delete mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt rename shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/{ImageLoaderComponent.kt => ImageLoaderComponent.android.kt} (59%) create mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.android.kt delete mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/ImageLoader.kt rename shared/src/{iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt => commonMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.kt} (61%) delete mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt create mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.ios.kt create mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.ios.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 23c180b6b..5f61d7b1b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ androidx_work = "2.9.0" androidx_datastore = "1.1.0-beta01" androidx_browser = "1.7.0" androidx_annotation = "1.7.1" -coil = "2.5.0" +coil = "3.0.0-alpha03" spotless = "6.25.0" ktfmt = "0.44" kotlininject = "0.6.3" @@ -89,7 +89,9 @@ androidx_datastore_okio = { module = "androidx.datastore:datastore-core-okio", v androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx_datastore" } 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:coil-compose", version.ref = "coil" } +coil_compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil_network = { module = "io.coil-kt.coil3:coil-network-ktor", 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" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 2cbaf646a..df21e613c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -121,6 +121,9 @@ kotlin { implementation(libs.bundles.xmlutil) api(libs.webview) implementation(libs.uuid) + api(libs.coil.compose) + api(libs.coil.network) + api(libs.coil.svg) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -134,7 +137,6 @@ kotlin { api(libs.androidx.browser) implementation(libs.ktor.client.okhttp) implementation(libs.sqldelight.driver.android) - implementation(libs.coil.compose) api(libs.sqliteAndroid) } val androidInstrumentedTest by getting { diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt deleted file mode 100644 index f89a2bd2c..000000000 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dev.sasikanth.rss.reader.components - -import android.content.Context -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.unit.IntSize -import androidx.core.graphics.drawable.toBitmap -import coil.imageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.size.Scale -import dev.sasikanth.rss.reader.components.image.ImageLoader -import dev.sasikanth.rss.reader.di.scopes.AppScope -import me.tatarka.inject.annotations.Inject - -@Inject -@AppScope -class AndroidImageLoader(private val context: Context) : ImageLoader { - - override suspend fun getImage(url: String, size: IntSize?): ImageBitmap? { - val requestBuilder = - ImageRequest.Builder(context) - .data(url) - .scale(Scale.FILL) - .allowHardware(false) - .memoryCacheKey("$url.dynamic_colors") - - if (size != null) { - requestBuilder.size(size.width, size.height) - } - - return when (val result = context.imageLoader.execute(requestBuilder.build())) { - is SuccessResult -> result.drawable.toBitmap().asImageBitmap() - else -> null - } - } -} diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.android.kt similarity index 59% rename from shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt rename to shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.android.kt index 120ff3a21..fdee4522d 100644 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.android.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sasikanth Miriyampalli + * Copyright 2024 Sasikanth Miriyampalli * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,13 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package dev.sasikanth.rss.reader.di -import dev.sasikanth.rss.reader.components.AndroidImageLoader -import dev.sasikanth.rss.reader.components.image.ImageLoader +import android.content.Context +import coil3.PlatformContext import me.tatarka.inject.annotations.Provides +import okio.Path +import okio.Path.Companion.toPath + +actual interface ImageLoaderPlatformComponent { -internal actual interface ImageLoaderComponent { + @Provides fun providePlatformContext(context: Context): PlatformContext = context - @Provides fun AndroidImageLoader.bind(): ImageLoader = this + @Provides + fun diskCache(application: Context): Path = + application.cacheDir.absolutePath.toPath().resolve("dev_sasikanth_rss_reader_images_cache") } diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.android.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.android.kt new file mode 100644 index 000000000..12dae0242 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.android.kt @@ -0,0 +1,29 @@ +/* + * 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.utils + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.drawable.toBitmap +import coil3.Image +import coil3.PlatformContext +import coil3.annotation.ExperimentalCoilApi + +@OptIn(ExperimentalCoilApi::class) +actual fun Image.toComposeImageBitmap(context: PlatformContext): ImageBitmap { + return asDrawable(context.resources).toBitmap().asImageBitmap() +} 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 44e57f8a2..676d4ab0a 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 @@ -20,8 +20,10 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import coil3.ImageLoader +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.setSingletonImageLoaderFactory import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation import com.arkivanov.essenty.backhandler.BackHandler @@ -29,8 +31,6 @@ import dev.sasikanth.rss.reader.about.ui.AboutScreen import dev.sasikanth.rss.reader.bookmarks.ui.BookmarksScreen import dev.sasikanth.rss.reader.components.DynamicContentTheme import dev.sasikanth.rss.reader.components.LocalDynamicColorState -import dev.sasikanth.rss.reader.components.image.ImageLoader -import dev.sasikanth.rss.reader.components.image.LocalImageLoader import dev.sasikanth.rss.reader.components.rememberDynamicColorState import dev.sasikanth.rss.reader.home.ui.HomeScreen import dev.sasikanth.rss.reader.platform.LinkHandler @@ -48,17 +48,18 @@ typealias App = @Composable () -> Unit @Inject @Composable -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class, ExperimentalCoilApi::class) fun App( appPresenter: AppPresenter, - imageLoader: ImageLoader, shareHandler: ShareHandler, linkHandler: LinkHandler, + imageLoader: ImageLoader, ) { + setSingletonImageLoaderFactory { imageLoader } + val dynamicColorState = rememberDynamicColorState(imageLoader = imageLoader) CompositionLocalProvider( - LocalImageLoader provides imageLoader, LocalWindowSizeClass provides calculateWindowSizeClass(), LocalDynamicColorState provides dynamicColorState, LocalShareHandler provides shareHandler, diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/DynamicContentTheme.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/DynamicContentTheme.kt index 05aa6dcc5..3efa0f203 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/DynamicContentTheme.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/DynamicContentTheme.kt @@ -29,7 +29,12 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.unit.IntSize +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.size.Scale import dev.sasikanth.material.color.utilities.dynamiccolor.DynamicColor import dev.sasikanth.material.color.utilities.dynamiccolor.MaterialDynamicColors import dev.sasikanth.material.color.utilities.dynamiccolor.ToneDeltaConstraint @@ -39,12 +44,13 @@ import dev.sasikanth.material.color.utilities.quantize.QuantizerCelebi import dev.sasikanth.material.color.utilities.scheme.DynamicScheme import dev.sasikanth.material.color.utilities.scheme.SchemeContent import dev.sasikanth.material.color.utilities.score.Score -import dev.sasikanth.rss.reader.components.image.ImageLoader import dev.sasikanth.rss.reader.ui.AppTheme import dev.sasikanth.rss.reader.utils.Constants.EPSILON import dev.sasikanth.rss.reader.utils.inverse +import dev.sasikanth.rss.reader.utils.toComposeImageBitmap import kotlin.math.absoluteValue import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.withContext private const val TINTED_BACKGROUND = "tinted_background" @@ -64,7 +70,7 @@ private const val SURFACE_CONTAINER_HIGHEST = "surface_container_highest" @Composable internal fun DynamicContentTheme( - dynamicColorState: DynamicColorState = rememberDynamicColorState(), + dynamicColorState: DynamicColorState, content: @Composable () -> Unit ) { val colorScheme = @@ -104,8 +110,9 @@ internal fun rememberDynamicColorState( defaultSurfaceContainerLowest: Color = AppTheme.colorScheme.surfaceContainerLowest, defaultSurfaceContainerHigh: Color = AppTheme.colorScheme.surfaceContainerHigh, defaultSurfaceContainerHighest: Color = AppTheme.colorScheme.surfaceContainerHighest, - imageLoader: ImageLoader? = null + imageLoader: ImageLoader, ): DynamicColorState { + val platformContext = LocalPlatformContext.current return rememberSaveable(saver = DynamicColorState.Saver) { DynamicColorState( defaultTintedBackground, @@ -124,7 +131,7 @@ internal fun rememberDynamicColorState( defaultSurfaceContainerHighest, ) } - .also { it.setImageLoader(imageLoader) } + .apply { setImageLoader(imageLoader, platformContext) } } /** @@ -199,7 +206,9 @@ internal class DynamicColorState( else -> null } private var images = emptyList() - private var imageLoader: ImageLoader? = null + + private lateinit var imageLoader: ImageLoader + private lateinit var platformContext: PlatformContext companion object { val Saver: Saver = @@ -245,15 +254,16 @@ internal class DynamicColorState( ) } - fun setImageLoader(imageLoader: ImageLoader?) { + fun setImageLoader(imageLoader: ImageLoader, platformContext: PlatformContext) { this.imageLoader = imageLoader + this.platformContext = platformContext } - suspend fun onContentChange(images: List) { - if (!this.images.containsAll(images)) { - this.images = images - this.images.forEach { imageUrl -> fetchDynamicColors(imageUrl) } + suspend fun onContentChange(newImages: List) { + if (images.isEmpty()) { + images = newImages } + images.forEach { imageUrl -> fetchDynamicColors(imageUrl) } } fun updateOffset( @@ -262,6 +272,7 @@ internal class DynamicColorState( nextImageUrl: String?, offset: Float ) { + val previousDynamicColors = previousImageUrl?.let { cache?.get(it) } val currentDynamicColors = cache?.get(currentImageUrl) val nextDynamicColors = nextImageUrl?.let { cache?.get(it) } @@ -397,6 +408,7 @@ internal class DynamicColorState( surfaceContainerHighest = defaultSurfaceContainerHighest } + @OptIn(ExperimentalCoilApi::class) private suspend fun fetchDynamicColors(url: String): DynamicColors? { val cached = cache?.get(url) if (cached != null) { @@ -404,7 +416,19 @@ internal class DynamicColorState( return cached } - val image = imageLoader?.getImage(url, size = IntSize(64, 64)) + val imageRequest = + ImageRequest.Builder(platformContext) + .data(url) + .scale(Scale.FILL) + .size(64) + .memoryCacheKey("$url.dynamic_colors") + .build() + + val image = + withContext(Dispatchers.IO) { + imageLoader.execute(imageRequest).image?.toComposeImageBitmap(platformContext) + } + return if (image != null) { extractColorsFromImage(image) .let { colorsMap -> 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 359472867..92e9c8c76 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 @@ -15,7 +15,6 @@ */ package dev.sasikanth.rss.reader.components.image -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -23,7 +22,9 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.IntSize +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.size.Size @Composable internal fun AsyncImage( @@ -31,7 +32,7 @@ internal fun AsyncImage( contentDescription: String?, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit, - size: IntSize? = null, + size: Size = Size.ORIGINAL, backgroundColor: Color? = null ) { val backgroundColorModifier = @@ -42,20 +43,14 @@ internal fun AsyncImage( } Box(modifier.then(backgroundColorModifier)) { - val imageState by rememberImageLoaderState(url, size) + val imageRequest = + ImageRequest.Builder(LocalPlatformContext.current).data(url).size(size).build() - when (imageState) { - is ImageLoaderState.Loaded -> { - Image( - modifier = Modifier.matchParentSize(), - bitmap = (imageState as ImageLoaderState.Loaded).image, - contentDescription = contentDescription, - contentScale = contentScale - ) - } - else -> { - // TODO: Handle other cases instead of just showing blank space? - } - } + coil3.compose.AsyncImage( + model = imageRequest, + contentDescription = contentDescription, + modifier = Modifier.matchParentSize(), + contentScale = contentScale + ) } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/ImageLoader.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/ImageLoader.kt deleted file mode 100644 index a029a3758..000000000 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/image/ImageLoader.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dev.sasikanth.rss.reader.components.image - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.unit.IntSize - -@Composable -internal fun rememberImageLoaderState(url: String?, size: IntSize?): State { - val initialState = - if (url.isNullOrBlank()) { - ImageLoaderState.Error - } else { - ImageLoaderState.Loading - } - val imageLoader = LocalImageLoader.current - val result = remember(url, imageLoader) { mutableStateOf(initialState) } - - LaunchedEffect(url) { - val imageLoaderState = - try { - ImageLoaderState.Loaded(imageLoader?.getImage(url!!, size = size)!!) - } catch (e: Exception) { - ImageLoaderState.Error - } - - result.value = imageLoaderState - } - - return result -} - -interface ImageLoader { - suspend fun getImage(url: String, size: IntSize?): ImageBitmap? -} - -internal sealed interface ImageLoaderState { - data object Idle : ImageLoaderState - - data object Loading : ImageLoaderState - - data class Loaded(val image: ImageBitmap) : ImageLoaderState - - data object Error : ImageLoaderState -} - -internal val LocalImageLoader = staticCompositionLocalOf { null } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt index b268f746d..e5e885f9e 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sasikanth Miriyampalli + * Copyright 2024 Sasikanth Miriyampalli * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,6 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package dev.sasikanth.rss.reader.di -internal expect interface ImageLoaderComponent +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import me.tatarka.inject.annotations.Provides +import okio.Path + +expect interface ImageLoaderPlatformComponent + +interface ImageLoaderComponent : ImageLoaderPlatformComponent { + + val imageLoader: ImageLoader + + @Provides + fun imageLoader( + platformContext: PlatformContext, + cachePath: Path, + ): ImageLoader { + return ImageLoader.Builder(platformContext) + .memoryCache { MemoryCache.Builder().maxSizePercent(platformContext, percent = 0.25).build() } + .diskCache { DiskCache.Builder().directory(cachePath).build() } + .build() + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt index f165bc577..63bc30b90 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/di/SharedApplicationComponent.kt @@ -15,7 +15,6 @@ */ package dev.sasikanth.rss.reader.di -import dev.sasikanth.rss.reader.components.image.ImageLoader import dev.sasikanth.rss.reader.core.network.di.NetworkComponent import dev.sasikanth.rss.reader.di.scopes.AppScope import dev.sasikanth.rss.reader.filemanager.FileManagerComponent @@ -29,13 +28,11 @@ import me.tatarka.inject.annotations.Provides abstract class SharedApplicationComponent : DataComponent, - ImageLoaderComponent, SentryComponent, NetworkComponent, LoggingComponent, - FileManagerComponent { - - abstract val imageLoader: ImageLoader + FileManagerComponent, + ImageLoaderComponent { abstract val initializers: Set 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 878c556e4..635284c8a 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 @@ -49,9 +49,9 @@ import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed +import coil3.size.Size import dev.sasikanth.rss.reader.components.LocalDynamicColorState import dev.sasikanth.rss.reader.components.image.AsyncImage import dev.sasikanth.rss.reader.core.model.local.PostWithMetadata @@ -295,7 +295,7 @@ private fun FeaturedSectionBlurredBackground( .then(modifier), contentDescription = null, contentScale = ContentScale.Crop, - size = IntSize(128, 128), + size = Size(128, 128), backgroundColor = AppTheme.colorScheme.surfaceContainerLowest ) } diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.kt similarity index 61% rename from shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt rename to shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.kt index bcc4c4b5d..0200b4659 100644 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sasikanth Miriyampalli + * Copyright 2024 Sasikanth Miriyampalli * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.sasikanth.rss.reader.di -import dev.sasikanth.rss.reader.components.IOSImageLoader -import dev.sasikanth.rss.reader.components.image.ImageLoader -import me.tatarka.inject.annotations.Provides +package dev.sasikanth.rss.reader.utils -internal actual interface ImageLoaderComponent { +import androidx.compose.ui.graphics.ImageBitmap +import coil3.Image +import coil3.PlatformContext +import coil3.annotation.ExperimentalCoilApi - @Provides fun IOSImageLoader.bind(): ImageLoader = this -} +@OptIn(ExperimentalCoilApi::class) +expect fun Image.toComposeImageBitmap(context: PlatformContext): ImageBitmap diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt deleted file mode 100644 index 0ac78df1e..000000000 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright 2023 Sasikanth Miriyampalli - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:OptIn(ExperimentalForeignApi::class) - -package dev.sasikanth.rss.reader.components - -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import androidx.compose.ui.unit.IntSize -import dev.sasikanth.rss.reader.components.image.ImageLoader -import dev.sasikanth.rss.reader.di.scopes.AppScope -import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.readBytes -import io.ktor.http.HttpStatusCode -import io.ktor.util.toMap -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.convert -import kotlinx.cinterop.readBytes -import kotlinx.cinterop.usePinned -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -import kotlinx.coroutines.withContext -import me.tatarka.inject.annotations.Inject -import org.jetbrains.skia.Bitmap -import org.jetbrains.skia.Canvas -import org.jetbrains.skia.ColorAlphaType -import org.jetbrains.skia.Image -import org.jetbrains.skia.ImageInfo -import org.jetbrains.skia.Rect -import org.jetbrains.skia.SamplingMode -import platform.Foundation.NSCachedURLResponse -import platform.Foundation.NSCharacterSet -import platform.Foundation.NSData -import platform.Foundation.NSHTTPURLResponse -import platform.Foundation.NSString -import platform.Foundation.NSURL -import platform.Foundation.NSURLCache -import platform.Foundation.NSURLRequest -import platform.Foundation.URLFragmentAllowedCharacterSet -import platform.Foundation.create -import platform.Foundation.stringByAddingPercentEncodingWithAllowedCharacters - -@Inject -@AppScope -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -class IOSImageLoader(private val httpClient: HttpClient) : ImageLoader { - - companion object { - private const val MAX_REDIRECTS_ALLOWED = 5 - } - - private val memoryCacheSize = (10 * 1024 * 1024).toULong() // 10 MB cache size - private val diskCacheSize = (50 * 1024 * 1024).toULong() // 50 MB cache size - - private val urlCache by - lazy(LazyThreadSafetyMode.NONE) { - NSURLCache( - memoryCapacity = memoryCacheSize, - diskCapacity = diskCacheSize, - diskPath = "dev_sasikanth_rss_reader_images_cache" - ) - } - - @Suppress("CAST_NEVER_SUCCEEDS") - override suspend fun getImage(url: String, size: IntSize?): ImageBitmap? { - return withContext(Dispatchers.IO) { - return@withContext try { - val encodedUrl = - (url as NSString).stringByAddingPercentEncodingWithAllowedCharacters( - NSCharacterSet.URLFragmentAllowedCharacterSet - ) - ?: return@withContext null - val data = - loadCachedImage(encodedUrl) ?: downloadImage(encodedUrl) ?: return@withContext null - - Image.makeFromEncoded(data).toBitmap(size).asComposeImageBitmap() - } catch (e: Exception) { - null - } - } - } - - private fun loadCachedImage(url: String): ByteArray? { - val request = createNSURLRequest(url) ?: return null - - urlCache.cachedResponseForRequest(request)?.let { cachedResponse -> - val bytes = cachedResponse.data.bytes - return bytes?.readBytes(cachedResponse.data.length.toInt()) - } - - return null - } - - private suspend fun downloadImage(url: String): ByteArray? { - val request = createNSURLRequest(url) ?: return null - val response = httpClient.get(url) - - when (response.status) { - HttpStatusCode.MultipleChoices, - HttpStatusCode.MovedPermanently, - HttpStatusCode.Found, - HttpStatusCode.SeeOther, - HttpStatusCode.TemporaryRedirect, - HttpStatusCode.PermanentRedirect -> { - var redirectCount = 0 - if (redirectCount < MAX_REDIRECTS_ALLOWED) { - val newUrl = response.headers["Location"] - if (newUrl != url && newUrl != null) { - redirectCount += 1 - downloadImage(url = newUrl) - } else { - return null - } - } else { - return null - } - } - HttpStatusCode.OK -> { - // continue - } - else -> { - return null - } - } - - return response.readBytes().also { data -> - if (data.isNotEmpty()) { - val cachedResponse = - createCachedResponse(httpResponse = response, data = data, requestUrl = url) - urlCache.storeCachedResponse(cachedResponse = cachedResponse, forRequest = request) - } - } - } - - private fun createNSURLRequest(url: String): NSURLRequest? { - val nsUrl = NSURL.URLWithString(url) ?: return null - return NSURLRequest.requestWithURL(nsUrl) - } - - @Suppress("UNCHECKED_CAST") - private fun createCachedResponse( - httpResponse: HttpResponse, - data: ByteArray, - requestUrl: String - ): NSCachedURLResponse { - val statusCode = httpResponse.status.value - val headers = httpResponse.headers.toMap().mapValues { it.value.joinToString(", ") } - - val url = NSURL(string = requestUrl) - val response = - NSHTTPURLResponse( - uRL = url, - statusCode = statusCode.toLong(), - HTTPVersion = httpResponse.version.toString(), - headerFields = headers as Map - ) - val nsData = data.toNSData() - - return NSCachedURLResponse(response = response, data = nsData) - } - - private fun ByteArray.toNSData(): NSData = - this.usePinned { NSData.create(bytes = it.addressOf(0), length = this.size.convert()) } -} - -private fun Image.toBitmap(size: IntSize? = null): Bitmap { - val width = size?.width ?: this.width - val height = size?.height ?: this.height - - val bitmap = Bitmap() - bitmap.allocPixels(ImageInfo.makeN32(width, height, ColorAlphaType.PREMUL)) - val canvas = Canvas(bitmap) - canvas.drawImageRect( - this, - Rect.makeWH(this.width.toFloat(), this.height.toFloat()), - Rect.makeXYWH(0f, 0f, width.toFloat(), height.toFloat()), - SamplingMode.DEFAULT, - null, - true - ) - bitmap.setImmutable() - return bitmap -} diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.ios.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.ios.kt new file mode 100644 index 000000000..018321c71 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.ios.kt @@ -0,0 +1,48 @@ +/* + * 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.di + +import coil3.PlatformContext +import kotlinx.cinterop.ExperimentalForeignApi +import me.tatarka.inject.annotations.Provides +import okio.Path +import okio.Path.Companion.toPath +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask + +actual interface ImageLoaderPlatformComponent { + + @Provides fun providePlatformContext(): PlatformContext = PlatformContext.INSTANCE + + @Provides + fun diskCache(): Path = + NSFileManager.defaultManager.cacheDir.toPath().resolve("dev_sasikanth_rss_reader_images_cache") + + @OptIn(ExperimentalForeignApi::class) + private val NSFileManager.cacheDir: String + get() = + URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = true, + error = null, + ) + ?.path + .orEmpty() +} diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.ios.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.ios.kt new file mode 100644 index 000000000..6e24245e5 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/utils/CoilExt.ios.kt @@ -0,0 +1,28 @@ +/* + * 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.utils + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import coil3.Image +import coil3.PlatformContext +import coil3.annotation.ExperimentalCoilApi + +@OptIn(ExperimentalCoilApi::class) +actual fun Image.toComposeImageBitmap(context: PlatformContext): ImageBitmap { + return asBitmap().asComposeImageBitmap() +}