From 06edb9eff461b92d45698101fa93751d3ed55a0e Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 16 Sep 2023 18:26:49 +0530 Subject: [PATCH 1/4] Fix incorrect version name and code when building release artifacts for Android on CI --- .github/workflows/android_prod_release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android_prod_release.yml b/.github/workflows/android_prod_release.yml index 5bfb60d70..f1a3e7a61 100644 --- a/.github/workflows/android_prod_release.yml +++ b/.github/workflows/android_prod_release.yml @@ -28,14 +28,14 @@ jobs: - name: Setup versionName regardless of how this action is triggered id: version_name run: | - WORKFLOW_INPUT=${{ steps.tramline.outputs.version_code }} + WORKFLOW_INPUT=${{ steps.tramline.outputs.version_name }} VERSION_NAME=${WORKFLOW_INPUT:-"1.0.0"} echo "ORG_GRADLE_PROJECT_VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV - name: Setup versionCode regardless of how this action is triggered id: version_code run: | - WORKFLOW_INPUT=${{ steps.tramline.outputs.version_name }} + WORKFLOW_INPUT=${{ steps.tramline.outputs.version_code }} VERSION_CODE=${WORKFLOW_INPUT:-"1"} echo "ORG_GRADLE_PROJECT_VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV From 12fc0c0ac425c75f7a122a228019bd6101bac296 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 16 Sep 2023 19:12:11 +0530 Subject: [PATCH 2/4] Change image loader dependency configuration to `api` instead of `implementation` This was causing issues with DI when providing `ImageLoader` for Android builds --- shared/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 71a7c1a48..de8c2544f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -109,7 +109,7 @@ kotlin { api(libs.okio) implementation(libs.paging.common) implementation(libs.paging.compose) - implementation(libs.imageloader) + api(libs.imageloader) } } val commonTest by getting { From 96ec0a302a50960b8e23db687d9f3645065f7818 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 16 Sep 2023 21:07:19 +0530 Subject: [PATCH 3/4] Revert "Replace custom image loader with image loader dependency (#99)" This reverts commit d3e2ccb8180895e8dcf5ecc33e7cf566bca54be0. While image-loader library is great, it was causing OOM when doing dynamic theming. For some reason I also noticed lot of "blinks" when switching from one image to another in featured image section. So, reverting back to my custom image loader --- gradle/libs.versions.toml | 4 +- shared/build.gradle.kts | 2 +- .../rss/reader/components/AsyncImage.kt | 38 ++++ .../rss/reader/components/ImageLoader.kt | 50 +++++ .../rss/reader/di/ImageLoaderComponent.kt | 25 +-- .../dev/sasikanth/rss/reader/app/App.kt | 5 +- .../rss/reader/components/AsyncImage.kt | 31 +-- .../reader/components/DynamicContentTheme.kt | 21 +- .../rss/reader/components/ImageLoader.kt | 25 +++ .../reader/di/SharedApplicationComponent.kt | 3 + .../rss/reader/components/AsyncImage.kt | 51 +++++ .../rss/reader/components/IOSImageLoader.kt | 196 ++++++++++++++++++ .../rss/reader/di/ImageLoaderComponent.kt | 35 +--- 13 files changed, 381 insertions(+), 105 deletions(-) create mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt create mode 100644 shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt create mode 100644 shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt create mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt create mode 100644 shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09623c6d7..bd540226f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ androidx_test_rules = "1.5.0" androidx_work = "2.8.1" androidx_datastore = "1.1.0-alpha05" androidx_browser = "1.6.0" +coil = "2.4.0" spotless = "6.21.0" ktfmt = "0.44" kotlininject = "0.6.3" @@ -41,7 +42,6 @@ lyricist = "1.4.2" atomicfu = "0.22.0" okio = "3.5.0" paging = "3.2.0-alpha05-0.2.3" -imageloader = "1.6.7" [libraries] compose_runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" } @@ -80,6 +80,7 @@ androidx_work = { module = "androidx.work:work-runtime-ktx", version.ref = "andr androidx_datastore_okio = { module = "androidx.datastore:datastore-core-okio", version.ref = "androidx_datastore" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx_datastore" } androidx_browser = { module = "androidx.browser:browser", version.ref = "androidx_browser" } +coil_compose = { module = "io.coil-kt:coil-compose", 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" } @@ -93,7 +94,6 @@ lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", versi okio = { module = "com.squareup.okio:okio", version.ref = "okio" } paging-common = { module = "app.cash.paging:paging-common", version.ref = "paging" } paging-compose = { module = "app.cash.paging:paging-compose-common", version.ref = "paging" } -imageloader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "imageloader" } [plugins] android_application = { id = "com.android.application", version.ref = "android_gradle_plugin" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index de8c2544f..1f406d57d 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -109,7 +109,6 @@ kotlin { api(libs.okio) implementation(libs.paging.common) implementation(libs.paging.compose) - api(libs.imageloader) } } val commonTest by getting { @@ -126,6 +125,7 @@ kotlin { api(libs.androidx.core) implementation(libs.ktor.client.okhttp) implementation(libs.sqldelight.driver.android) + implementation(libs.coil.compose) api(libs.sqliteAndroid) } } 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 new file mode 100644 index 000000000..41c8843c8 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt @@ -0,0 +1,38 @@ +/* + * 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 new file mode 100644 index 000000000..478c11c83 --- /dev/null +++ b/shared/src/androidMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt @@ -0,0 +1,50 @@ +/* + * 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 cb660ba17..7db33b847 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,30 +15,11 @@ */ package dev.sasikanth.rss.reader.di -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 dev.sasikanth.rss.reader.components.AndroidImageLoader +import dev.sasikanth.rss.reader.components.ImageLoader import me.tatarka.inject.annotations.Provides -import okio.Path.Companion.toOkioPath actual interface ImageLoaderComponent { - @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 - } - } - } - } + @Provides fun AndroidImageLoader.bind(): ImageLoader = this } 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 7d53cce5b..d2e4e941d 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,11 +29,12 @@ 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 6534b8c7c..f14038bed 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,39 +15,14 @@ */ 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 -fun AsyncImage( +expect fun AsyncImage( url: String, contentDescription: String?, - contentScale: ContentScale = ContentScale.Crop, + contentScale: ContentScale = ContentScale.Fit, 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 9f965c912..6128dcf67 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,15 +26,8 @@ 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 @@ -46,8 +39,6 @@ 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" @@ -288,15 +279,9 @@ class DynamicColorState( return cached } - 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()) + val image = imageLoader?.getImage(url, size = 128) + return if (image != null) { + extractColorsFromImage(image) .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 new file mode 100644 index 000000000..56db075a5 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/components/ImageLoader.kt @@ -0,0 +1,25 @@ +/* + * 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 bf76f90b5..625a7715e 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,6 +15,7 @@ */ 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 @@ -26,6 +27,8 @@ 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 new file mode 100644 index 000000000..67b0c9fef --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/AsyncImage.kt @@ -0,0 +1,51 @@ +/* + * 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 new file mode 100644 index 000000000..57fa58c92 --- /dev/null +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt @@ -0,0 +1,196 @@ +/* + * 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 cd8bfebc4..3886c7679 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,40 +15,11 @@ */ package dev.sasikanth.rss.reader.di -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 dev.sasikanth.rss.reader.components.IOSImageLoader +import dev.sasikanth.rss.reader.components.ImageLoader 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 - @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 - } + @Provides fun IOSImageLoader.bind(): ImageLoader = this } From da07523edfa130f470edf44d4fe99c605abb972d Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Sat, 16 Sep 2023 21:10:52 +0530 Subject: [PATCH 4/4] Use proper keys for image state in `IOSImageLoader#rememberImageLoaderState` This was causing issues were a different image was loading for few seconds before correct image is loaded --- .../rss/reader/components/IOSImageLoader.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) 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 index 57fa58c92..88f0ceb93 100644 --- a/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt +++ b/shared/src/iosMain/kotlin/dev/sasikanth/rss/reader/components/IOSImageLoader.kt @@ -18,8 +18,10 @@ package dev.sasikanth.rss.reader.components import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.produceState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asComposeImageBitmap import dev.sasikanth.rss.reader.di.scopes.AppScope @@ -62,15 +64,20 @@ internal fun rememberImageLoaderState(url: String?): State { ImageLoaderState.Loading } val imageLoader = LocalImageLoader.current + val result = remember(url, imageLoader) { mutableStateOf(initialState) } - return produceState(initialState, url) { - value = + LaunchedEffect(url) { + val imageLoaderState = try { ImageLoaderState.Loaded(imageLoader?.getImage(url!!, size = null)!!) } catch (e: Exception) { ImageLoaderState.Error } + + result.value = imageLoaderState } + + return result } internal sealed interface ImageLoaderState {