diff --git a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt deleted file mode 100644 index 41c8843c8..000000000 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt +++ /dev/null @@ -1,38 +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 androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale - -@Composable -actual fun AsyncImage( - url: String, - contentDescription: String?, - contentScale: ContentScale, - modifier: Modifier, -) { - Box(modifier) { - coil.compose.AsyncImage( - modifier = Modifier.matchParentSize(), - model = url, - contentDescription = contentDescription, - contentScale = ContentScale.Crop - ) - } -} 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 478c11c83..000000000 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt +++ /dev/null @@ -1,50 +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.core.graphics.drawable.toBitmap -import coil.imageLoader -import coil.request.ImageRequest -import coil.request.SuccessResult -import coil.size.Scale -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: Int?): ImageBitmap? { - val requestBuilder = - ImageRequest.Builder(context) - .data(url) - .scale(Scale.FILL) - .allowHardware(false) - .memoryCacheKey("$url.dynamic_colors") - - if (size != null) { - requestBuilder.size(size) - } - - 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.kt index 7db33b847..cb660ba17 100644 --- a/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt @@ -15,11 +15,30 @@ */ package dev.sasikanth.rss.reader.di -import dev.sasikanth.rss.reader.components.AndroidImageLoader -import dev.sasikanth.rss.reader.components.ImageLoader +import android.content.Context +import com.seiko.imageloader.ImageLoader +import com.seiko.imageloader.component.setupDefaultComponents +import com.seiko.imageloader.defaultImageResultMemoryCache +import com.seiko.imageloader.option.androidContext +import dev.sasikanth.rss.reader.di.scopes.AppScope import me.tatarka.inject.annotations.Provides +import okio.Path.Companion.toOkioPath actual interface ImageLoaderComponent { - @Provides fun AndroidImageLoader.bind(): ImageLoader = this + @Provides + @AppScope + fun providesImageLoader(context: Context): ImageLoader { + return ImageLoader { + options { androidContext(context) } + components { setupDefaultComponents() } + interceptor { + defaultImageResultMemoryCache() + diskCacheConfig { + directory(context.cacheDir.resolve("image_cache").toOkioPath()) + maxSizeBytes(512L * 1024 * 1024) // 512MB + } + } + } + } } 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 d2e4e941d..7d53cce5b 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 @@ -29,12 +29,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation -import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.plus import com.arkivanov.essenty.backhandler.BackHandler +import com.seiko.imageloader.ImageLoader +import com.seiko.imageloader.LocalImageLoader import dev.sasikanth.rss.reader.bookmarks.ui.BookmarksScreen import dev.sasikanth.rss.reader.components.DynamicContentTheme -import dev.sasikanth.rss.reader.components.ImageLoader -import dev.sasikanth.rss.reader.components.LocalImageLoader import dev.sasikanth.rss.reader.components.rememberDynamicColorState import dev.sasikanth.rss.reader.home.ui.HomeScreen import dev.sasikanth.rss.reader.repository.BrowserType diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt index f14038bed..6534b8c7c 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt @@ -15,14 +15,39 @@ */ package dev.sasikanth.rss.reader.components +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.ContentScale +import com.seiko.imageloader.model.ImageRequest +import com.seiko.imageloader.option.SizeResolver +import com.seiko.imageloader.rememberImagePainter @Composable -expect fun AsyncImage( +fun AsyncImage( url: String, contentDescription: String?, - contentScale: ContentScale = ContentScale.Fit, + contentScale: ContentScale = ContentScale.Crop, modifier: Modifier = Modifier, -) + size: Size = Size(1024f, 1024f) +) { + val request = + remember(url) { + ImageRequest { + data(url) + size(SizeResolver(size)) + } + } + val painter = rememberImagePainter(request) + Box(modifier) { + Image( + modifier = Modifier.matchParentSize(), + painter = painter, + contentDescription = contentDescription, + contentScale = contentScale, + ) + } +} 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 6128dcf67..9f965c912 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 @@ -26,8 +26,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap +import com.seiko.imageloader.ImageLoader +import com.seiko.imageloader.LocalImageLoader +import com.seiko.imageloader.asImageBitmap +import com.seiko.imageloader.model.ImageRequest +import com.seiko.imageloader.model.ImageResult +import com.seiko.imageloader.option.SizeResolver 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,6 +46,8 @@ import dev.sasikanth.material.color.utilities.scheme.SchemeContent import dev.sasikanth.material.color.utilities.score.Score import dev.sasikanth.rss.reader.ui.AppTheme import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext private const val TINTED_BACKGROUND = "tinted_background" @@ -279,9 +288,15 @@ class DynamicColorState( return cached } - val image = imageLoader?.getImage(url, size = 128) - return if (image != null) { - extractColorsFromImage(image) + val imageRequest = ImageRequest { + data(url) + size(SizeResolver(Size(128f, 128f))) + } + val imageResult = + imageLoader?.async(imageRequest)?.filterIsInstance()?.first() + + return if (imageResult != null) { + extractColorsFromImage(imageResult.bitmap.asImageBitmap()) .let { colorsMap -> return@let if (colorsMap.isNotEmpty()) { DynamicColors( diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt deleted file mode 100644 index 56db075a5..000000000 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt +++ /dev/null @@ -1,25 +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 androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.ImageBitmap - -interface ImageLoader { - suspend fun getImage(url: String, size: Int?): ImageBitmap? -} - -val LocalImageLoader = staticCompositionLocalOf { null } 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 625a7715e..bf76f90b5 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.ImageLoader import dev.sasikanth.rss.reader.di.scopes.AppScope import dev.sasikanth.rss.reader.initializers.Initializer import dev.sasikanth.rss.reader.network.NetworkComponent @@ -27,8 +26,6 @@ import me.tatarka.inject.annotations.Provides abstract class SharedApplicationComponent : DataComponent, ImageLoaderComponent, SentryComponent, NetworkComponent { - abstract val imageLoader: ImageLoader - abstract val initializers: Set @Provides @AppScope fun DefaultDispatchersProvider.bind(): DispatchersProvider = this diff --git a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt deleted file mode 100644 index 67b0c9fef..000000000 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt +++ /dev/null @@ -1,51 +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 androidx.compose.foundation.Image -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.layout.ContentScale -import io.ktor.client.request.get -import org.jetbrains.skia.Image - -@Composable -actual fun AsyncImage( - url: String, - contentDescription: String?, - contentScale: ContentScale, - modifier: Modifier, -) { - Box(modifier) { - val imageState by rememberImageLoaderState(url) - - 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? - } - } - } -} 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 57fa58c92..000000000 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt +++ /dev/null @@ -1,196 +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.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.produceState -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asComposeImageBitmap -import dev.sasikanth.rss.reader.di.scopes.AppScope -import io.ktor.client.HttpClient -import io.ktor.client.engine.darwin.Darwin -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.client.statement.readBytes -import io.ktor.util.toMap -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.NSData -import platform.Foundation.NSHTTPURLResponse -import platform.Foundation.NSURL -import platform.Foundation.NSURLCache -import platform.Foundation.NSURLRequest -import platform.Foundation.create - -@Composable -internal fun rememberImageLoaderState(url: String?): State { - val initialState = - if (url.isNullOrBlank()) { - ImageLoaderState.Error - } else { - ImageLoaderState.Loading - } - val imageLoader = LocalImageLoader.current - - return produceState(initialState, url) { - value = - try { - ImageLoaderState.Loaded(imageLoader?.getImage(url!!, size = null)!!) - } catch (e: Exception) { - ImageLoaderState.Error - } - } -} - -internal sealed interface ImageLoaderState { - object Idle : ImageLoaderState - - object Loading : ImageLoaderState - - data class Loaded(val image: ImageBitmap) : ImageLoaderState - - object Error : ImageLoaderState -} - -@Inject -@AppScope -class IOSImageLoader : ImageLoader { - - private val memoryCacheSize = (10 * 1024 * 1024).toULong() // 10 MB cache size - private val diskCacheSize = (50 * 1024 * 1024).toULong() // 50 MB cache size - private val httpClient = - HttpClient(Darwin) { - engine { - configureRequest { - setTimeoutInterval(60.0) - setAllowsCellularAccess(true) - } - } - } - private val urlCache = - NSURLCache( - memoryCapacity = memoryCacheSize, - diskCapacity = diskCacheSize, - diskPath = "dev_sasikanth_rss_reader_images_cache" - ) - - override suspend fun getImage(url: String, size: Int?): ImageBitmap? { - return withContext(Dispatchers.IO) { - val cachedImage = loadCachedImage(url) - val data = - if (cachedImage != null) { - cachedImage - } else { - downloadImage(url) ?: return@withContext null - } - - return@withContext Image.makeFromEncoded(data).toBitmap(size).asComposeImageBitmap() - } - } - - private fun hasImageCache(url: String): Boolean { - val request = createNSURLRequest(url) ?: return false - return urlCache.cachedResponseForRequest(request) != 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) - - return response.readBytes().also { data -> - 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: Int? = null): Bitmap { - val width = size ?: this.width - val height = size ?: 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.kt b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt index 3886c7679..cd8bfebc4 100644 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/di/ImageLoaderComponent.kt @@ -15,11 +15,40 @@ */ package dev.sasikanth.rss.reader.di -import dev.sasikanth.rss.reader.components.IOSImageLoader -import dev.sasikanth.rss.reader.components.ImageLoader +import com.seiko.imageloader.ImageLoader +import com.seiko.imageloader.component.setupDefaultComponents +import com.seiko.imageloader.defaultImageResultMemoryCache +import dev.sasikanth.rss.reader.di.scopes.AppScope import me.tatarka.inject.annotations.Provides +import okio.Path.Companion.toPath +import platform.Foundation.NSCachesDirectory +import platform.Foundation.NSSearchPathForDirectoriesInDomains +import platform.Foundation.NSUserDomainMask actual interface ImageLoaderComponent { - @Provides fun IOSImageLoader.bind(): ImageLoader = this + @Provides + @AppScope + fun providesImageLoader(): ImageLoader { + return ImageLoader { + components { setupDefaultComponents() } + interceptor { + defaultImageResultMemoryCache() + + diskCacheConfig { + directory(getCacheDir().toPath().resolve("image_cache")) + maxSizeBytes(512L * 1024 * 1024) // 512MB + } + } + } + } + + private fun getCacheDir(): String { + return NSSearchPathForDirectoriesInDomains( + NSCachesDirectory, + NSUserDomainMask, + true, + ) + .first() as String + } }