diff --git a/.editorconfig b/.editorconfig index a1c8aaad..5ce188c5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -20,3 +20,6 @@ ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 # @see https://youtrack.jetbrains.com/issue/KTIJ-21944 ij_kotlin_blank_lines_around_block_when_branches = 0 ij_kotlin_line_break_after_multiline_when_entry = false + +[*.yml] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes index 17dde093..dd66c79c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,2 @@ **/baseline-prof.txt linguist-generated=true -**/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text **/yarn.lock -diff diff --git a/.github/workflows/Screenshots.yml b/.github/workflows/CompareScreenshot.yml similarity index 63% rename from .github/workflows/Screenshots.yml rename to .github/workflows/CompareScreenshot.yml index a4e7ffd5..52e0949b 100644 --- a/.github/workflows/Screenshots.yml +++ b/.github/workflows/CompareScreenshot.yml @@ -2,28 +2,37 @@ name: CompareScreenshot on: push: + branches: + - master + pull_request: paths: - - 'image-loader/**' + - 'image-loader/src/**' -env: - GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx6g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" +permissions: { } jobs: - test: - runs-on: macos-latest + compare-screenshot-test: + runs-on: ubuntu-latest + timeout-minutes: 20 + + permissions: + contents: read # for clone + actions: write # for upload-artifact steps: - - uses: actions/checkout@v4 - with: - lfs: true + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - uses: actions/setup-java@v3.13.0 + - name: Set up JDK 17 + uses: actions/setup-java@0ab4596768b603586c0de567f2430c30f5b0d2b0 # v3.13.0 with: - distribution: 'zulu' - java-version: 19 + distribution: temurin + java-version: 17 - - name: Gradle cache - uses: gradle/gradle-build-action@v2 + - name: Setup Gradle + uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a # v2.9.0 + with: + gradle-version: wrapper # Download screenshots from main branch - uses: dawidd6/action-download-artifact@v2 @@ -35,7 +44,7 @@ jobs: - name: compare screenshot test id: compare-screenshot-test - run: ./gradlew compareRoborazziDebug --stacktrace --info + run: ./gradlew compareRoborazzi --stacktrace --info - uses: actions/upload-artifact@v3 if: ${{ always() }} diff --git a/.github/workflows/CompareScreenshotComment.yml b/.github/workflows/CompareScreenshotComment.yml new file mode 100644 index 00000000..760e431f --- /dev/null +++ b/.github/workflows/CompareScreenshotComment.yml @@ -0,0 +1,150 @@ +name: Screenshot compare comment + +on: + workflow_run: + workflows: + - CompareScreenshot + types: + - completed + +permissions: { } + +jobs: + comment-compare-screenshot-test: + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + + timeout-minutes: 2 + + permissions: + actions: read # for downloading artifacts + contents: write # for pushing screenshot-diff to companion branch + pull-requests: write # for creating a comment on pull requests + + runs-on: ubuntu-latest + + steps: + - uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0 + with: + name: pr + run_id: ${{ github.event.workflow_run.id }} + - id: get-pull-request-number + name: Get pull request number + shell: bash + run: | + echo "pull_request_number=$(cat NR)" >> "$GITHUB_OUTPUT" + - name: master checkout + id: checkout-master + uses: actions/checkout@v3 + with: + ref: master + - id: switch-companion-branch + env: + BRANCH_NAME: companion_${{ github.event.workflow_run.head_branch }} + run: | + # orphan means it will create no history branch + git branch -D "$BRANCH_NAME" || true + git checkout --orphan "$BRANCH_NAME" + git rm -rf . + - uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0 + with: + run_id: ${{ github.event.workflow_run.id }} + name: screenshot-diff + path: screenshot-diff + - id: check-if-there-are-valid-files + name: Check if there are valid files + shell: bash + run: | + # Find all the files ending with _compare.png + mapfile -t files_to_add < <(find . -type f -name "*_compare.png") + + # Check for invalid file names and add only valid ones + exist_valid_files="false" + for file in "${files_to_add[@]}"; do + if [[ $file =~ ^[a-zA-Z0-9_./-]+$ ]]; then + exist_valid_files="true" + break + fi + done + echo "exist_valid_files=$exist_valid_files" >> "$GITHUB_OUTPUT" + - id: push-screenshot-diff + shell: bash + if: steps.check-if-there-are-valid-files.outputs.exist_valid_files == 'true' + env: + BRANCH_NAME: companion_${{ github.event.workflow_run.head_branch }} + run: | + # Find all the files ending with _compare.png + files_to_add=$(find . -type f -name "*_compare.png") + + # Check for invalid file names and add only valid ones + for file in $files_to_add; do + if [[ "$file" =~ ^[a-zA-Z0-9_./-]+$ ]]; then + git add "$file" + fi + done + git config --global user.name ScreenshotBot + git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com + git commit -m "Add screenshot diff" + git push origin HEAD:"$BRANCH_NAME" -f + - id: generate-diff-reports + name: Generate diff reports + if: steps.check-if-there-are-valid-files.outputs.exist_valid_files == 'true' + env: + BRANCH_NAME: companion_${{ github.event.workflow_run.head_branch }} + shell: bash + run: | + # Find all the files ending with _compare.png in roborazzi folder + files=$(find . -type f -name "*_compare.png" | grep "roborazzi/" | grep -E "^[a-zA-Z0-9_./-]+$") + delimiter="$(openssl rand -hex 8)" + { + echo "reports<<${delimiter}" + + # Create markdown table header + echo "Snapshot diff report" + echo "| File name | Image |" + echo "|-------|-------|" + } >> "$GITHUB_OUTPUT" + + # Iterate over the files and create table rows + for file in $files; do + # Get the file name and insert newlines every 20 characters + fileName=$(basename "$file" | sed -r 's/(.{20})/\1
/g') + echo "| [$fileName](https://github.com/${{ github.repository }}/blob/$BRANCH_NAME/$file) | ![](https://github.com/${{ github.repository }}/blob/$BRANCH_NAME/$file?raw=true) |" >> "$GITHUB_OUTPUT" + done + echo "${delimiter}" >> "$GITHUB_OUTPUT" + - name: Find Comment + uses: peter-evans/find-comment@v2 + id: fc + if: steps.generate-diff-reports.outputs.reports != '' + with: + issue-number: ${{ steps.get-pull-request-number.outputs.pull_request_number }} + comment-author: 'github-actions[bot]' + body-includes: Snapshot diff report + + - name: Add or update comment on PR + uses: peter-evans/create-or-update-comment@v3 + if: steps.generate-diff-reports.outputs.reports != '' + with: + comment-id: ${{ steps.fc.outputs.comment-id }} + issue-number: ${{ steps.get-pull-request-number.outputs.pull_request_number }} + body: ${{ steps.generate-diff-reports.outputs.reports }} + edit-mode: replace + + - name: Cleanup outdated companion branches + run: | + # Find outdated companion branches with last commit date + git branch -r --format="%(refname:lstrip=3)" | grep companion_ | while read -r branch; do + last_commit_date_timestamp=$(git log -1 --format=%ct "origin/$branch") + now_timestamp=$(date +%s) + # Delete branch if it's older than 1 month + # if [ $((now_timestamp - last_commit_date_timestamp)) -gt 2592000 ]; then + # For testing purpose, delete branch if it's older than 1 second + echo "branch: $branch now_timestamp: $now_timestamp last_commit_date_timestamp: $last_commit_date_timestamp" + if [ $((now_timestamp - last_commit_date_timestamp)) -gt 1 ]; then + # Comment out for demonstration purpose + echo "Deleting $branch" + + # git push origin --delete "$branch" + fi + done diff --git a/.github/workflows/DocsDeploy.yml b/.github/workflows/DocsDeploy.yml index b5a66cb2..86a96b16 100644 --- a/.github/workflows/DocsDeploy.yml +++ b/.github/workflows/DocsDeploy.yml @@ -34,7 +34,7 @@ jobs: run: ./gradlew dokkaHtmlMultiModule --stacktrace - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 cache: yarn diff --git a/.github/workflows/DocsTest.yml b/.github/workflows/DocsTest.yml index bfd3cd22..8a2f518a 100644 --- a/.github/workflows/DocsTest.yml +++ b/.github/workflows/DocsTest.yml @@ -34,7 +34,7 @@ jobs: run: ./gradlew dokkaHtmlMultiModule --stacktrace - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 cache: yarn diff --git a/.github/workflows/StoreScreenshot.yml b/.github/workflows/StoreScreenshot.yml index 6b25faa8..88ca8f15 100644 --- a/.github/workflows/StoreScreenshot.yml +++ b/.github/workflows/StoreScreenshot.yml @@ -3,8 +3,12 @@ name: StoreScreenshot on: push: branches: - - main + - master pull_request: + paths: + - 'image-loader/src/**' + +run-name: "StoreScreenshot by ${{ github.actor }}" permissions: { } @@ -19,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Set up JDK 17 uses: actions/setup-java@0ab4596768b603586c0de567f2430c30f5b0d2b0 # v3.13.0 @@ -35,7 +39,7 @@ jobs: - name: record screenshot id: record-test - run: ./gradlew recordRoborazziDebug --stacktrace + run: ./gradlew recordRoborazzi --stacktrace - uses: actions/upload-artifact@v3 if: ${{ always() }} @@ -59,4 +63,4 @@ jobs: name: screenshot-test-results path: | **/build/test-results - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/README.md b/README.md index a72dfc00..bd616703 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,17 @@ kotlin { sourceSets { val commonMain by getting { dependencies { -+ api("io.github.qdsfdhvh:image-loader:1.6.8") ++ api("io.github.qdsfdhvh:image-loader:1.7.0") // optional - Moko Resources Decoder -+ api("io.github.qdsfdhvh:image-loader-extension-moko-resources:1.6.8") ++ api("io.github.qdsfdhvh:image-loader-extension-moko-resources:1.7.0") // optional - Blur Interceptor (only support bitmap) -+ api("io.github.qdsfdhvh:image-loader-extension-blur:1.6.8") ++ api("io.github.qdsfdhvh:image-loader-extension-blur:1.7.0") } } val jvmMain by getting { dependencies { // optional - ImageIO Decoder -+ api("io.github.qdsfdhvh:image-loader-extension-imageio:1.6.8") ++ api("io.github.qdsfdhvh:image-loader-extension-imageio:1.7.0") } } } @@ -58,18 +58,40 @@ fun Content() { CompositionLocalProvider( LocalImageLoader provides remember { generateImageLoader() }, ) { - val painter = rememberImagePainter("https://..") + // Option 1 on 1.7.0+ + AutoSizeImage( + "https://...", + contentDescription = "image", + ) + // Option 2 on 1.7.0+ + AutoSizeBox("https://...") { action -> + when (action) { + is ImageAction.Success -> { + Image( + rememberImageSuccessPainter(action), + contentDescription = "image", + ) + } + is ImageAction.Loading -> {} + is ImageAction.Failure -> {} + } + } + // Option 3 Image( - painter = painter, + painter = rememberImagePainter("https://.."), contentDescription = "image", ) } } ``` +Use priority: `AutoSizeImage` -> `AutoSizeBox` -> `rememberImagePainter`. + +`AutoSizeBox` & `AutoSizeImage` are based on **Modifier.Node**, `AutoSizeImage` ≈ `AutoSizeBox` + `Painter`. + #### in Android -```kotlin title="MainActivity.kt" +```kotlin fun generateImageLoader(): ImageLoader { return ImageLoader { options { @@ -96,7 +118,7 @@ fun generateImageLoader(): ImageLoader { #### in Jvm -```kotlin title="Main.kt" +```kotlin fun generateImageLoader(): ImageLoader { return ImageLoader { components { diff --git a/app/android/benchmark/build.gradle.kts b/app/android/benchmark/build.gradle.kts index 1aa7bc09..cdd23fc4 100644 --- a/app/android/benchmark/build.gradle.kts +++ b/app/android/benchmark/build.gradle.kts @@ -9,6 +9,7 @@ plugins { android { namespace = "com.seiko.imageloader.demo.benchmark" defaultConfig { + minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // TODO temporary until AGP 8.2, which no longer requires this. diff --git a/app/android/src/main/java/com/seiko/imageloader/demo/MainActivity.kt b/app/android/src/main/java/com/seiko/imageloader/demo/MainActivity.kt index 0a9bf110..a30ec844 100644 --- a/app/android/src/main/java/com/seiko/imageloader/demo/MainActivity.kt +++ b/app/android/src/main/java/com/seiko/imageloader/demo/MainActivity.kt @@ -5,9 +5,9 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember -import com.seiko.imageloader.DefaultAndroid import com.seiko.imageloader.ImageLoader import com.seiko.imageloader.LocalImageLoader +import com.seiko.imageloader.createDefaultAndroid import com.seiko.imageloader.demo.util.commonConfig class MainActivity : ComponentActivity() { @@ -24,7 +24,7 @@ class MainActivity : ComponentActivity() { private fun generateImageLoader(): ImageLoader { return ImageLoader { - takeFrom(ImageLoader.DefaultAndroid(applicationContext)) + takeFrom(ImageLoader.createDefaultAndroid(applicationContext)) commonConfig() } // return ImageLoader { diff --git a/app/common/build.gradle.kts b/app/common/build.gradle.kts index 5f86fd30..7a4ca1e8 100644 --- a/app/common/build.gradle.kts +++ b/app/common/build.gradle.kts @@ -7,9 +7,8 @@ plugins { } kotlin { - @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { + commonMain { dependencies { api(compose.runtime) api(compose.foundation) @@ -27,24 +26,24 @@ kotlin { implementation(libs.kermit) } } - val androidMain by getting { + androidMain { // https://github.com/icerockdev/moko-resources/issues/531 - dependsOn(commonMain) + dependsOn(commonMain.get()) dependencies { implementation(libs.ktor.client.cio) } } - val desktopMain by getting { + desktopMain { dependencies { implementation(libs.ktor.client.cio) } } - val appleMain by getting { + appleMain { dependencies { implementation(libs.ktor.client.darwin) } } - val jsMain by getting { + jsMain { dependencies { implementation(libs.ktor.client.js) } diff --git a/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/scene/Common.kt b/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/scene/Common.kt index 03eae010..74591bbe 100644 --- a/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/scene/Common.kt +++ b/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/scene/Common.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -28,12 +27,11 @@ import androidx.compose.ui.layout.ContentScale import com.seiko.imageloader.demo.model.Image import com.seiko.imageloader.demo.util.NullDataInterceptor import com.seiko.imageloader.demo.util.decodeJson -import com.seiko.imageloader.model.ImageEvent +import com.seiko.imageloader.model.ImageAction import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.model.ImageRequestBuilder -import com.seiko.imageloader.model.ImageResult -import com.seiko.imageloader.rememberImageAction -import com.seiko.imageloader.rememberImageActionPainter +import com.seiko.imageloader.rememberImageSuccessPainter +import com.seiko.imageloader.ui.AutoSizeBox import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -77,7 +75,7 @@ fun ImageItem( Box(modifier, Alignment.Center) { val dataState by rememberUpdatedState(data) val blockState by rememberUpdatedState(block) - val requestState = remember { + val request by remember { derivedStateOf { ImageRequest { data(dataState) @@ -92,27 +90,27 @@ fun ImageItem( } } } - val action by rememberImageAction(requestState) - val painter = rememberImageActionPainter(action) - Image( - painter = painter, - contentDescription = null, - contentScale = contentScale, - modifier = Modifier.fillMaxSize(), - ) - when (val current = action) { - is ImageEvent.StartWithDisk, - is ImageEvent.StartWithFetch, - -> { - CircularProgressIndicator() - } - is ImageResult.Source -> { - Text("image result is source") - } - is ImageResult.Error -> { - Text(current.error.message ?: "Error") + + AutoSizeBox( + request, + Modifier.matchParentSize(), + ) { action -> + when (action) { + is ImageAction.Loading -> { + CircularProgressIndicator() + } + is ImageAction.Success -> { + Image( + rememberImageSuccessPainter(action), + contentDescription = "image", + contentScale = contentScale, + modifier = Modifier.matchParentSize(), + ) + } + is ImageAction.Failure -> { + Text(action.error.message ?: "Error") + } } - else -> Unit } } } diff --git a/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/util/ImageLoader.kt b/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/util/ImageLoader.kt index aa4a12b7..a464b939 100644 --- a/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/util/ImageLoader.kt +++ b/app/common/src/commonMain/kotlin/com/seiko/imageloader/demo/util/ImageLoader.kt @@ -66,7 +66,7 @@ object NullDataInterceptor : Interceptor { override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val data = chain.request.data if (data === NullRequestData || data is String && data.isEmpty()) { - return ImageResult.Painter( + return ImageResult.OfPainter( painter = EmptyPainter, ) } diff --git a/app/intellij-plugin/build.gradle.kts b/app/intellij-plugin/build.gradle.kts index 94387650..591b17c3 100644 --- a/app/intellij-plugin/build.gradle.kts +++ b/app/intellij-plugin/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("org.jetbrains.intellij") version "1.15.0" + id("org.jetbrains.intellij") version "1.16.0" java id("app.kotlin.jvm") id("app.compose.multiplatform") diff --git a/app/ios-combine/build.gradle.kts b/app/ios-combine/build.gradle.kts index edbf3cfc..b8f15588 100644 --- a/app/ios-combine/build.gradle.kts +++ b/app/ios-combine/build.gradle.kts @@ -21,7 +21,7 @@ kotlin { version = "1.0.0" summary = "Shared code for the sample" homepage = "https://github.com/qdsfdhvh/compose-imageloader" - ios.deploymentTarget = "16.0" + ios.deploymentTarget = "14.0" podfile = project.file("../ios/Podfile") framework { baseName = "combine" diff --git a/app/ios-combine/src/commonMain/kotlin/com/seiko/imageloader/demo/Main.kt b/app/ios-combine/src/commonMain/kotlin/com/seiko/imageloader/demo/Main.kt index dcf4ff98..b34c8cf7 100644 --- a/app/ios-combine/src/commonMain/kotlin/com/seiko/imageloader/demo/Main.kt +++ b/app/ios-combine/src/commonMain/kotlin/com/seiko/imageloader/demo/Main.kt @@ -3,9 +3,9 @@ package com.seiko.imageloader.demo import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.window.ComposeUIViewController -import com.seiko.imageloader.DefaultIOS import com.seiko.imageloader.ImageLoader import com.seiko.imageloader.LocalImageLoader +import com.seiko.imageloader.createDefaultIOS import com.seiko.imageloader.demo.util.commonConfig import platform.UIKit.UIViewController @@ -20,7 +20,7 @@ fun MainViewController(): UIViewController = ComposeUIViewController { private fun generateImageLoader(): ImageLoader { return ImageLoader { - takeFrom(ImageLoader.DefaultIOS) + takeFrom(ImageLoader.createDefaultIOS()) commonConfig() } // return ImageLoader { diff --git a/app/macos/build.gradle.kts b/app/macos/build.gradle.kts index 12078f61..926b8c7b 100644 --- a/app/macos/build.gradle.kts +++ b/app/macos/build.gradle.kts @@ -30,19 +30,12 @@ kotlin { } } } - @Suppress("UNUSED_VARIABLE") sourceSets { - val macosMain by creating { + commonMain { dependencies { implementation(projects.app.common) } } - val macosX64Main by getting { - dependsOn(macosMain) - } - val macosArm64Main by getting { - dependsOn(macosMain) - } } } diff --git a/app/web/build.gradle.kts b/app/web/build.gradle.kts index 2279788f..abc74569 100644 --- a/app/web/build.gradle.kts +++ b/app/web/build.gradle.kts @@ -9,9 +9,8 @@ kotlin { browser() binaries.executable() } - @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { + commonMain { dependencies { implementation(projects.app.common) implementation(compose.runtime) @@ -19,9 +18,9 @@ kotlin { implementation(libs.moko.resources) } } - val jsMain by getting { + jsMain { // https://github.com/icerockdev/moko-resources/issues/531 - dependsOn(commonMain) + dependsOn(commonMain.get()) } } } diff --git a/build-logic/convention/src/main/kotlin/KotlinMultiplatformConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KotlinMultiplatformConventionPlugin.kt index 1e005fc0..91d302c2 100644 --- a/build-logic/convention/src/main/kotlin/KotlinMultiplatformConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KotlinMultiplatformConventionPlugin.kt @@ -24,7 +24,7 @@ class KotlinMultiplatformConventionPlugin : Plugin { nodejs() } @OptIn(ExperimentalKotlinGradlePluginApi::class) - targetHierarchy.custom { + applyHierarchyTemplate { common { group("jvm") { withAndroidTarget() @@ -49,6 +49,14 @@ class KotlinMultiplatformConventionPlugin : Plugin { } } } + targets.configureEach { + compilations.configureEach { + compilerOptions.configure { + // https://youtrack.jetbrains.com/issue/KT-61573 + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } } configKotlin() } diff --git a/build.gradle.kts b/build.gradle.kts index f84e74bf..54bef2f7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,10 +87,10 @@ object ProjectVersion { private const val major = "1" // functionality in a backwards compatible manner - private const val monir = "6" + private const val monir = "7" // backwards compatible bug fixes - private const val path = "8" + private const val path = "0" const val version = "$major.$monir.$path" } diff --git a/docs/docs/core/basic.md b/docs/docs/core/basic.md index 65b09eff..1d1684e5 100644 --- a/docs/docs/core/basic.md +++ b/docs/docs/core/basic.md @@ -5,13 +5,35 @@ Just use it like this for display image: ```kotlin -val painter = rememberImagePainter("https://..") +// Option 1 on 1.7.0+ +AutoSizeImage( + "https://...", + contentDescription = "image", +) +// Option 2 on 1.7.0+ +AutoSizeBox("https://...") { action -> + when (action) { + is ImageAction.Success -> { + Image( + rememberImageSuccessPainter(action), + contentDescription = "image", + ) + } + is ImageAction.Loading -> {} + is ImageAction.Failure -> {} + } +} +// Option 3 Image( - painter = painter, + painter = rememberImagePainter("https://.."), contentDescription = "image", ) ``` +Use priority: `AutoSizeImage` -> `AutoSizeBox` -> `rememberImagePainter`. + +`AutoSizeBox` & `AutoSizeImage` are based on **Modifier.Node**, `AutoSizeImage` ≈ `AutoSizeBox` + `Painter`. + PS: default `Imageloader` will reload when it's displayed, is not friendly for `https` link, so it is recommended to custom `ImageLoader` and configure the cache. ## Custom ImageLoader @@ -24,11 +46,7 @@ fun Content() { CompositionLocalProvider( LocalImageLoader provides remember { generateImageLoader() }, ) { - val painter = rememberImagePainter("https://..") - Image( - painter = painter, - contentDescription = "image", - ) + // App } } ``` @@ -45,6 +63,8 @@ fun generateImageLoader(): ImageLoader { setupDefaultComponents() } interceptor { + // cache 100 success image result, without bitmap + defaultImageResultMemoryCache() memoryCacheConfig { // Set the max size to 25% of the app's available memory. maxSizePercent(context, 0.25) @@ -67,6 +87,8 @@ fun generateImageLoader(): ImageLoader { setupDefaultComponents() } interceptor { + // cache 100 success image result, without bitmap + defaultImageResultMemoryCache() memoryCacheConfig { maxSizeBytes(32 * 1024 * 1024) // 32MB } @@ -95,7 +117,9 @@ fun generateImageLoader(): ImageLoader { components { setupDefaultComponents() } - interceptor { + interceptor { + // cache 100 success image result, without bitmap + defaultImageResultMemoryCache() memoryCacheConfig { maxSizeBytes(32 * 1024 * 1024) // 32MB } diff --git a/docs/docs/core/imageloader.md b/docs/docs/core/imageloader.md index 45729a31..aeacc8b2 100644 --- a/docs/docs/core/imageloader.md +++ b/docs/docs/core/imageloader.md @@ -11,22 +11,30 @@ interface ImageLoader { `ImageAction` structure is as follows: ```kotlin -sealed interface ImageAction - -sealed interface ImageEvent : ImageAction { - object Start : ImageEvent - object StartWithMemory : ImageEvent - object StartWithDisk : ImageEvent - object StartWithFetch : ImageEvent - data class Progress(val progress: Float) : ImageEvent +sealed interface ImageAction { + sealed interface Loading : ImageAction + sealed interface Success : ImageAction + sealed interface Failure : ImageAction { + val error: Throwable + } +} + +sealed interface ImageEvent : ImageAction.Loading { + data object Start : ImageEvent + data object StartWithMemory : ImageEvent + data object StartWithDisk : ImageEvent + data object StartWithFetch : ImageEvent } sealed interface ImageResult : ImageAction { - data class Source() : ImageResult - data class Bitmap() : ImageResult - data class Image() : ImageResult - data class Painter() : ImageResult - data class Error() : ImageResult + data class OfBitmap() : ImageResult, ImageAction.Success + data class OfImage() : ImageResult, ImageAction.Success + data class OfPainter() :ImageResult, ImageAction.Success + data class OfError(override val error: Throwable) : ImageResult, ImageAction.Failure + data class OfSource() : ImageResult, ImageAction.Failure { + override val error: Throwable + get() = IllegalStateException("failure to decode image source") + } } ``` @@ -35,7 +43,7 @@ sealed interface ImageResult : ImageAction { This is the most center feature of `ImageLoader`, The loading of the entire image is implemented by the default 3 + 2 interceptors: - **MappedInterceptor** -- MemoryCacheInterceptor +- MemoryCacheInterceptors - **DecodeInterceptor** - DiskCacheInterceptor - **FetchInterceptor** @@ -95,7 +103,6 @@ ImageLoader { retryIfDiskDecodeError = true imageConfig = Options.ImageConfig.ARGB_8888 scale = Scale.AUTO - sizeResolver = SizeResolver.Unspecified memoryCachePolicy = CachePolicy.ENABLED diskCachePolicy = CachePolicy.ENABLED playAnimate = true diff --git a/docs/docs/setup.mdx b/docs/docs/setup.mdx index 1aaa4f2a..5edce839 100644 --- a/docs/docs/setup.mdx +++ b/docs/docs/setup.mdx @@ -15,17 +15,17 @@ kotlin { sourceSets { val commonMain by getting { dependencies { -+ api("io.github.qdsfdhvh:image-loader:1.6.8") ++ api("io.github.qdsfdhvh:image-loader:1.7.0") // optional - Moko Resources Decoder -+ api("io.github.qdsfdhvh:image-loader-extension-moko-resources:1.6.8") ++ api("io.github.qdsfdhvh:image-loader-extension-moko-resources:1.7.0") // optional - Blur Interceptor (only support bitmap) -+ api("io.github.qdsfdhvh:image-loader-extension-blur:1.6.8") ++ api("io.github.qdsfdhvh:image-loader-extension-blur:1.7.0") } } val jvmMain by getting { dependencies { // optional - ImageIO Decoder -+ api("io.github.qdsfdhvh:image-loader-extension-imageio:1.6.8") ++ api("io.github.qdsfdhvh:image-loader-extension-imageio:1.7.0") } } } @@ -38,7 +38,7 @@ Copy the following snippets if you are using [gradle version catalog](https://do ```xml title="libs.versions.toml" [versions] -image-loader = "1.6.1" +image-loader = "1.7.0" [libraries] image-loader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "image-loader" } diff --git a/docs/src/pages/index.mdx b/docs/src/pages/index.mdx index b67889ac..66212ae8 100644 --- a/docs/src/pages/index.mdx +++ b/docs/src/pages/index.mdx @@ -11,9 +11,27 @@ ```kotlin @Composeable fun Content() { - val painter = rememberImagePainter("https://...") + // Option 1 on 1.7.0+ + AutoSizeImage( + "https://...", + contentDescription = "image", + ) + // Option 2 on 1.7.0+ + AutoSizeBox("https://...") { action -> + when (action) { + is ImageAction.Success -> { + Image( + rememberImageSuccessPainter(action), + contentDescription = "image", + ) + } + is ImageAction.Loading -> {} + is ImageAction.Failure -> {} + } + } + // Option 3 Image( - painter = painter, + painter = rememberImagePainter("https://.."), contentDescription = "image", ) } diff --git a/extension/blur/build.gradle.kts b/extension/blur/build.gradle.kts index 93b64e36..fcd92450 100644 --- a/extension/blur/build.gradle.kts +++ b/extension/blur/build.gradle.kts @@ -12,8 +12,7 @@ kotlin { implementation(projects.imageLoader) } } - @Suppress("UNUSED_VARIABLE") - val androidMain by getting { + androidMain { dependencies { implementation("com.github.android:renderscript-intrinsics-replacement-toolkit:9a70eae6f1") } diff --git a/extension/blur/src/commonMain/kotlin/com/seiko/imageloader/intercept/BlurInterceptor.kt b/extension/blur/src/commonMain/kotlin/com/seiko/imageloader/intercept/BlurInterceptor.kt index 586a5587..aa433f8e 100644 --- a/extension/blur/src/commonMain/kotlin/com/seiko/imageloader/intercept/BlurInterceptor.kt +++ b/extension/blur/src/commonMain/kotlin/com/seiko/imageloader/intercept/BlurInterceptor.kt @@ -9,9 +9,9 @@ class BlurInterceptor : Interceptor { override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val request = chain.request val result = chain.proceed(request) - if (result is ImageResult.Bitmap) { + if (result is ImageResult.OfBitmap) { val blurEffects = request.blurEffects ?: return result - return ImageResult.Bitmap( + return ImageResult.OfBitmap( bitmap = blur(result.bitmap, blurEffects.radius), ) } diff --git a/extension/imageio/build.gradle.kts b/extension/imageio/build.gradle.kts index 499ac873..77b15943 100644 --- a/extension/imageio/build.gradle.kts +++ b/extension/imageio/build.gradle.kts @@ -11,7 +11,7 @@ kotlin { dependencies { implementation(projects.imageLoader) // svg - implementation("com.twelvemonkeys.imageio:imageio-batik:3.9.4") + implementation("com.twelvemonkeys.imageio:imageio-batik:3.10.0") implementation("org.apache.xmlgraphics:batik-transcoder:1.17") } } diff --git a/extension/imageio/src/jvmMain/kotlin/com/seiko/imageloader/component/decoder/ImageIODecoder.kt b/extension/imageio/src/jvmMain/kotlin/com/seiko/imageloader/component/decoder/ImageIODecoder.kt index 85884517..e709ea08 100644 --- a/extension/imageio/src/jvmMain/kotlin/com/seiko/imageloader/component/decoder/ImageIODecoder.kt +++ b/extension/imageio/src/jvmMain/kotlin/com/seiko/imageloader/component/decoder/ImageIODecoder.kt @@ -15,13 +15,13 @@ class ImageIODecoder( val image = runInterruptible { ImageIO.read(source.inputStream()) } - return DecodeResult.Painter( + return DecodeResult.OfPainter( painter = image.toPainter(), ) } class Factory : Decoder.Factory { - override suspend fun create(source: DecodeSource, options: Options): Decoder? { + override fun create(source: DecodeSource, options: Options): Decoder? { if (isGif(source.source)) return null return ImageIODecoder(source.source) } diff --git a/extension/moko-resources/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.android.kt b/extension/moko-resources/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.android.kt index f5ff9633..caa0fcd1 100644 --- a/extension/moko-resources/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.android.kt +++ b/extension/moko-resources/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.android.kt @@ -13,26 +13,26 @@ import okio.buffer import okio.source internal actual suspend fun AssetResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Source( + return FetchResult.OfSource( source = getInputStream(options.androidContext).source().buffer(), ) } internal actual suspend fun ColorResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Painter( + return FetchResult.OfPainter( painter = ColorPainter(Color(getColor(options.androidContext))), ) } internal actual suspend fun FileResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Source( + return FetchResult.OfSource( source = options.androidContext.resources.openRawResource(rawResId).source().buffer(), ) } internal actual suspend fun ImageResource.toFetchResult(options: Options): FetchResult? { val drawable = requireNotNull(getDrawable(options.androidContext)) - return FetchResult.Image( + return FetchResult.OfImage( image = drawable.toImage(), ) } diff --git a/extension/moko-resources/src/desktopMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.desktop.kt b/extension/moko-resources/src/desktopMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.desktop.kt index e9309b73..8ec50ec0 100644 --- a/extension/moko-resources/src/desktopMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.desktop.kt +++ b/extension/moko-resources/src/desktopMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.desktop.kt @@ -16,7 +16,7 @@ internal actual suspend fun AssetResource.toFetchResult(options: Options): Fetch } internal actual suspend fun ColorResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Painter( + return FetchResult.OfPainter( painter = ColorPainter( Color( red = lightColor.red, @@ -31,7 +31,7 @@ internal actual suspend fun ColorResource.toFetchResult(options: Options): Fetch internal actual suspend fun FileResource.toFetchResult(options: Options): FetchResult? { val stream = resourcesClassLoader.getResourceAsStream(filePath) ?: throw FileNotFoundException("Couldn't open resource as stream at: $filePath") - return FetchResult.Source( + return FetchResult.OfSource( source = stream.source().buffer(), ) } @@ -39,7 +39,7 @@ internal actual suspend fun FileResource.toFetchResult(options: Options): FetchR internal actual suspend fun ImageResource.toFetchResult(options: Options): FetchResult? { val stream = resourcesClassLoader.getResourceAsStream(filePath) ?: throw FileNotFoundException("Couldn't open resource as stream at: $filePath") - return FetchResult.Source( + return FetchResult.OfSource( source = stream.source().buffer(), ) } diff --git a/extension/moko-resources/src/iosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.ios.kt b/extension/moko-resources/src/iosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.ios.kt index 82b10621..aea29635 100644 --- a/extension/moko-resources/src/iosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.ios.kt +++ b/extension/moko-resources/src/iosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.ios.kt @@ -46,7 +46,7 @@ internal actual suspend fun ColorResource.toFetchResult(options: Options): Fetch alpha = alpha.value.toFloat(), ) } - return FetchResult.Painter( + return FetchResult.OfPainter( painter = ColorPainter(color), ) } @@ -57,7 +57,7 @@ internal actual suspend fun FileResource.toFetchResult(options: Options): FetchR ofType = extension, inDirectory = "files", )!!.toPath() - return FetchResult.Source( + return FetchResult.OfSource( source = FileSystem.SYSTEM.source(path).buffer(), ) } @@ -68,7 +68,7 @@ internal actual suspend fun ImageResource.toFetchResult(options: Options): Fetch ?: throw IllegalArgumentException("can't read UIImage of $this") val cgImage: CGImageRef = uiImage.CGImage() ?: throw IllegalArgumentException("can't read CGImage of $this") - return FetchResult.Image( + return FetchResult.OfImage( image = cgImage.toSkiaImage(), ) } diff --git a/extension/moko-resources/src/jsMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.js.kt b/extension/moko-resources/src/jsMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.js.kt index 49bf0409..879d864a 100644 --- a/extension/moko-resources/src/jsMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.js.kt +++ b/extension/moko-resources/src/jsMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.js.kt @@ -14,25 +14,25 @@ import okio.BufferedSource import org.khronos.webgl.Int8Array internal actual suspend fun AssetResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Source( + return FetchResult.OfSource( source = fetchToSource(originalPath), ) } internal actual suspend fun ColorResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Painter( + return FetchResult.OfPainter( painter = ColorPainter(Color(lightColor.argb)), ) } internal actual suspend fun FileResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Source( + return FetchResult.OfSource( source = fetchToSource(fileUrl), ) } internal actual suspend fun ImageResource.toFetchResult(options: Options): FetchResult? { - return FetchResult.Source( + return FetchResult.OfSource( source = fetchToSource(fileUrl), ) } diff --git a/extension/moko-resources/src/macosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.macos.kt b/extension/moko-resources/src/macosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.macos.kt index e8fc390e..bd96e573 100644 --- a/extension/moko-resources/src/macosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.macos.kt +++ b/extension/moko-resources/src/macosMain/kotlin/com/seiko/imageloader/component/fetcher/MokoResourceFetcher.macos.kt @@ -24,7 +24,7 @@ internal actual suspend fun ColorResource.toFetchResult(options: Options): Fetch val nsColor = getNSColor() val deviceColor = nsColor.colorUsingColorSpace(deviceRGBColorSpace) ?: error("can't convert $nsColor to deviceRGBColorSpace") - return FetchResult.Painter( + return FetchResult.OfPainter( painter = ColorPainter( Color( red = deviceColor.redComponent.toFloat(), @@ -42,7 +42,7 @@ internal actual suspend fun FileResource.toFetchResult(options: Options): FetchR ofType = extension, inDirectory = "files", )!!.toPath() - return FetchResult.Source( + return FetchResult.OfSource( source = FileSystem.SYSTEM.source(path).buffer(), ) } @@ -56,7 +56,7 @@ internal actual suspend fun ImageResource.toFetchResult(options: Options): Fetch context = null, hints = null, ) ?: throw IllegalArgumentException("can't read CGImage of $this") - return FetchResult.Image( + return FetchResult.OfImage( image = cgImage.toSkiaImage(), ) } diff --git a/extension/nine-patch/src/commonMain/kotlin/com/seiko/imageloader/intercept/NinePatchInterceptor.kt b/extension/nine-patch/src/commonMain/kotlin/com/seiko/imageloader/intercept/NinePatchInterceptor.kt index 94c19ea1..cea77289 100644 --- a/extension/nine-patch/src/commonMain/kotlin/com/seiko/imageloader/intercept/NinePatchInterceptor.kt +++ b/extension/nine-patch/src/commonMain/kotlin/com/seiko/imageloader/intercept/NinePatchInterceptor.kt @@ -10,9 +10,9 @@ class NinePatchInterceptor : Interceptor { override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val request = chain.request val result = chain.proceed(request) - if (result is ImageResult.Bitmap) { + if (result is ImageResult.OfBitmap) { val centerSlice = request.ninePatchData ?: return result - return ImageResult.Painter( + return ImageResult.OfPainter( painter = NinePatchPainter( image = result.bitmap.asImageBitmap(), ninePatchData = centerSlice, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2f8986e..e1216a3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "8.1.2" -compose-multiplatform = "1.5.3" -kotlin = "1.9.10" +compose-multiplatform = "1.5.10" +kotlin = "1.9.20" kotlinx-coroutines = "1.7.3" kotlinx-serialization = "1.6.0" androidx-core-ktx = "1.12.0" @@ -13,21 +13,21 @@ androidx-lifecycle-runtime-ktx = "2.6.2" spotless = "6.22.0" ktlint = "0.50.0" publish = "0.25.3" -dokka = "1.9.0" +dokka = "1.9.10" moko-resources = "0.23.0" ktor = "2.3.5" okio = "3.6.0" uri-kmp = "0.0.15" kermit = "2.0.1" androidsvg = "1.4" -benchmark = "1.2.0-rc02" +benchmark = "1.2.0" junit = "4.13.2" androidx-test-junit = "1.1.5" androidx-test-espresso = "3.5.1" androidx-test-uiautomator = "2.2.0" profileinstaller = "1.3.1" -roborazzi = "1.6.0-rc-1" -robolectric = "4.10.3" +roborazzi = "1.8.0-alpha-4" +robolectric = "4.11.1" poko = "0.15.0" turbine = "1.0.0" diff --git a/image-loader/build.gradle.kts b/image-loader/build.gradle.kts index 96d2ab20..5d95c469 100644 --- a/image-loader/build.gradle.kts +++ b/image-loader/build.gradle.kts @@ -12,10 +12,15 @@ plugins { } kotlin { - @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { + all { + languageSettings { + optIn("kotlin.experimental.ExperimentalNativeApi") + } + } + commonMain { dependencies { + api(compose.foundation) api(compose.ui) api(libs.kotlinx.coroutines.core) api(libs.okio) @@ -23,7 +28,7 @@ kotlin { api(libs.uri.kmp) } } - val commonTest by getting { + commonTest { dependencies { implementation(kotlin("test")) implementation(libs.bundles.test.common) @@ -31,12 +36,7 @@ kotlin { implementation(compose.ui) } } - val jvmMain by getting { - dependencies { - implementation(libs.ktor.client.okhttp) - } - } - val androidMain by getting { + androidMain { dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.core.ktx) @@ -45,18 +45,18 @@ kotlin { implementation(libs.androidsvg) } } - val androidUnitTest by getting { + androidUnitTest { dependencies { implementation(compose.desktop.uiTestJUnit4) implementation(libs.bundles.test.android) } } - val desktopMain by getting { + desktopMain { dependencies { implementation(libs.kotlinx.coroutines.swing) } } - val desktopTest by getting { + desktopTest { languageSettings { enableLanguageFeature(LanguageFeature.ContextReceivers.name) } @@ -69,29 +69,19 @@ kotlin { } } } - val appleMain by getting { - dependencies { - implementation(libs.ktor.client.darwin) - } - } - val jsMain by getting { - dependencies { - implementation(libs.ktor.client.js) - } - } val noJsMain by creating { - dependsOn(commonMain) - jvmMain.dependsOn(this) - appleMain.dependsOn(this) + dependsOn(commonMain.get()) + jvmMain.get().dependsOn(this) + appleMain.get().dependsOn(this) dependencies { implementation(libs.androidx.collection) } } val noAndroidMain by creating { - dependsOn(commonMain) - desktopMain.dependsOn(this) - appleMain.dependsOn(this) - jsMain.dependsOn(this) + dependsOn(commonMain.get()) + desktopMain.get().dependsOn(this) + appleMain.get().dependsOn(this) + jsMain.get().dependsOn(this) } } sourceSets.all { diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/BitmapFactoryDecoder.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/BitmapFactoryDecoder.kt index 4dddf97c..f482fea6 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/BitmapFactoryDecoder.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/BitmapFactoryDecoder.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build.VERSION.SDK_INT +import androidx.compose.ui.geometry.isSpecified import com.seiko.imageloader.option.Options import com.seiko.imageloader.option.androidContext import com.seiko.imageloader.util.DEFAULT_MAX_PARALLELISM @@ -14,7 +15,7 @@ import com.seiko.imageloader.util.calculateInSampleSize import com.seiko.imageloader.util.computeSizeMultiplier import com.seiko.imageloader.util.isRotated import com.seiko.imageloader.util.isSwapped -import com.seiko.imageloader.util.toBitmapConfig +import com.seiko.imageloader.util.toAndroidConfig import com.seiko.imageloader.util.toSoftware import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Semaphore @@ -78,14 +79,14 @@ class BitmapFactoryDecoder private constructor( // Reverse the EXIF transformations to get the original image. val bitmap = ExifUtils.reverseTransformations(outBitmap, exifData) - return DecodeResult.Bitmap( + return DecodeResult.OfBitmap( bitmap = bitmap, ) } /** Compute and set [BitmapFactory.Options.inPreferredConfig]. */ private fun BitmapFactory.Options.configureConfig(exifData: ExifData) { - var config = options.imageConfig.toBitmapConfig() + var config = options.bitmapConfig.toAndroidConfig() // Disable hardware bitmaps if we need to perform EXIF transformations. if (exifData.isFlipped || exifData.isRotated) { @@ -118,7 +119,15 @@ class BitmapFactoryDecoder private constructor( // EXIF transformations (but before sampling). val srcWidth = if (exifData.isSwapped) outHeight else outWidth val srcHeight = if (exifData.isSwapped) outWidth else outHeight - val (dstWidth, dstHeight) = calculateDstSize(srcWidth, srcHeight, options.maxImageSize) + + val maxImageSize = if (options.size.isSpecified && !options.size.isEmpty()) { + minOf(options.size.width, options.size.height).toInt() + .coerceAtMost(options.maxImageSize) + } else { + options.maxImageSize + } + val (dstWidth, dstHeight) = calculateDstSize(srcWidth, srcHeight, maxImageSize) + // Calculate the image's sample size. inSampleSize = calculateInSampleSize( srcWidth = srcWidth, @@ -163,7 +172,7 @@ class BitmapFactoryDecoder private constructor( private val parallelismLock = Semaphore(maxParallelism) - override suspend fun create(source: DecodeSource, options: Options): Decoder { + override fun create(source: DecodeSource, options: Options): Decoder { return BitmapFactoryDecoder( context = context ?: options.androidContext, source = source, diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt index 5902c5b7..27354775 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt @@ -10,7 +10,7 @@ import com.seiko.imageloader.util.FrameDelayRewritingSource import com.seiko.imageloader.util.MovieDrawable import com.seiko.imageloader.util.isGif import com.seiko.imageloader.util.isHardware -import com.seiko.imageloader.util.toBitmapConfig +import com.seiko.imageloader.util.toAndroidConfig import kotlinx.coroutines.runInterruptible import okio.BufferedSource import okio.buffer @@ -40,7 +40,7 @@ class GifDecoder private constructor( val movie: Movie? = bufferedSource.use { Movie.decodeStream(it.inputStream()) } check(movie != null && movie.width() > 0 && movie.height() > 0) { "Failed to decode GIF." } - val config = options.imageConfig.toBitmapConfig() + val config = options.bitmapConfig.toAndroidConfig() val movieConfig = when { // movie.isOpaque && options.allowRgb565 -> Bitmap.Config.RGB_565 config.isHardware -> Bitmap.Config.ARGB_8888 @@ -64,7 +64,7 @@ class GifDecoder private constructor( // Set the animated transformation to be applied on each frame. // drawable.setAnimatedTransformation(options.parameters.animatedTransformation()) - DecodeResult.Image( + DecodeResult.OfImage( image = drawable.toImage(), ) } @@ -72,7 +72,7 @@ class GifDecoder private constructor( class Factory @JvmOverloads constructor( private val enforceMinimumFrameDelay: Boolean = true, ) : Decoder.Factory { - override suspend fun create(source: DecodeSource, options: Options): Decoder? { + override fun create(source: DecodeSource, options: Options): Decoder? { if (!options.playAnimate) return null if (!isGif(source.source)) return null return GifDecoder(source, options, enforceMinimumFrameDelay) diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/ImageDecoderDecoder.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/ImageDecoderDecoder.kt index 624efb92..4bc6c91d 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/ImageDecoderDecoder.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/ImageDecoderDecoder.kt @@ -21,7 +21,7 @@ import com.seiko.imageloader.util.isAnimatedHeif import com.seiko.imageloader.util.isAnimatedWebP import com.seiko.imageloader.util.isGif import com.seiko.imageloader.util.isHardware -import com.seiko.imageloader.util.toBitmapConfig +import com.seiko.imageloader.util.toAndroidConfig import kotlinx.coroutines.runInterruptible import okio.BufferedSource import okio.FileSystem @@ -62,7 +62,7 @@ class ImageDecoderDecoder private constructor( imageDecoder?.close() wrapDecodeSource.close() } - DecodeResult.Image( + DecodeResult.OfImage( image = wrapDrawable(drawable).toImage(), ) } @@ -101,7 +101,7 @@ class ImageDecoderDecoder private constructor( } private fun ImageDecoder.configureImageDecoderProperties() { - val config = options.imageConfig.toBitmapConfig() + val config = options.bitmapConfig.toAndroidConfig() allocator = if (config.isHardware) { ImageDecoder.ALLOCATOR_HARDWARE } else { @@ -175,7 +175,7 @@ class ImageDecoderDecoder private constructor( private val enforceMinimumFrameDelay: Boolean = true, ) : Decoder.Factory { - override suspend fun create(source: DecodeSource, options: Options): Decoder? { + override fun create(source: DecodeSource, options: Options): Decoder? { if (!options.playAnimate) return null if (!isApplicable(source.source)) return null return ImageDecoderDecoder( diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt index 9a6584ad..1a94751e 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt @@ -20,11 +20,8 @@ class SvgDecoder private constructor( override suspend fun decode(): DecodeResult { val svg = SVG.getFromInputStream(source.source.inputStream()) - val requestSize = options.sizeResolver.run { - density.size() - } - return DecodeResult.Painter( - painter = SVGPainter(svg, density, requestSize), + return DecodeResult.OfPainter( + painter = SVGPainter(svg, density, options.size), ) } @@ -32,7 +29,7 @@ class SvgDecoder private constructor( private val density: Density? = null, ) : Decoder.Factory { - override suspend fun create(source: DecodeSource, options: Options): Decoder? { + override fun create(source: DecodeSource, options: Options): Decoder? { if (!isApplicable(source)) return null return SvgDecoder( source = source, diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/AssetUriFetcher.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/AssetUriFetcher.kt index a8a7cc63..bbca5e1b 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/AssetUriFetcher.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/AssetUriFetcher.kt @@ -21,7 +21,7 @@ class AssetUriFetcher private constructor( override suspend fun fetch(): FetchResult { val path = data.pathSegments.drop(1).joinToString("/") - return FetchResult.Source( + return FetchResult.OfSource( source = context.assets.open(path).source().buffer(), extra = extraData { mimeType(MimeTypeMap.getSingleton().getMimeTypeFromUrl(path)) diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ContentUriFetcher.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ContentUriFetcher.kt index d58f2848..07137eaf 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ContentUriFetcher.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ContentUriFetcher.kt @@ -46,7 +46,7 @@ class ContentUriFetcher private constructor( checkNotNull(stream) { "Unable to open '$data'." } } - return FetchResult.Source( + return FetchResult.OfSource( source = inputStream.source().buffer(), extra = extraData { mimeType(contentResolver.getType(androidUri)) diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/DrawableFetcher.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/DrawableFetcher.kt index a98ee675..0c352809 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/DrawableFetcher.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/DrawableFetcher.kt @@ -10,11 +10,11 @@ class DrawableFetcher private constructor( ) : Fetcher { override suspend fun fetch(): FetchResult { return if (data is BitmapDrawable) { - FetchResult.Bitmap( + FetchResult.OfBitmap( bitmap = data.bitmap, ) } else { - FetchResult.Image( + FetchResult.OfImage( image = data.toImage(), ) } diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ResourceUriFetcher.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ResourceUriFetcher.kt index 2386d312..34f9bd5c 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ResourceUriFetcher.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/component/fetcher/ResourceUriFetcher.kt @@ -26,7 +26,7 @@ import com.seiko.imageloader.option.androidContext import com.seiko.imageloader.toImage import com.seiko.imageloader.util.DrawableUtils import com.seiko.imageloader.util.getMimeTypeFromUrl -import com.seiko.imageloader.util.toBitmapConfig +import com.seiko.imageloader.util.toAndroidConfig import okio.buffer import okio.source import org.xmlpull.v1.XmlPullParser @@ -63,27 +63,27 @@ class ResourceUriFetcher private constructor( val isVector = drawable.isVector if (isVector) { - FetchResult.Bitmap( + FetchResult.OfBitmap( bitmap = DrawableUtils.convertToBitmap( drawable = drawable, - config = options.imageConfig.toBitmapConfig(), + config = options.bitmapConfig.toAndroidConfig(), scale = options.scale, allowInexactSize = options.allowInexactSize, ), ) } else if (drawable is BitmapDrawable) { - FetchResult.Bitmap( + FetchResult.OfBitmap( bitmap = drawable.bitmap, ) } else { - FetchResult.Image( + FetchResult.OfImage( image = drawable.toImage(), ) } } else { val typedValue = TypedValue() val inputStream = resources.openRawResource(resId, typedValue) - FetchResult.Source( + FetchResult.OfSource( source = inputStream.source().buffer(), extra = extraData { mimeType(mimeType) diff --git a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/util/Bitmap.kt b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/util/Bitmap.kt index 2cd383d2..ef0bc2fb 100644 --- a/image-loader/src/androidMain/kotlin/com/seiko/imageloader/util/Bitmap.kt +++ b/image-loader/src/androidMain/kotlin/com/seiko/imageloader/util/Bitmap.kt @@ -3,7 +3,7 @@ package com.seiko.imageloader.util import android.graphics.Bitmap import android.os.Build import android.util.Log -import com.seiko.imageloader.option.Options +import com.seiko.imageloader.BitmapConfig internal val Bitmap.safeConfig: Bitmap.Config get() = config @@ -11,16 +11,16 @@ internal val Bitmap.safeConfig: Bitmap.Config internal val Bitmap.Config.isHardware: Boolean get() = Build.VERSION.SDK_INT >= 26 && this == Bitmap.Config.HARDWARE -internal fun Options.ImageConfig.toBitmapConfig(): Bitmap.Config = when (this) { - Options.ImageConfig.ALPHA_8 -> Bitmap.Config.ALPHA_8 - Options.ImageConfig.ARGB_8888 -> Bitmap.Config.ARGB_8888 - Options.ImageConfig.RGBA_F16 -> if (Build.VERSION.SDK_INT >= 26) { +internal fun BitmapConfig.toAndroidConfig(): Bitmap.Config = when (this) { + BitmapConfig.ALPHA_8 -> Bitmap.Config.ALPHA_8 + BitmapConfig.ARGB_8888 -> Bitmap.Config.ARGB_8888 + BitmapConfig.RGBA_F16 -> if (Build.VERSION.SDK_INT >= 26) { Bitmap.Config.RGBA_F16 } else { Log.w("ImageConfig", "ImageConfig.RGBA_F16 not support in android less than API 26") Bitmap.Config.ARGB_8888 } - Options.ImageConfig.HARDWARE -> if (Build.VERSION.SDK_INT >= 26) { + BitmapConfig.HARDWARE -> if (Build.VERSION.SDK_INT >= 26) { Bitmap.Config.HARDWARE } else { Log.w("ImageConfig", "ImageConfig.HARDWARE not support in android less than API 26") diff --git a/image-loader/src/androidMain/singleton/com/seiko/imageloader/ImageLoaderFactory.kt b/image-loader/src/androidMain/singleton/com/seiko/imageloader/ImageLoaderFactory.kt index 8ee4501c..9e925390 100644 --- a/image-loader/src/androidMain/singleton/com/seiko/imageloader/ImageLoaderFactory.kt +++ b/image-loader/src/androidMain/singleton/com/seiko/imageloader/ImageLoaderFactory.kt @@ -22,5 +22,5 @@ val Context.imageLoader: ImageLoader private fun Context.newImageLoader(): ImageLoader { currentImageLoader?.let { return it } return (applicationContext as? ImageLoaderFactory)?.newImageLoader() - ?: ImageLoader.DefaultAndroid(this) + ?: ImageLoader.createDefaultAndroid(this) } diff --git a/image-loader/src/androidMain/singleton/com/seiko/imageloader/LocalImageLoader.android.kt b/image-loader/src/androidMain/singleton/com/seiko/imageloader/LocalImageLoader.android.kt index e31f8a62..8d011593 100644 --- a/image-loader/src/androidMain/singleton/com/seiko/imageloader/LocalImageLoader.android.kt +++ b/image-loader/src/androidMain/singleton/com/seiko/imageloader/LocalImageLoader.android.kt @@ -29,8 +29,7 @@ actual fun createImageLoaderProvidableCompositionLocal() = ImageLoaderProvidable delegate = staticCompositionLocalOf { null }, ) -@Suppress("FunctionName") -fun ImageLoader.Companion.DefaultAndroid(context: Context): ImageLoader { +fun ImageLoader.Companion.createDefaultAndroid(context: Context): ImageLoader { return ImageLoader { options { androidContext(context.applicationContext) diff --git a/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt b/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt index 72df9e62..0fe05652 100644 --- a/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt +++ b/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt @@ -6,12 +6,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.github.takahirom.roborazzi.ExperimentalRoborazziApi -import com.github.takahirom.roborazzi.InternalRoborazziApi -import com.github.takahirom.roborazzi.RoborazziContext -import com.github.takahirom.roborazzi.RoborazziOptions -import com.github.takahirom.roborazzi.captureRoboImage -import org.junit.Before +import com.github.takahirom.roborazzi.RoborazziRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -26,31 +21,23 @@ class ChangeImageUrlTest : ChangeImageUrlCommonTest() { @get:Rule val composeTestRule = createAndroidComposeRule() - @OptIn(ExperimentalRoborazziApi::class, InternalRoborazziApi::class) - @Before - fun initRoborazziConfig() { - RoborazziContext.setRuleOverrideOutputDirectory( - outputDirectory = "src/androidUnitTest/snapshots/images", - ) - } + @get:Rule + val roborazziRule = RoborazziRule( + composeRule = composeTestRule, + captureRoot = composeTestRule.onRoot(), + options = RoborazziRule.Options( + captureType = RoborazziRule.CaptureType.AllImage(), + outputDirectoryPath = "build/outputs/roborazzi/android", + ), + ) @Test fun test_image_change() = with(composeTestRule) { setContent { TestUI() } - val roborazziOptions = RoborazziOptions( - captureType = RoborazziOptions.CaptureType.Screenshot(), - compareOptions = RoborazziOptions.CompareOptions(changeThreshold = 0F), - ) - onRoot().captureRoboImage( - roborazziOptions = roborazziOptions, - ) (0..2).forEach { _ -> onNodeWithTag(BUTTON_TAG).performClick() - onRoot().captureRoboImage( - roborazziOptions = roborazziOptions, - ) } } } diff --git a/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt b/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt index 939bcc8e..c1eff7aa 100644 --- a/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt +++ b/image-loader/src/androidUnitTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt @@ -25,7 +25,7 @@ class ComposeScreenShotTest : ComposeScreenShotCommonTest() { captureRoot = composeTestRule.onRoot(), options = RoborazziRule.Options( captureType = RoborazziRule.CaptureType.LastImage(), - outputDirectoryPath = "src/androidUnitTest/snapshots/images", + outputDirectoryPath = "build/outputs/roborazzi/android", ), ) diff --git a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change.png b/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change.png deleted file mode 100644 index bab35f16..00000000 --- a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8efefc34190788fa550753f68a91e24901c35db9d9f5aae00f037c6d93ee6a81 -size 244 diff --git a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_2.png b/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_2.png deleted file mode 100644 index 7089e409..00000000 --- a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_2.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bb405b2da07222a107615a7a375d4334e87061124154af487fa7e259df788e51 -size 245 diff --git a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_3.png b/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_3.png deleted file mode 100644 index c99cc537..00000000 --- a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_3.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:93e0b25a633563cf1482934edd6776f144b5b88d92e1e88d71a3c17672266470 -size 243 diff --git a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_4.png b/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_4.png deleted file mode 100644 index 4cc5ca44..00000000 --- a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_4.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:925fd1a1dccd1de52e89bd601b5854664362a5803caf491163e0f67505d347d6 -size 246 diff --git a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_error_painter.png b/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_error_painter.png deleted file mode 100644 index cd24e6a4..00000000 --- a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_error_painter.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e025c7f7560d3dcef1bb0d7371573048c5fc10f35435f30733f31ee5a55d6340 -size 310 diff --git a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_load_image.png b/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_load_image.png deleted file mode 100644 index ccfa73b3..00000000 --- a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_load_image.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4af898ef87c20b18fcafb2deaa5d50689182ff9f29e3ee8d79bb245778e56256 -size 816 diff --git a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_placeholder_painter.png b/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_placeholder_painter.png deleted file mode 100644 index e2aeb82a..00000000 --- a/image-loader/src/androidUnitTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_placeholder_painter.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a5154ac23b0ee9042e5f107c05782349f9aa544a8b0367b4145d2b767ef9480f -size 311 diff --git a/image-loader/src/appleMain/kotlin/com/seiko/imageloader/util/Platform.apple.kt b/image-loader/src/appleMain/kotlin/com/seiko/imageloader/util/Platform.apple.kt index fee06707..49431509 100644 --- a/image-loader/src/appleMain/kotlin/com/seiko/imageloader/util/Platform.apple.kt +++ b/image-loader/src/appleMain/kotlin/com/seiko/imageloader/util/Platform.apple.kt @@ -1,20 +1,7 @@ package com.seiko.imageloader.util -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.darwin.Darwin -import kotlinx.atomicfu.locks.SynchronizedObject import okio.FileSystem -import kotlin.experimental.ExperimentalNativeApi -@OptIn(ExperimentalNativeApi::class) actual typealias WeakReference = kotlin.native.ref.WeakReference -actual typealias LockObject = SynchronizedObject - -internal actual inline fun synchronized(lock: LockObject, block: () -> R): R { - return kotlinx.atomicfu.locks.synchronized(lock, block) -} - -internal actual val httpEngine: HttpClientEngine get() = Darwin.create() - internal actual val defaultFileSystem: FileSystem? get() = FileSystem.SYSTEM diff --git a/image-loader/src/appleMain/singleton/com/seiko/imageloader/LocalImageLoader.apple.kt b/image-loader/src/appleMain/singleton/com/seiko/imageloader/LocalImageLoader.apple.kt index c32b70df..a550da4c 100644 --- a/image-loader/src/appleMain/singleton/com/seiko/imageloader/LocalImageLoader.apple.kt +++ b/image-loader/src/appleMain/singleton/com/seiko/imageloader/LocalImageLoader.apple.kt @@ -9,12 +9,12 @@ import platform.Foundation.NSUserDomainMask actual fun createImageLoaderProvidableCompositionLocal() = ImageLoaderProvidableCompositionLocal( delegate = staticCompositionLocalOf { - ImageLoader.DefaultIOS + ImageLoader.createDefaultIOS() }, ) -val ImageLoader.Companion.DefaultIOS: ImageLoader - get() = ImageLoader { +fun ImageLoader.Companion.createDefaultIOS(): ImageLoader { + return ImageLoader { components { setupDefaultComponents() } @@ -29,6 +29,7 @@ val ImageLoader.Companion.DefaultIOS: ImageLoader } } } +} private fun getCacheDir(): String { return NSSearchPathForDirectoriesInDomains( diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Bitmap.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Bitmap.kt index 6ed2d167..930c0b54 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Bitmap.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Bitmap.kt @@ -12,6 +12,18 @@ internal expect val Bitmap.size: Int internal expect val Bitmap.identityHashCode: Int +enum class BitmapConfig { + ALPHA_8, + ARGB_8888, + RGBA_F16, + HARDWARE, + ; + + companion object { + val Default = ARGB_8888 + } +} + expect fun Bitmap.asImageBitmap(): ImageBitmap fun Bitmap.toPainter(filterQuality: FilterQuality = DefaultFilterQuality): Painter { diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ImageLoader.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ImageLoader.kt index 2e4ca4b8..3c03125e 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ImageLoader.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ImageLoader.kt @@ -1,35 +1,26 @@ package com.seiko.imageloader import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.isSpecified import com.seiko.imageloader.intercept.InterceptorChainImpl import com.seiko.imageloader.model.ImageAction import com.seiko.imageloader.model.ImageEvent import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.model.ImageResult +import com.seiko.imageloader.option.Options import com.seiko.imageloader.util.ioDispatcher import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.transformLatest import kotlin.coroutines.CoroutineContext @Immutable interface ImageLoader { val config: ImageLoaderConfig - fun async(requestFlow: Flow): Flow - - fun async(request: ImageRequest): Flow = async(flowOf(request)) - - @Deprecated("", ReplaceWith("Use imageloader.async(request).filterIsInstance().first()")) - suspend fun execute(request: ImageRequest): ImageResult { - return async(request).filterIsInstance().first() - } + fun async(request: ImageRequest): Flow companion object } @@ -47,21 +38,26 @@ private class RealImageLoader( private val requestCoroutineContext: CoroutineContext, override val config: ImageLoaderConfig, ) : ImageLoader { - @OptIn(ExperimentalCoroutinesApi::class) - override fun async(requestFlow: Flow) = requestFlow - .transformLatest { request -> - if (!request.skipEvent) { - emit(ImageEvent.Start) - } - val chain = InterceptorChainImpl( - initialRequest = request, - config = config, - flowCollector = this, - ) - emit(chain.proceed(request)) - }.catch { - if (it !is CancellationException) { - emit(ImageResult.Error(it)) + override fun async(request: ImageRequest) = flow { + if (!request.skipEvent) { + emit(ImageEvent.Start) + } + val initialSize = request.sizeResolver.size() + val options = Options(config.defaultOptions) { + if (initialSize.isSpecified) { + size = initialSize } - }.flowOn(requestCoroutineContext) + } + val chain = InterceptorChainImpl( + initialRequest = request, + initialOptions = options, + config = config, + flowCollector = this, + ) + emit(chain.proceed(request)) + }.catch { + if (it !is CancellationException) { + emit(ImageResult.OfError(it)) + } + }.flowOn(requestCoroutineContext) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/RememberExt.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Remember.ext.kt similarity index 94% rename from image-loader/src/commonMain/kotlin/com/seiko/imageloader/RememberExt.kt rename to image-loader/src/commonMain/kotlin/com/seiko/imageloader/Remember.ext.kt index 66015ecf..fd8edf2f 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/RememberExt.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Remember.ext.kt @@ -69,8 +69,8 @@ fun rememberImagePainter( request: ImageRequest, imageLoader: ImageLoader = LocalImageLoader.current, filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, - placeholderPainter: (@Composable () -> Painter)? = request.placeholderPainter, - errorPainter: (@Composable () -> Painter)? = request.errorPainter, + placeholderPainter: (@Composable () -> Painter)? = null, + errorPainter: (@Composable () -> Painter)? = null, ): Painter { val action by rememberImageAction(request, imageLoader) return rememberImageActionPainter( diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Remember.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Remember.kt index 795760b7..f8eabe33 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Remember.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/Remember.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.FilterQuality @@ -17,27 +16,6 @@ import com.seiko.imageloader.model.ImageEvent import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.model.ImageResult import com.seiko.imageloader.util.AnimationPainter -import kotlinx.coroutines.flow.Flow - -@Composable -fun rememberImageAction( - request: State, - imageLoader: ImageLoader = LocalImageLoader.current, -): State { - return remember(request, imageLoader) { - imageLoader.async(snapshotFlow { request.value }) - }.collectAsState(ImageEvent.Start) -} - -@Composable -fun rememberImageAction( - request: Flow, - imageLoader: ImageLoader = LocalImageLoader.current, -): State { - return remember(request, imageLoader) { - imageLoader.async(request) - }.collectAsState(ImageEvent.Start) -} @Composable fun rememberImageAction( @@ -57,12 +35,9 @@ fun rememberImageActionPainter( errorPainter: (@Composable () -> Painter)? = null, ): Painter { return when (action) { - is ImageEvent -> placeholderPainter?.invoke() ?: EmptyPainter - is ImageResult -> rememberImageResultPainter( - result = action, - filterQuality = filterQuality, - errorPainter = errorPainter, - ) + is ImageAction.Success -> rememberImageSuccessPainter(action, filterQuality) + is ImageAction.Loading -> placeholderPainter?.invoke() ?: EmptyPainter + is ImageAction.Failure -> errorPainter?.invoke() ?: EmptyPainter } } @@ -73,22 +48,30 @@ fun rememberImageResultPainter( errorPainter: (@Composable () -> Painter)? = null, ): Painter { return when (result) { - is ImageResult.Painter -> remember(result) { - result.painter + is ImageAction.Success -> rememberImageSuccessPainter(result, filterQuality) + is ImageAction.Failure -> errorPainter?.invoke() ?: EmptyPainter + } +} + +@Composable +fun rememberImageSuccessPainter( + action: ImageAction.Success, + filterQuality: FilterQuality = DefaultFilterQuality, +): Painter { + return when (action) { + is ImageResult.OfPainter -> remember(action) { + action.painter } - is ImageResult.Bitmap -> remember(result, filterQuality) { - result.bitmap.toPainter(filterQuality) + is ImageResult.OfBitmap -> remember(action, filterQuality) { + action.bitmap.toPainter(filterQuality) } - is ImageResult.Image -> remember(result) { - result.image.toPainter() + is ImageResult.OfImage -> remember(action) { + action.image.toPainter() } - is ImageResult.Error, - is ImageResult.Source, - -> errorPainter?.invoke() ?: EmptyPainter }.also { painter -> - if (painter is AnimationPainter) { + if (painter is AnimationPainter && painter.isPlay()) { LaunchedEffect(painter) { - while (painter.isPlay()) { + while (painter.nextPlay()) { withFrameMillis { frameTimeMillis -> painter.update(frameTimeMillis) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/RememberDeprecated.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/RememberDeprecated.kt deleted file mode 100644 index 0bbb9839..00000000 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/RememberDeprecated.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.seiko.imageloader - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.drawscope.DrawScope.Companion.DefaultFilterQuality -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.layout.ContentScale -import com.seiko.imageloader.model.ImageRequest -import com.seiko.imageloader.option.toScale - -@Deprecated( - message = "move contentScale into ImageRequest", - replaceWith = ReplaceWith("ImageRequest { scale(contentScale.toScale()) }"), -) -@Composable -fun rememberImagePainter( - url: String, - contentScale: ContentScale, - imageLoader: ImageLoader = LocalImageLoader.current, - filterQuality: FilterQuality = DefaultFilterQuality, - placeholderPainter: (@Composable () -> Painter)? = null, - errorPainter: (@Composable () -> Painter)? = null, -): Painter { - return rememberImagePainter( - request = remember(url, contentScale, placeholderPainter, errorPainter) { - ImageRequest { - data(url) - scale(contentScale.toScale()) - placeholderPainter?.let { placeholderPainter(it) } - errorPainter?.let { errorPainter(it) } - } - }, - imageLoader = imageLoader, - filterQuality = filterQuality, - ) -} - -@Deprecated( - message = "move contentScale into ImageRequest", - replaceWith = ReplaceWith("ImageRequest { scale(contentScale.toScale()) }"), -) -@Composable -fun rememberImagePainter( - resId: Int, - contentScale: ContentScale, - imageLoader: ImageLoader = LocalImageLoader.current, - filterQuality: FilterQuality = DefaultFilterQuality, - placeholderPainter: (@Composable () -> Painter)? = null, - errorPainter: (@Composable () -> Painter)? = null, -): Painter { - return rememberImagePainter( - request = remember(resId, contentScale, placeholderPainter, errorPainter) { - ImageRequest { - data(resId) - scale(contentScale.toScale()) - placeholderPainter?.let { placeholderPainter(it) } - errorPainter?.let { errorPainter(it) } - } - }, - imageLoader = imageLoader, - filterQuality = filterQuality, - ) -} - -@Deprecated( - message = "move contentScale into ImageRequest", - replaceWith = ReplaceWith("ImageRequest { scale(contentScale.toScale()) }"), -) -@Composable -fun rememberImagePainter( - request: ImageRequest, - contentScale: ContentScale, - imageLoader: ImageLoader = LocalImageLoader.current, - filterQuality: FilterQuality = DefaultFilterQuality, -): Painter { - return rememberImagePainter( - request = remember(request) { - ImageRequest(request) { - scale(contentScale.toScale()) - } - }, - imageLoader = imageLoader, - filterQuality = filterQuality, - ) -} - -@Deprecated("Use rememberImageAction&rememberImageActionPainter or rememberImagePainter") -@Composable -fun rememberAsyncImagePainter( - url: String, - contentScale: ContentScale = ContentScale.Fit, - imageLoader: ImageLoader = LocalImageLoader.current, - filterQuality: FilterQuality = DefaultFilterQuality, -): Painter { - val request = remember(url) { - ImageRequest { - data(url) - scale(contentScale.toScale()) - } - } - val action by rememberImageAction(request, imageLoader) - return rememberImageActionPainter(action, filterQuality) -} - -@Deprecated("Use rememberImageAction&rememberImageActionPainter or rememberImagePainter") -@Composable -fun rememberAsyncImagePainter( - resId: Int, - contentScale: ContentScale = ContentScale.Fit, - imageLoader: ImageLoader = LocalImageLoader.current, - filterQuality: FilterQuality = DefaultFilterQuality, -): Painter { - val request = remember(resId) { - ImageRequest { - data(resId) - scale(contentScale.toScale()) - } - } - val action by rememberImageAction(request, imageLoader) - return rememberImageActionPainter(action, filterQuality) -} - -@Deprecated("Use rememberImageAction&rememberImageActionPainter or rememberImagePainter") -@Composable -fun rememberAsyncImagePainter( - request: ImageRequest, - contentScale: ContentScale = ContentScale.Fit, - imageLoader: ImageLoader = LocalImageLoader.current, - filterQuality: FilterQuality = DefaultFilterQuality, -): Painter { - val newRequest = remember(request) { - ImageRequest(request) { - scale(contentScale.toScale()) - } - } - val action by rememberImageAction(newRequest, imageLoader) - return rememberImageActionPainter(action, filterQuality) -} diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/disk/DiskLruCache.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/disk/DiskLruCache.kt index e23dd803..63320551 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/disk/DiskLruCache.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/disk/DiskLruCache.kt @@ -1,18 +1,19 @@ package com.seiko.imageloader.cache.disk import com.seiko.imageloader.cache.disk.DiskLruCache.Editor -import com.seiko.imageloader.util.LockObject import com.seiko.imageloader.util.LruHashMap import com.seiko.imageloader.util.createFile import com.seiko.imageloader.util.deleteContents import com.seiko.imageloader.util.forEachIndices import com.seiko.imageloader.util.getOrPut -import com.seiko.imageloader.util.synchronized import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.internal.SynchronizedObject +import kotlinx.coroutines.internal.synchronized import kotlinx.coroutines.launch import okio.BufferedSink import okio.Closeable @@ -69,7 +70,7 @@ import okio.buffer * @param valueCount the number of values per cache entry. Must be positive. * @param maxSize the maximum number of bytes this cache should use to store. */ -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, InternalCoroutinesApi::class) internal class DiskLruCache( fileSystem: FileSystem, private val directory: Path, @@ -138,7 +139,7 @@ internal class DiskLruCache( private var mostRecentTrimFailed = false private var mostRecentRebuildFailed = false - private val syncObject = LockObject() + private val syncObject = SynchronizedObject() private val fileSystem = object : ForwardingFileSystem(fileSystem) { override fun sink(file: Path, mustCreate: Boolean): Sink { diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/memory/WeakMemoryCache.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/memory/WeakMemoryCache.kt index 979f0365..9996c4f1 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/memory/WeakMemoryCache.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/cache/memory/WeakMemoryCache.kt @@ -1,11 +1,12 @@ package com.seiko.imageloader.cache.memory import androidx.compose.ui.graphics.painter.Painter -import com.seiko.imageloader.util.LockObject import com.seiko.imageloader.util.WeakReference import com.seiko.imageloader.util.firstNotNullOfOrNullIndices import com.seiko.imageloader.util.removeIfIndices -import com.seiko.imageloader.util.synchronized +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.internal.SynchronizedObject +import kotlinx.coroutines.internal.synchronized /** * An in-memory cache that holds weak references to [Painter]s. @@ -30,6 +31,7 @@ internal open class EmptyWeakMemoryCache : WeakMemoryCache { } /** A [WeakMemoryCache] implementation backed by a [LinkedHashMap]. */ +@OptIn(InternalCoroutinesApi::class) internal open class RealWeakMemoryCache( private val valueHashProvider: (V) -> Int, ) : WeakMemoryCache { @@ -37,7 +39,7 @@ internal open class RealWeakMemoryCache( internal val cache = LinkedHashMap>>() private var operationsSinceCleanUp = 0 - private val syncObject = LockObject() + private val syncObject = SynchronizedObject() override val keys: Set get() = synchronized(syncObject) { cache.keys.toSet() } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistry.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistry.kt index 96317dd3..1ac0a18a 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistry.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistry.kt @@ -1,5 +1,6 @@ package com.seiko.imageloader.component +import com.seiko.imageloader.Poko import com.seiko.imageloader.component.decoder.DecodeSource import com.seiko.imageloader.component.decoder.Decoder import com.seiko.imageloader.component.fetcher.Fetcher @@ -8,11 +9,11 @@ import com.seiko.imageloader.component.mapper.Mapper import com.seiko.imageloader.option.Options import com.seiko.imageloader.util.forEachIndices -class ComponentRegistry internal constructor( - private val mappers: List>, - private val keyers: List, - private val fetcherFactories: List, - private val decoderFactories: List, +@Poko class ComponentRegistry internal constructor( + val mappers: List>, + val keyers: List, + val fetcherFactories: List, + val decoderFactories: List, ) { internal fun merge(component: ComponentRegistry) = ComponentRegistry( mappers = mappers + component.mappers, @@ -21,13 +22,6 @@ class ComponentRegistry internal constructor( decoderFactories = decoderFactories + component.decoderFactories, ) - internal fun newBuilder() = ComponentRegistryBuilder( - mappers = mappers.toMutableList(), - keyers = keyers.toMutableList(), - fetcherFactories = fetcherFactories.toMutableList(), - decoderFactories = decoderFactories.toMutableList(), - ) - fun map(data: Any, options: Options): Any { var mappedData = data mappers.forEachIndices { mapper -> @@ -55,7 +49,7 @@ class ComponentRegistry internal constructor( throw RuntimeException("Unable to create a fetcher that supports: $data") } - suspend fun decode( + fun decode( source: DecodeSource, options: Options, startIndex: Int = 0, diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistryBuilder.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistryBuilder.kt index 73d6a62c..55ad49a5 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistryBuilder.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/ComponentRegistryBuilder.kt @@ -5,29 +5,33 @@ import com.seiko.imageloader.component.fetcher.Fetcher import com.seiko.imageloader.component.keyer.Keyer import com.seiko.imageloader.component.mapper.Mapper -class ComponentRegistryBuilder( +class ComponentRegistryBuilder internal constructor( private val mappers: MutableList> = mutableListOf(), private val keyers: MutableList = mutableListOf(), private val fetcherFactories: MutableList = mutableListOf(), private val decoderFactories: MutableList = mutableListOf(), ) { + internal constructor(componentRegistry: ComponentRegistry) : this( + mappers = componentRegistry.mappers.toMutableList(), + keyers = componentRegistry.keyers.toMutableList(), + fetcherFactories = componentRegistry.fetcherFactories.toMutableList(), + decoderFactories = componentRegistry.decoderFactories.toMutableList(), + ) fun takeFrom( componentRegistry: ComponentRegistry, clearComponents: Boolean = false, ) { - componentRegistry.newBuilder().let { - if (clearComponents) { - mappers.clear() - keyers.clear() - fetcherFactories.clear() - decoderFactories.clear() - } - mappers.addAll(it.mappers) - keyers.addAll(it.keyers) - fetcherFactories.addAll(it.fetcherFactories) - decoderFactories.addAll(it.decoderFactories) + if (clearComponents) { + mappers.clear() + keyers.clear() + fetcherFactories.clear() + decoderFactories.clear() } + mappers.addAll(componentRegistry.mappers) + keyers.addAll(componentRegistry.keyers) + fetcherFactories.addAll(componentRegistry.fetcherFactories) + decoderFactories.addAll(componentRegistry.decoderFactories) } fun add(mapper: Mapper) { diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/SetupComponents.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/SetupComponents.kt index 3a2e9cdd..9a27fe35 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/SetupComponents.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/SetupComponents.kt @@ -4,7 +4,6 @@ import com.seiko.imageloader.component.fetcher.Base64Fetcher import com.seiko.imageloader.component.fetcher.BitmapFetcher import com.seiko.imageloader.component.fetcher.KtorUrlFetcher import com.seiko.imageloader.component.keyer.KtorUrlKeyer -import com.seiko.imageloader.component.mapper.Base64Mapper import com.seiko.imageloader.component.mapper.KtorUrlMapper import com.seiko.imageloader.component.mapper.StringUriMapper import com.seiko.imageloader.util.httpEngineFactory @@ -19,7 +18,6 @@ fun ComponentRegistryBuilder.setupKtorComponents( } fun ComponentRegistryBuilder.setupBase64Components() { - add(Base64Mapper()) add(Base64Fetcher.Factory()) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/decoder/Decoder.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/decoder/Decoder.kt index 9944c65a..350944d1 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/decoder/Decoder.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/decoder/Decoder.kt @@ -1,28 +1,29 @@ package com.seiko.imageloader.component.decoder +import androidx.compose.ui.graphics.painter.Painter +import com.seiko.imageloader.Bitmap +import com.seiko.imageloader.Image import com.seiko.imageloader.Poko import com.seiko.imageloader.model.ImageResult import com.seiko.imageloader.option.Options -typealias DecodeSource = ImageResult.Source +typealias DecodeSource = ImageResult.OfSource interface Decoder { suspend fun decode(): DecodeResult? - interface Factory { - suspend fun create(source: DecodeSource, options: Options): Decoder? + fun interface Factory { + fun create(source: DecodeSource, options: Options): Decoder? } } +fun Decoder(block: () -> DecodeResult?) = object : Decoder { + override suspend fun decode(): DecodeResult? = block() +} + sealed interface DecodeResult { - @Poko class Bitmap( - val bitmap: com.seiko.imageloader.Bitmap, - ) : DecodeResult + @Poko class OfBitmap(val bitmap: Bitmap) : DecodeResult - @Poko class Image( - val image: com.seiko.imageloader.Image, - ) : DecodeResult + @Poko class OfImage(val image: Image) : DecodeResult - @Poko class Painter( - val painter: androidx.compose.ui.graphics.painter.Painter, - ) : DecodeResult + @Poko class OfPainter(val painter: Painter) : DecodeResult } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Base64Fetcher.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Base64Fetcher.kt index d73518c1..402cf6b3 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Base64Fetcher.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Base64Fetcher.kt @@ -1,29 +1,38 @@ package com.seiko.imageloader.component.fetcher -import com.seiko.imageloader.component.mapper.Base64Image import com.seiko.imageloader.model.extraData import com.seiko.imageloader.model.mimeType import com.seiko.imageloader.option.Options +import io.ktor.util.decodeBase64Bytes import okio.Buffer class Base64Fetcher private constructor( - private val data: Base64Image, + private val data: String, ) : Fetcher { override suspend fun fetch(): FetchResult { - return FetchResult.Source( - source = Buffer().apply { - write(data.content) - }, - extra = extraData { - mimeType(data.contentType) - }, - ) + return data.split(',').let { + val contentType = it.firstOrNull()?.removePrefix("data:")?.removeSuffix(";base64") + val content = it.last() + FetchResult.OfSource( + source = Buffer().apply { + write(content.decodeBase64Bytes()) + }, + extra = extraData { + mimeType(contentType) + }, + ) + } } class Factory : Fetcher.Factory { override fun create(data: Any, options: Options): Fetcher? { - if (data !is Base64Image) return null + if (data !is String) return null + if (!isApplicable(data)) return null return Base64Fetcher(data) } + + private fun isApplicable(data: String): Boolean { + return data.startsWith("data:") + } } } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/BitmapFetcher.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/BitmapFetcher.kt index 30625f7c..198d6caa 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/BitmapFetcher.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/BitmapFetcher.kt @@ -7,7 +7,7 @@ class BitmapFetcher private constructor( private val data: Bitmap, ) : Fetcher { override suspend fun fetch(): FetchResult { - return FetchResult.Bitmap( + return FetchResult.OfBitmap( bitmap = data, ) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Fetcher.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Fetcher.kt index 9bbb4ca5..955cace8 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Fetcher.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/Fetcher.kt @@ -1,5 +1,8 @@ package com.seiko.imageloader.component.fetcher +import androidx.compose.ui.graphics.painter.Painter +import com.seiko.imageloader.Bitmap +import com.seiko.imageloader.Image import com.seiko.imageloader.Poko import com.seiko.imageloader.model.EmptyExtraData import com.seiko.imageloader.model.ExtraData @@ -13,21 +16,19 @@ interface Fetcher { } } +fun Fetcher(block: suspend () -> FetchResult?) = object : Fetcher { + override suspend fun fetch(): FetchResult? = block() +} + sealed interface FetchResult { - @Poko class Source( + @Poko class OfSource( val source: BufferedSource, val extra: ExtraData = EmptyExtraData, ) : FetchResult - @Poko class Bitmap( - val bitmap: com.seiko.imageloader.Bitmap, - ) : FetchResult + @Poko class OfBitmap(val bitmap: Bitmap) : FetchResult - @Poko class Image( - val image: com.seiko.imageloader.Image, - ) : FetchResult + @Poko class OfImage(val image: Image) : FetchResult - @Poko class Painter( - val painter: androidx.compose.ui.graphics.painter.Painter, - ) : FetchResult + @Poko class OfPainter(val painter: Painter) : FetchResult } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/KtorUrlFetcher.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/KtorUrlFetcher.kt index 21d23de7..18af6919 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/KtorUrlFetcher.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/fetcher/KtorUrlFetcher.kt @@ -24,7 +24,7 @@ class KtorUrlFetcher private constructor( url(httpUrl) } if (response.status.isSuccess()) { - return FetchResult.Source( + return FetchResult.OfSource( source = response.bodyAsChannel().source(), extra = extraData { mimeType(response.contentType()?.toString()) diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/keyer/KtorUrlKeyer.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/keyer/KtorUrlKeyer.kt index 5f68ac02..5ec24718 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/keyer/KtorUrlKeyer.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/keyer/KtorUrlKeyer.kt @@ -1,5 +1,6 @@ package com.seiko.imageloader.component.keyer +import com.seiko.imageloader.BitmapConfig import com.seiko.imageloader.option.Options import com.seiko.imageloader.option.Scale import com.seiko.imageloader.util.DEFAULT_MAX_IMAGE_SIZE @@ -23,8 +24,8 @@ class KtorUrlKeyer : Keyer { if (!options.premultipliedAlpha) { append("-premultipliedAlpha") } - if (options.imageConfig != Options.ImageConfig.ARGB_8888) { - append("-imageConfig=${options.imageConfig}") + if (options.bitmapConfig != BitmapConfig.Default) { + append("-imageConfig=${options.bitmapConfig}") } if (options.scale != Scale.FILL) { append("-scale=fit") diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/Base64Mapper.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/Base64Mapper.kt deleted file mode 100644 index 56c57e80..00000000 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/Base64Mapper.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.seiko.imageloader.component.mapper - -import com.seiko.imageloader.option.Options -import io.ktor.util.decodeBase64Bytes - -class Base64Image( - val contentType: String?, - val content: ByteArray, -) - -class Base64Mapper : Mapper { - override fun map(data: Any, options: Options): Base64Image? { - if (data !is String) return null - if (!isApplicable(data)) return null - return data.split(",").let { - val contentType = it.firstOrNull()?.removePrefix("data:")?.removeSuffix(";base64") - val content = it.last() - Base64Image( - contentType = contentType, - content = content.decodeBase64Bytes(), - ) - } - } - - private fun isApplicable(data: String): Boolean { - return data.startsWith("data:") - } -} diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/KtorUrlMapper.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/KtorUrlMapper.kt index 663606f7..744d92b2 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/KtorUrlMapper.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/KtorUrlMapper.kt @@ -11,6 +11,6 @@ class KtorUrlMapper : Mapper { } private fun isApplicable(data: String): Boolean { - return data.startsWith("http") + return data.startsWith("http:") || data.startsWith("https:") } } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/StringUriMapper.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/StringUriMapper.kt index 6cd496aa..4d5dae7d 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/StringUriMapper.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/component/mapper/StringUriMapper.kt @@ -6,6 +6,11 @@ import com.seiko.imageloader.option.Options class StringUriMapper : Mapper { override fun map(data: Any, options: Options): Uri? { if (data !is String) return null + // ignore data uri, see: https://en.wikipedia.org/wiki/Data_URI_scheme + if (data.startsWith("data:")) return null + // ignore http url + if (data.startsWith("http:")) return null + if (data.startsWith("https:")) return null return Uri.parseOrNull(data) } } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DecodeInterceptor.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DecodeInterceptor.kt index 00855d19..dce9935b 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DecodeInterceptor.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DecodeInterceptor.kt @@ -18,7 +18,7 @@ class DecodeInterceptor : Interceptor { private suspend fun proceed(chain: Interceptor.Chain, request: ImageRequest): ImageResult { val options = chain.options return when (val result = chain.proceed(request)) { - is ImageResult.Source -> { + is ImageResult.OfSource -> { runCatching { decode(chain.components, result, options) }.fold( @@ -68,8 +68,8 @@ class DecodeInterceptor : Interceptor { } } -private fun DecodeResult.toImageResult() = when (this) { - is DecodeResult.Bitmap -> ImageResult.Bitmap(bitmap = bitmap) - is DecodeResult.Image -> ImageResult.Image(image = image) - is DecodeResult.Painter -> ImageResult.Painter(painter = painter) +private fun DecodeResult.toImageResult(): ImageResult = when (this) { + is DecodeResult.OfBitmap -> ImageResult.OfBitmap(bitmap = bitmap) + is DecodeResult.OfImage -> ImageResult.OfImage(image = image) + is DecodeResult.OfPainter -> ImageResult.OfPainter(painter = painter) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DiskCacheInterceptor.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DiskCacheInterceptor.kt index 0107479f..bd5a0010 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DiskCacheInterceptor.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/DiskCacheInterceptor.kt @@ -43,7 +43,7 @@ class DiskCacheInterceptor( tag = "DiskCacheInterceptor", data = request.data, ) { "read disk cache" } - return ImageResult.Source( + return ImageResult.OfSource( source = snapshot.source(), dataSource = DataSource.Disk, ) @@ -53,7 +53,7 @@ class DiskCacheInterceptor( } val result = chain.proceed(request) when (result) { - is ImageResult.Source -> { + is ImageResult.OfSource -> { snapshot = runCatching { writeToDiskCache( options, @@ -73,7 +73,7 @@ class DiskCacheInterceptor( tag = "DiskCacheInterceptor", data = request.data, ) { "write disk cache" } - return ImageResult.Source( + return ImageResult.OfSource( source = snapshot.source(), dataSource = result.dataSource, extra = result.extra, diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/FetchInterceptor.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/FetchInterceptor.kt index e758674d..4a94c76c 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/FetchInterceptor.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/FetchInterceptor.kt @@ -41,18 +41,18 @@ class FetchInterceptor : Interceptor { } private fun FetchResult.toImageResult() = when (this) { - is FetchResult.Source -> ImageResult.Source( + is FetchResult.OfSource -> ImageResult.OfSource( source = source, dataSource = DataSource.Engine, extra = extra, ) - is FetchResult.Bitmap -> ImageResult.Bitmap( + is FetchResult.OfBitmap -> ImageResult.OfBitmap( bitmap = bitmap, ) - is FetchResult.Image -> ImageResult.Image( + is FetchResult.OfImage -> ImageResult.OfImage( image = image, ) - is FetchResult.Painter -> ImageResult.Painter( + is FetchResult.OfPainter -> ImageResult.OfPainter( painter = painter, ) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainHelper.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainHelper.kt index f8e21a50..e1716f89 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainHelper.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainHelper.kt @@ -4,16 +4,18 @@ import com.seiko.imageloader.ImageLoaderConfig import com.seiko.imageloader.model.ImageAction import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.option.Options +import com.seiko.imageloader.option.takeFrom import kotlinx.coroutines.flow.FlowCollector internal class InterceptorChainHelper( initialImageRequest: ImageRequest, + private val initialOptions: Options, private val config: ImageLoaderConfig, private val flowCollector: FlowCollector, ) { val logger get() = config.logger - private val interceptors by lazy { + val interceptors by lazy { initialImageRequest.interceptors?.plus(config.interceptors.list) ?: config.interceptors.list } @@ -24,17 +26,11 @@ internal class InterceptorChainHelper( } fun getOptions(request: ImageRequest): Options { - return Options(config.defaultOptions) { - request.optionsBuilders.forEach { builder -> - builder.invoke(this) - } + return Options(initialOptions) { + takeFrom(request) } } - fun getInterceptor(index: Int): Interceptor { - return interceptors[index] - } - suspend fun emit(action: ImageAction) { flowCollector.emit(action) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainImpl.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainImpl.kt index 855e0668..9ea2e51b 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainImpl.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/InterceptorChainImpl.kt @@ -17,11 +17,13 @@ internal class InterceptorChainImpl( constructor( initialRequest: ImageRequest, + initialOptions: Options, config: ImageLoaderConfig, flowCollector: FlowCollector, ) : this( helper = InterceptorChainHelper( initialImageRequest = initialRequest, + initialOptions = initialOptions, config = config, flowCollector = flowCollector, ), @@ -36,7 +38,7 @@ internal class InterceptorChainImpl( ) override suspend fun proceed(request: ImageRequest): ImageResult { - val interceptor = helper.getInterceptor(index) + val interceptor = helper.interceptors[index] val chain = copy(index = index + 1, request = request) return interceptor.intercept(chain) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/Interceptors.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/Interceptors.kt index cc582e8c..adeca9b2 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/Interceptors.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/intercept/Interceptors.kt @@ -92,16 +92,8 @@ class InterceptorsBuilder internal constructor() { } fun memoryCache( - mapToMemoryValue: (ImageResult) -> Bitmap? = { - if (it is ImageResult.Bitmap) { - it.bitmap - } else { - null - } - }, - mapToImageResult: (Bitmap) -> ImageResult? = { - ImageResult.Bitmap(it) - }, + mapToMemoryValue: (ImageResult) -> Bitmap? = { (it as? ImageResult.OfBitmap)?.bitmap }, + mapToImageResult: (Bitmap) -> ImageResult? = { ImageResult.OfBitmap(it) }, block: () -> MemoryCache, ) { memoryCaches.add( @@ -113,26 +105,6 @@ class InterceptorsBuilder internal constructor() { ) } - fun anyMemoryCacheConfig( - valueHashProvider: (T) -> Int, - valueSizeProvider: (T) -> Int, - mapToMemoryValue: (ImageResult) -> T?, - mapToImageResult: (T) -> ImageResult?, - block: MemoryCacheBuilder.() -> Unit, - ) { - anyMemoryCache( - mapToMemoryValue = mapToMemoryValue, - mapToImageResult = mapToImageResult, - block = { - MemoryCache( - valueHashProvider = valueHashProvider, - valueSizeProvider = valueSizeProvider, - block = block, - ) - }, - ) - } - fun anyMemoryCache( mapToMemoryValue: (ImageResult) -> T?, mapToImageResult: (T) -> ImageResult?, diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageAction.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageAction.kt index 2876fdf5..c8db14e7 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageAction.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageAction.kt @@ -1,20 +1,27 @@ package com.seiko.imageloader.model import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.painter.Painter +import com.seiko.imageloader.Bitmap +import com.seiko.imageloader.Image import com.seiko.imageloader.Poko import okio.BufferedSource @Immutable -sealed interface ImageAction +sealed interface ImageAction { + sealed interface Loading : ImageAction + sealed interface Success : ImageAction + sealed interface Failure : ImageAction { + val error: Throwable + } +} @Immutable -sealed interface ImageEvent : ImageAction { - object Start : ImageEvent - object StartWithMemory : ImageEvent - object StartWithDisk : ImageEvent - object StartWithFetch : ImageEvent - - @Poko class Progress(val progress: Float) : ImageEvent +sealed interface ImageEvent : ImageAction.Loading { + data object Start : ImageEvent + data object StartWithMemory : ImageEvent + data object StartWithDisk : ImageEvent + data object StartWithFetch : ImageEvent } @Immutable @@ -22,33 +29,28 @@ sealed interface ImageResult : ImageAction { @Immutable @Poko - class Source( - val source: BufferedSource, - val dataSource: DataSource, - val extra: ExtraData = EmptyExtraData, - ) : ImageResult + class OfBitmap(val bitmap: Bitmap) : ImageResult, ImageAction.Success @Immutable @Poko - class Bitmap( - val bitmap: com.seiko.imageloader.Bitmap, - ) : ImageResult + class OfImage(val image: Image) : ImageResult, ImageAction.Success @Immutable @Poko - class Image( - val image: com.seiko.imageloader.Image, - ) : ImageResult + class OfPainter(val painter: Painter) : ImageResult, ImageAction.Success @Immutable @Poko - class Painter( - val painter: androidx.compose.ui.graphics.painter.Painter, - ) : ImageResult + class OfError(override val error: Throwable) : ImageResult, ImageAction.Failure @Immutable @Poko - class Error( - val error: Throwable, - ) : ImageResult + class OfSource( + val source: BufferedSource, + val dataSource: DataSource, + val extra: ExtraData = EmptyExtraData, + ) : ImageResult, ImageAction.Failure { + override val error: Throwable + get() = IllegalStateException("failure to decode image source") + } } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageRequest.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageRequest.kt index 720aff3e..90485afd 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageRequest.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/model/ImageRequest.kt @@ -1,8 +1,6 @@ package com.seiko.imageloader.model -import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.painter.Painter import com.seiko.imageloader.component.ComponentRegistry import com.seiko.imageloader.component.ComponentRegistryBuilder import com.seiko.imageloader.intercept.Interceptor @@ -13,37 +11,69 @@ import com.seiko.imageloader.option.SizeResolver @Immutable class ImageRequest internal constructor( val data: Any, - val optionsBuilders: List Unit>, val extra: ExtraData, - val placeholderPainter: (@Composable () -> Painter)?, - val errorPainter: (@Composable () -> Painter)?, + val sizeResolver: SizeResolver, val skipEvent: Boolean, + internal val optionsBuilders: List Unit>, internal val components: ComponentRegistry?, internal val interceptors: List?, ) { - @Deprecated("", ReplaceWith("ImageRequest(request) {}")) - fun newBuilder(block: ImageRequestBuilder.() -> Unit) = ImageRequest(this, block) + + override fun equals(other: Any?): Boolean { + return other is ImageRequest && + data == other.data && + extra == other.extra && + sizeResolver == other.sizeResolver && + skipEvent == other.skipEvent && + optionsBuilders == other.optionsBuilders && + components == other.components && + interceptors == other.interceptors + } + + override fun hashCode(): Int { + var result = data.hashCode() + result = 31 * result + extra.hashCode() + result = 31 * result + sizeResolver.hashCode() + result = 31 * result + skipEvent.hashCode() + result = 31 * result + optionsBuilders.hashCode() + result = 31 * result + components.hashCode() + result = 31 * result + interceptors.hashCode() + return result + } + + override fun toString(): String { + return "ImageRequest(" + + "data=$data," + + "extra=$extra," + + "sizeResolver=$sizeResolver," + + "skipEvent=$skipEvent," + + "optionsBuilders=$optionsBuilders," + + "components=$components," + + "interceptors=$interceptors)" + } } class ImageRequestBuilder internal constructor() { private var data: Any? = null + private var sizeResolver: SizeResolver = SizeResolver.Unspecified private val optionsBuilders: MutableList Unit> = mutableListOf() private var extraData: ExtraData? = null - private var placeholderPainter: (@Composable () -> Painter)? = null - private var errorPainter: (@Composable () -> Painter)? = null private var componentBuilder: ComponentRegistryBuilder? = null private var interceptors: MutableList? = null var skipEvent: Boolean = false - fun takeFrom(request: ImageRequest) { + fun takeFrom( + request: ImageRequest, + clearOptions: Boolean = false, + ) { data = request.data - optionsBuilders.clear() + if (clearOptions) { + optionsBuilders.clear() + } optionsBuilders.addAll(request.optionsBuilders) extraData = request.extra - placeholderPainter = request.placeholderPainter - errorPainter = request.errorPainter - componentBuilder = request.components?.newBuilder() + componentBuilder = request.components?.let { ComponentRegistryBuilder(it) } interceptors = request.interceptors?.toMutableList() skipEvent = request.skipEvent } @@ -53,9 +83,7 @@ class ImageRequestBuilder internal constructor() { } fun size(sizeResolver: SizeResolver) { - optionsBuilders.add { - this.sizeResolver = sizeResolver - } + this.sizeResolver = sizeResolver } fun scale(scale: Scale) { @@ -84,20 +112,11 @@ class ImageRequestBuilder internal constructor() { ?: extraData(builder) } - fun placeholderPainter(loader: @Composable () -> Painter) { - placeholderPainter = loader - } - - fun errorPainter(loader: @Composable () -> Painter) { - errorPainter = loader - } - internal fun build() = ImageRequest( data = data ?: NullRequestData, + sizeResolver = sizeResolver, optionsBuilders = optionsBuilders, extra = extraData ?: EmptyExtraData, - placeholderPainter = placeholderPainter, - errorPainter = errorPainter, skipEvent = skipEvent, components = componentBuilder?.build(), interceptors = interceptors, diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/Options.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/Options.kt index c60f3378..83e5f52b 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/Options.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/Options.kt @@ -1,10 +1,13 @@ package com.seiko.imageloader.option +import androidx.compose.ui.geometry.Size +import com.seiko.imageloader.BitmapConfig import com.seiko.imageloader.Poko import com.seiko.imageloader.cache.CachePolicy import com.seiko.imageloader.model.EmptyExtraData import com.seiko.imageloader.model.ExtraData import com.seiko.imageloader.model.ExtraDataBuilder +import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.model.extraData import com.seiko.imageloader.util.DEFAULT_MAX_IMAGE_SIZE @@ -12,9 +15,9 @@ import com.seiko.imageloader.util.DEFAULT_MAX_IMAGE_SIZE val allowInexactSize: Boolean, val premultipliedAlpha: Boolean, val retryIfDiskDecodeError: Boolean, - val imageConfig: ImageConfig, + val bitmapConfig: BitmapConfig, + val size: Size, val scale: Scale, - val sizeResolver: SizeResolver, val memoryCachePolicy: CachePolicy, val diskCachePolicy: CachePolicy, val playAnimate: Boolean, @@ -22,17 +25,6 @@ import com.seiko.imageloader.util.DEFAULT_MAX_IMAGE_SIZE val maxImageSize: Int, val extra: ExtraData, ) { - - @Deprecated("", ReplaceWith("Options(options) {}")) - fun newBuilder(block: OptionsBuilder.() -> Unit) = Options(this, block) - - enum class ImageConfig { - ALPHA_8, - ARGB_8888, - RGBA_F16, - HARDWARE, - } - companion object { internal const val REPEAT_INFINITE = -1 } @@ -43,9 +35,9 @@ class OptionsBuilder internal constructor() { var allowInexactSize: Boolean = false var premultipliedAlpha: Boolean = true var retryIfDiskDecodeError: Boolean = true - var imageConfig: Options.ImageConfig = Options.ImageConfig.ARGB_8888 + var bitmapConfig: BitmapConfig = BitmapConfig.Default + var size: Size = Size.Unspecified var scale: Scale = Scale.FILL - var sizeResolver: SizeResolver = SizeResolver.Unspecified var memoryCachePolicy: CachePolicy = CachePolicy.ENABLED var diskCachePolicy: CachePolicy = CachePolicy.ENABLED var playAnimate: Boolean = true @@ -66,9 +58,9 @@ class OptionsBuilder internal constructor() { allowInexactSize = options.allowInexactSize premultipliedAlpha = options.premultipliedAlpha retryIfDiskDecodeError = options.retryIfDiskDecodeError - imageConfig = options.imageConfig + bitmapConfig = options.bitmapConfig + size = options.size scale = options.scale - sizeResolver = options.sizeResolver memoryCachePolicy = options.memoryCachePolicy diskCachePolicy = options.diskCachePolicy playAnimate = options.playAnimate @@ -94,9 +86,9 @@ class OptionsBuilder internal constructor() { allowInexactSize = allowInexactSize, premultipliedAlpha = premultipliedAlpha, retryIfDiskDecodeError = retryIfDiskDecodeError, - imageConfig = imageConfig, + bitmapConfig = bitmapConfig, + size = size, scale = scale, - sizeResolver = sizeResolver, memoryCachePolicy = memoryCachePolicy, diskCachePolicy = diskCachePolicy, playAnimate = playAnimate, @@ -106,6 +98,12 @@ class OptionsBuilder internal constructor() { ) } +internal fun OptionsBuilder.takeFrom(request: ImageRequest) { + request.optionsBuilders.forEach { builder -> + builder.invoke(this) + } +} + fun Options(block: OptionsBuilder.() -> Unit = {}) = OptionsBuilder().apply(block).build() diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/SizeResolver.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/SizeResolver.kt index 0f4abb94..24440456 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/SizeResolver.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/option/SizeResolver.kt @@ -1,25 +1,45 @@ package com.seiko.imageloader.option import androidx.compose.ui.geometry.Size -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.DpSize +import kotlinx.coroutines.CompletableDeferred interface SizeResolver { - suspend fun Density.size(): Size + suspend fun size(): Size companion object { - val Unspecified = SizeResolver(Size.Unspecified) + val Unspecified: SizeResolver = RealSizeResolver(Size.Unspecified) } } fun SizeResolver(block: suspend () -> Size) = object : SizeResolver { - override suspend fun Density.size(): Size = block() + override suspend fun size(): Size = block() } -fun SizeResolver(size: Size): SizeResolver = object : SizeResolver { - override suspend fun Density.size(): Size = size +class AsyncSizeResolver : SizeResolver { + + private val sizeObserver = CompletableDeferred() + + override suspend fun size(): Size { + return sizeObserver.await() + } + + fun setSize(size: Size) { + sizeObserver.complete(size) + } } -fun SizeResolver(size: DpSize): SizeResolver = object : SizeResolver { - override suspend fun Density.size(): Size = size.toSize() +fun SizeResolver(size: Size): SizeResolver = RealSizeResolver(size) + +private class RealSizeResolver(private val size: Size) : SizeResolver { + override suspend fun size(): Size = size + + override fun equals(other: Any?): Boolean { + return other is RealSizeResolver && size == other.size + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + size.hashCode() + return result + } } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeBox.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeBox.kt new file mode 100644 index 00000000..ef809b56 --- /dev/null +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeBox.kt @@ -0,0 +1,209 @@ +package com.seiko.imageloader.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.Constraints +import com.seiko.imageloader.ImageLoader +import com.seiko.imageloader.LocalImageLoader +import com.seiko.imageloader.model.ImageAction +import com.seiko.imageloader.model.ImageEvent +import com.seiko.imageloader.model.ImageRequest +import com.seiko.imageloader.option.AsyncSizeResolver +import com.seiko.imageloader.option.SizeResolver +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +@Composable +fun AutoSizeBox( + request: ImageRequest, + modifier: Modifier = Modifier, + imageLoader: ImageLoader = LocalImageLoader.current, + contentAlignment: Alignment = Alignment.Center, + propagateMinConstraints: Boolean = false, + isOnlyPostFirstEvent: Boolean = true, + content: @Composable BoxScope.(ImageAction) -> Unit, +) { + var action by remember { + mutableStateOf(ImageEvent.Start) + } + Box( + modifier = modifier.autoSizeBoxNode( + request = request, + imageLoader = imageLoader, + onImageActionChange = { action = it }, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + ), + contentAlignment = contentAlignment, + propagateMinConstraints = propagateMinConstraints, + ) { + content(action) + } +} + +private fun Modifier.autoSizeBoxNode( + request: ImageRequest, + imageLoader: ImageLoader, + onImageActionChange: (ImageAction) -> Unit, + isOnlyPostFirstEvent: Boolean, +): Modifier = this then AutoSizeBoxNodeElement( + request = request, + imageLoader = imageLoader, + onImageActionChange = onImageActionChange, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, +) + +private data class AutoSizeBoxNodeElement( + val request: ImageRequest, + val imageLoader: ImageLoader, + val onImageActionChange: (ImageAction) -> Unit, + val isOnlyPostFirstEvent: Boolean, +) : ModifierNodeElement() { + + override fun create(): AutoSizeBoxNode { + return AutoSizeBoxNode( + request = request, + imageLoader = imageLoader, + onImageActionChange = onImageActionChange, + ) + } + + override fun update(node: AutoSizeBoxNode) { + node.update( + request = request, + imageLoader = imageLoader, + onImageActionChange = onImageActionChange, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + ) + } + + override fun InspectorInfo.inspectableProperties() { + name = "autoSizeBox" + properties["request"] = request + properties["imageLoader"] = imageLoader + properties["onImageActionChange"] = onImageActionChange + } +} + +private class AutoSizeBoxNode( + request: ImageRequest, + private var imageLoader: ImageLoader, + private var onImageActionChange: (ImageAction) -> Unit, +) : Modifier.Node(), LayoutModifierNode { + + private var currentImageJob: Job? = null + + private var cachedSize: Size = Size.Unspecified + + private var isReset = false + + private var request: ImageRequest = modifyRequest(request, cachedSize) + + override val shouldAutoInvalidate: Boolean + get() = false + + override fun onAttach() { + super.onAttach() + isReset = false + launchImage() + } + + override fun onReset() { + super.onReset() + isReset = true + } + + override fun onDetach() { + super.onDetach() + if (!isReset) { + cachedSize = Size.Unspecified + } + } + + fun update( + request: ImageRequest, + imageLoader: ImageLoader, + onImageActionChange: (ImageAction) -> Unit, + isOnlyPostFirstEvent: Boolean, + ) { + val finalRequest = modifyRequest( + request = request, + cachedSize = cachedSize, + skipEvent = isOnlyPostFirstEvent, + ) + val isRequestChange = this.request != finalRequest + + this.request = finalRequest + this.imageLoader = imageLoader + this.onImageActionChange = onImageActionChange + + if (isAttached && isRequestChange) { + launchImage() + } + } + + private fun launchImage() { + currentImageJob?.cancel() + currentImageJob = coroutineScope.launch { + imageLoader.async(request).collect { action -> + onImageActionChange(action) + } + } + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + cachedSize = constraints.inferredSize() + + val sizeResolver = request.sizeResolver + if (sizeResolver is AsyncSizeResolver) { + sizeResolver.setSize(cachedSize) + } + + val placeable = measurable.measure(constraints) + return layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } +} + +internal fun modifyRequest( + request: ImageRequest, + cachedSize: Size, + skipEvent: Boolean = false, +): ImageRequest { + return if (request.sizeResolver == SizeResolver.Unspecified) { + ImageRequest(request) { + if (cachedSize.isSpecified && !cachedSize.isEmpty()) { + size(SizeResolver(cachedSize)) + } else { + size(AsyncSizeResolver()) + } + this.skipEvent = skipEvent + } + } else { + request + } +} + +internal data class CachedPositionAndSize( + val position: Offset, + val size: Size, +) diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeImage.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeImage.kt new file mode 100644 index 00000000..c3adfec8 --- /dev/null +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeImage.kt @@ -0,0 +1,484 @@ +package com.seiko.imageloader.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.withSave +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.times +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.node.invalidateMeasurement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.toOffset +import com.seiko.imageloader.ImageLoader +import com.seiko.imageloader.LocalImageLoader +import com.seiko.imageloader.model.ImageEvent +import com.seiko.imageloader.model.ImageRequest +import com.seiko.imageloader.model.ImageResult +import com.seiko.imageloader.option.AsyncSizeResolver +import com.seiko.imageloader.toPainter +import com.seiko.imageloader.util.AnimationPainter +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +// AutoSizeImage ≈ AutoSizeBox + Painter +@Composable +fun AutoSizeImage( + request: ImageRequest, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + imageLoader: ImageLoader = LocalImageLoader.current, + placeholderPainter: (@Composable () -> Painter)? = null, + errorPainter: (@Composable () -> Painter)? = null, + isOnlyPostFirstEvent: Boolean = true, +) { + val semantics = if (contentDescription != null) { + Modifier.semantics { + this.contentDescription = contentDescription + this.role = Role.Image + } + } else { + Modifier + } + Layout( + modifier.then(semantics).clipToBounds().autoSizeImageNode( + request = request, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + imageLoader = imageLoader, + placeholderPainter = placeholderPainter?.invoke(), + errorPainter = errorPainter?.invoke(), + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + ), + ) { _, constraints -> + layout(constraints.minWidth, constraints.minHeight) {} + } +} + +private fun Modifier.autoSizeImageNode( + request: ImageRequest, + alignment: Alignment, + contentScale: ContentScale, + alpha: Float, + colorFilter: ColorFilter?, + imageLoader: ImageLoader, + placeholderPainter: Painter?, + errorPainter: Painter?, + isOnlyPostFirstEvent: Boolean, +): Modifier = this then AutoSizeImageNodeElement( + request = request, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + imageLoader = imageLoader, + placeholderPainter = placeholderPainter, + errorPainter = errorPainter, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, +) + +private data class AutoSizeImageNodeElement( + private val request: ImageRequest, + private val alignment: Alignment, + private val contentScale: ContentScale, + private val alpha: Float, + private val colorFilter: ColorFilter?, + private val imageLoader: ImageLoader, + private val placeholderPainter: Painter?, + private val errorPainter: Painter?, + private val isOnlyPostFirstEvent: Boolean, +) : ModifierNodeElement() { + + override fun create(): AutoSizeImageNode { + return AutoSizeImageNode( + request = request, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + imageLoader = imageLoader, + placeholderPainter = placeholderPainter, + errorPainter = errorPainter, + ) + } + + override fun update(node: AutoSizeImageNode) { + node.update( + request = request, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + imageLoader = imageLoader, + placeholderPainter = placeholderPainter, + errorPainter = errorPainter, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + ) + } + + override fun InspectorInfo.inspectableProperties() { + name = "autoSizeImage" + properties["request"] = request + properties["alignment"] = alignment + properties["contentScale"] = contentScale + properties["alpha"] = alpha + properties["colorFilter"] = colorFilter + properties["imageLoader"] = imageLoader + properties["placeholderPainter"] = placeholderPainter + properties["errorPainter"] = errorPainter + } +} + +private class AutoSizeImageNode( + request: ImageRequest, + private var alignment: Alignment, + private var contentScale: ContentScale, + private var alpha: Float, + private var colorFilter: ColorFilter?, + private var imageLoader: ImageLoader, + private var placeholderPainter: Painter?, + private var errorPainter: Painter?, +) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, CompositionLocalConsumerModifierNode { + + private var currentImageJob: Job? = null + private var currentPlayerJob: Job? = null + + private var cachedSize: Size = Size.Unspecified + + private var drawPainter: Painter? = null + private var drawPainterPositionAndSize: CachedPositionAndSize? = null + + private var hasFixedSize: Boolean = false + private var isReset: Boolean = false + + private var request: ImageRequest = modifyRequest(request, cachedSize) + + override val shouldAutoInvalidate: Boolean + get() = false + + override fun onAttach() { + super.onAttach() + isReset = false + launchImage() + } + + override fun onReset() { + super.onReset() + isReset = true + } + + override fun onDetach() { + super.onDetach() + // if this node is reset from pool, not need to reset size + if (!isReset) { + hasFixedSize = false + cachedSize = Size.Unspecified + } + updatePainter(null) + } + + fun update( + request: ImageRequest, + alignment: Alignment, + contentScale: ContentScale, + alpha: Float, + colorFilter: ColorFilter?, + imageLoader: ImageLoader, + placeholderPainter: Painter?, + errorPainter: Painter?, + isOnlyPostFirstEvent: Boolean, + ) { + val finalRequest = modifyRequest( + request = request, + cachedSize = cachedSize, + skipEvent = isOnlyPostFirstEvent, + ) + val isRequestChange = this.request != finalRequest + + this.request = finalRequest + this.alignment = alignment + this.contentScale = contentScale + this.alpha = alpha + this.colorFilter = colorFilter + this.imageLoader = imageLoader + this.placeholderPainter = placeholderPainter + this.errorPainter = errorPainter + + if (isAttached && isRequestChange) { + launchImage() + } + } + + private fun launchImage() { + currentImageJob?.cancel() + currentImageJob = coroutineScope.launch { + imageLoader.async(request).collect { action -> + when (action) { + is ImageEvent -> placeholderPainter + is ImageResult.OfPainter -> action.painter + is ImageResult.OfBitmap -> action.bitmap.toPainter() + is ImageResult.OfImage -> action.image.toPainter() + is ImageResult.OfError -> errorPainter + is ImageResult.OfSource -> errorPainter + }?.let { painter -> + updatePainter(painter) + } + } + } + } + + private fun updatePainter(painter: Painter?) { + (drawPainter as? RememberObserver)?.onAbandoned() + currentPlayerJob?.cancel() + currentPlayerJob = null + + drawPainter = painter + + if (isAttached) { + (painter as? RememberObserver)?.onRemembered() + checkPainterPlay() + } + + drawPainterPositionAndSize = null + + if (hasFixedSize) { + invalidateDraw() + } else { + invalidateMeasurement() + } + } + + private fun checkPainterPlay() { + val painter = drawPainter ?: return + if (painter is AnimationPainter && painter.isPlay()) { + currentPlayerJob?.cancel() + currentPlayerJob = coroutineScope.launch { + while (painter.nextPlay()) { + withFrameMillis { frameTimeMillis -> + painter.update(frameTimeMillis) + } + } + } + } + } + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints, + ): MeasureResult { + drawPainterPositionAndSize = null + hasFixedSize = constraints.hasFixedSize() + cachedSize = constraints.inferredSize() + + val sizeResolver = request.sizeResolver + if (sizeResolver is AsyncSizeResolver) { + sizeResolver.setSize(cachedSize) + } + + val placeable = measurable.measure( + modifyConstraints(constraints, drawPainter, contentScale), + ) + return layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + + override fun ContentDrawScope.draw() { + drawPainter?.let { painter -> + drawContext.canvas.withSave { + val positionAndSize = drawPainterPositionAndSize + ?: calcPositionAndSize(painter).also { + drawPainterPositionAndSize = it + } + val dx = positionAndSize.position.x + val dy = positionAndSize.position.y + val scaledSize = positionAndSize.size + translate(dx, dy) { + with(painter) { + draw(scaledSize, alpha, colorFilter) + } + } + } + } + + // Maintain the same pattern as Modifier.drawBehind to allow chaining of DrawModifiers + drawContent() + } + + private fun ContentDrawScope.calcPositionAndSize(painter: Painter): CachedPositionAndSize { + val intrinsicSize = painter.intrinsicSize + val srcWidth = if (intrinsicSize.hasSpecifiedAndFiniteWidth()) { + intrinsicSize.width + } else { + size.width + } + + val srcHeight = if (intrinsicSize.hasSpecifiedAndFiniteHeight()) { + intrinsicSize.height + } else { + size.height + } + val srcSize = Size(srcWidth, srcHeight) + + // Compute the offset to translate the content based on the given alignment + // and size to draw based on the ContentScale parameter + val scaledSize = if (size.width != 0f && size.height != 0f) { + srcSize * contentScale.computeScaleFactor(srcSize, size) + } else { + Size.Zero + } + + val alignedPosition = alignment.align( + IntSize(scaledSize.width.roundToInt(), scaledSize.height.roundToInt()), + IntSize(size.width.roundToInt(), size.height.roundToInt()), + layoutDirection, + ).toOffset() + return CachedPositionAndSize( + alignedPosition, + scaledSize, + ) + } + + override fun equals(other: Any?): Boolean { + return (other is AutoSizeImageNode) && + request == other.request && + alignment == other.alignment && + contentScale == other.contentScale && + alpha == other.alpha && + colorFilter == other.colorFilter && + imageLoader == other.imageLoader + } + + override fun hashCode(): Int { + var result = request.hashCode() + result = 31 * result + alignment.hashCode() + result = 31 * result + contentScale.hashCode() + result = 31 * result + alpha.hashCode() + result = 31 * result + colorFilter.hashCode() + result = 31 * result + imageLoader.hashCode() + result = 31 * result + placeholderPainter.hashCode() + result = 31 * result + errorPainter.hashCode() + return result + } + + override fun toString(): String { + return "AutoSizeImageModifier(" + + "painter=$drawPainter, " + + "alignment=$alignment, " + + "contentScale=$contentScale, " + + "alpha=$alpha, " + + "colorFilter=$colorFilter, " + + "imageLoader=$imageLoader, " + + "placeholderPainter=$placeholderPainter, " + + "errorPainter=$errorPainter)" + } +} + +private fun modifyConstraints( + constraints: Constraints, + painter: Painter?, + contentScale: ContentScale, +): Constraints { + if (constraints.hasFixedSize()) { + return constraints.copy( + minWidth = constraints.maxWidth, + minHeight = constraints.maxHeight, + ) + } + + val intrinsicSize = painter?.intrinsicSize ?: return constraints + val intrinsicWidth = if (intrinsicSize.hasSpecifiedAndFiniteWidth()) { + intrinsicSize.width.roundToInt() + } else { + constraints.minWidth + } + + val intrinsicHeight = if (intrinsicSize.hasSpecifiedAndFiniteHeight()) { + intrinsicSize.height.roundToInt() + } else { + constraints.minHeight + } + + // Scale the width and height appropriately based on the given constraints + // and ContentScale + val constrainedWidth = constraints.constrainWidth(intrinsicWidth) + val constrainedHeight = constraints.constrainHeight(intrinsicHeight) + val scaledSize = calculateScaledSize( + Size(constrainedWidth.toFloat(), constrainedHeight.toFloat()), + painter, + contentScale, + ) + + // For both width and height constraints, consume the minimum of the scaled width + // and the maximum constraint as some scale types can scale larger than the maximum + // available size (ex ContentScale.Crop) + // In this case the larger of the 2 dimensions is used and the aspect ratio is + // maintained. Even if the size of the composable is smaller, the painter will + // draw its content clipped + val minWidth = constraints.constrainWidth(scaledSize.width.roundToInt()) + val minHeight = constraints.constrainHeight(scaledSize.height.roundToInt()) + return constraints.copy(minWidth = minWidth, minHeight = minHeight) +} + +private fun calculateScaledSize(dstSize: Size, painter: Painter, contentScale: ContentScale): Size { + val srcWidth = if (!painter.intrinsicSize.hasSpecifiedAndFiniteWidth()) { + dstSize.width + } else { + painter.intrinsicSize.width + } + + val srcHeight = if (!painter.intrinsicSize.hasSpecifiedAndFiniteHeight()) { + dstSize.height + } else { + painter.intrinsicSize.height + } + + val srcSize = Size(srcWidth, srcHeight) + return if (dstSize.width != 0f && dstSize.height != 0f) { + srcSize * contentScale.computeScaleFactor(srcSize, dstSize) + } else { + Size.Zero + } +} + +private fun Constraints.hasFixedSize() = hasFixedWidth && hasFixedHeight + +internal fun Constraints.inferredSize(): Size { + if (!hasBoundedWidth || !hasBoundedHeight) return Size.Unspecified + return Size(maxWidth.toFloat(), maxHeight.toFloat()) +} + +private fun Size.hasSpecifiedAndFiniteWidth() = this != Size.Unspecified && width.isFinite() +private fun Size.hasSpecifiedAndFiniteHeight() = this != Size.Unspecified && height.isFinite() diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeWidget.ext.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeWidget.ext.kt new file mode 100644 index 00000000..f507ff4d --- /dev/null +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/ui/AutoSizeWidget.ext.kt @@ -0,0 +1,115 @@ +package com.seiko.imageloader.ui + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import com.seiko.imageloader.ImageLoader +import com.seiko.imageloader.LocalImageLoader +import com.seiko.imageloader.model.ImageAction +import com.seiko.imageloader.model.ImageRequest + +@Composable +fun AutoSizeBox( + url: String, + modifier: Modifier = Modifier, + imageLoader: ImageLoader = LocalImageLoader.current, + contentAlignment: Alignment = Alignment.Center, + propagateMinConstraints: Boolean = false, + isOnlyPostFirstEvent: Boolean = true, + content: @Composable BoxScope.(ImageAction) -> Unit, +) { + AutoSizeBox( + request = remember(url) { ImageRequest(url) }, + modifier = modifier, + imageLoader = imageLoader, + contentAlignment = contentAlignment, + propagateMinConstraints = propagateMinConstraints, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + content = content, + ) +} + +@Composable +fun AutoSizeBox( + resId: Int, + modifier: Modifier = Modifier, + imageLoader: ImageLoader = LocalImageLoader.current, + contentAlignment: Alignment = Alignment.Center, + propagateMinConstraints: Boolean = false, + isOnlyPostFirstEvent: Boolean = true, + content: @Composable BoxScope.(ImageAction) -> Unit, +) { + AutoSizeBox( + request = remember(resId) { ImageRequest(resId) }, + modifier = modifier, + imageLoader = imageLoader, + contentAlignment = contentAlignment, + propagateMinConstraints = propagateMinConstraints, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + content = content, + ) +} + +@Composable +fun AutoSizeImage( + url: String, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + imageLoader: ImageLoader = LocalImageLoader.current, + placeholderPainter: (@Composable () -> Painter)? = null, + errorPainter: (@Composable () -> Painter)? = null, + isOnlyPostFirstEvent: Boolean = true, +) { + AutoSizeImage( + request = remember(url) { ImageRequest(url) }, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + imageLoader = imageLoader, + placeholderPainter = placeholderPainter, + errorPainter = errorPainter, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + ) +} + +@Composable +fun AutoSizeImage( + resId: Int, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + imageLoader: ImageLoader = LocalImageLoader.current, + placeholderPainter: (@Composable () -> Painter)? = null, + errorPainter: (@Composable () -> Painter)? = null, + isOnlyPostFirstEvent: Boolean = true, +) { + AutoSizeImage( + request = remember(resId) { ImageRequest(resId) }, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = colorFilter, + imageLoader = imageLoader, + placeholderPainter = placeholderPainter, + errorPainter = errorPainter, + isOnlyPostFirstEvent = isOnlyPostFirstEvent, + ) +} diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/AnimationPainter.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/AnimationPainter.kt index 4a96623f..24bb3de1 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/AnimationPainter.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/AnimationPainter.kt @@ -4,5 +4,7 @@ interface AnimationPainter { fun isPlay(): Boolean + fun nextPlay(): Boolean + fun update(frameTimeMillis: Long) } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/CalculateSIze.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/CalculateSIze.kt index da7ea968..962ba7f5 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/CalculateSIze.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/CalculateSIze.kt @@ -19,7 +19,7 @@ internal fun calculateDstSize( dstHeight = ((maxImageSize.toFloat() / srcWidth.toFloat()) * dstHeight).toInt() dstWidth = maxImageSize } else { - dstWidth = ((maxImageSize.toFloat() / srcWidth.toFloat()) * dstWidth).toInt() + dstWidth = ((maxImageSize.toFloat() / srcHeight.toFloat()) * dstWidth).toInt() dstHeight = maxImageSize } } diff --git a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/Platform.kt b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/Platform.kt index 59f958f7..c61a9298 100644 --- a/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/Platform.kt +++ b/image-loader/src/commonMain/kotlin/com/seiko/imageloader/util/Platform.kt @@ -1,7 +1,6 @@ package com.seiko.imageloader.util import io.ktor.client.HttpClient -import io.ktor.client.engine.HttpClientEngine import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.CoroutineDispatcher import okio.BufferedSource @@ -13,17 +12,11 @@ expect class WeakReference(referred: T) { fun clear() } -expect class LockObject() - -internal expect inline fun synchronized(lock: LockObject, block: () -> R): R - internal expect suspend fun ByteReadChannel.source(): BufferedSource internal expect val ioDispatcher: CoroutineDispatcher -internal expect val httpEngine: HttpClientEngine - internal expect val defaultFileSystem: FileSystem? internal val httpEngineFactory: () -> HttpClient - get() = { HttpClient(httpEngine) } + get() = { HttpClient() } diff --git a/image-loader/src/commonMain/singleton/com/seiko/imageloader/LocalImageLoader.kt b/image-loader/src/commonMain/singleton/com/seiko/imageloader/LocalImageLoader.kt index 001834e6..2140c7d5 100644 --- a/image-loader/src/commonMain/singleton/com/seiko/imageloader/LocalImageLoader.kt +++ b/image-loader/src/commonMain/singleton/com/seiko/imageloader/LocalImageLoader.kt @@ -3,6 +3,7 @@ package com.seiko.imageloader import androidx.compose.runtime.Composable import androidx.compose.runtime.ProvidedValue import androidx.compose.runtime.ReadOnlyComposable +import com.seiko.imageloader.cache.memory.MemoryCache import com.seiko.imageloader.component.setupDefaultComponents import com.seiko.imageloader.intercept.InterceptorsBuilder import com.seiko.imageloader.model.ImageResult @@ -21,8 +22,8 @@ expect class ImageLoaderProvidableCompositionLocal { expect fun createImageLoaderProvidableCompositionLocal(): ImageLoaderProvidableCompositionLocal -val ImageLoader.Companion.Default: ImageLoader - get() = ImageLoader { +fun ImageLoader.Companion.createDefault(): ImageLoader { + return ImageLoader { components { setupDefaultComponents() } @@ -33,6 +34,7 @@ val ImageLoader.Companion.Default: ImageLoader } } } +} // cache 100 image result, without bitmap fun InterceptorsBuilder.defaultImageResultMemoryCache( @@ -42,23 +44,27 @@ fun InterceptorsBuilder.defaultImageResultMemoryCache( valueSizeProvider: (ImageResult) -> Int = { 1 }, mapToMemoryValue: (ImageResult) -> ImageResult? = { when (it) { - is ImageResult.Image, - is ImageResult.Painter, + is ImageResult.OfImage, + is ImageResult.OfPainter, -> it - is ImageResult.Bitmap -> if (includeBitmap) it else null - is ImageResult.Source, - is ImageResult.Error, + is ImageResult.OfBitmap -> if (includeBitmap) it else null + is ImageResult.OfSource, + is ImageResult.OfError, -> null } }, mapToImageResult: (ImageResult) -> ImageResult? = { it }, ) { - anyMemoryCacheConfig( - valueHashProvider = valueHashProvider, - valueSizeProvider = valueSizeProvider, + anyMemoryCache( mapToMemoryValue = mapToMemoryValue, mapToImageResult = mapToImageResult, ) { - maxSizeBytes(saveSize) + MemoryCache( + valueHashProvider = valueHashProvider, + valueSizeProvider = valueSizeProvider, + block = { + maxSizeBytes(saveSize) + }, + ) } } diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/ImageLoaderTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/ImageLoaderTest.kt index 3ca2a074..443fe59d 100644 --- a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/ImageLoaderTest.kt +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/ImageLoaderTest.kt @@ -9,7 +9,9 @@ import com.seiko.imageloader.component.fetcher.Fetcher import com.seiko.imageloader.model.ImageEvent import com.seiko.imageloader.model.ImageRequest import com.seiko.imageloader.model.ImageResult +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.transform import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -30,10 +32,10 @@ class ImageLoaderTest { resultPainter3 = ColorPainter(Color.Blue) imageLoader = ImageLoader { components { - add { data, _ -> - object : Fetcher { - override suspend fun fetch(): FetchResult { - return FetchResult.Painter( + add( + Fetcher.Factory { data, _ -> + Fetcher { + FetchResult.OfPainter( when (data) { "1" -> resultPainter1 "2" -> resultPainter2 @@ -41,8 +43,8 @@ class ImageLoaderTest { }, ) } - } - } + }, + ) } } } @@ -53,7 +55,7 @@ class ImageLoaderTest { imageLoader.async(request).test { assertEquals(ImageEvent.Start, awaitItem()) assertEquals(ImageEvent.StartWithFetch, awaitItem()) - assertEquals(ImageResult.Painter(resultPainter1), awaitItem()) + assertEquals(ImageResult.OfPainter(resultPainter1), awaitItem()) awaitComplete() } } @@ -65,17 +67,17 @@ class ImageLoaderTest { emit(ImageRequest("2")) emit(ImageRequest("3") { skipEvent = true }) } - imageLoader.async(requestFlow).test { + requestFlow.transform { emitAll(imageLoader.async(it)) }.test { // 1 assertEquals(ImageEvent.Start, awaitItem()) assertEquals(ImageEvent.StartWithFetch, awaitItem()) - assertEquals(ImageResult.Painter(resultPainter1), awaitItem()) + assertEquals(ImageResult.OfPainter(resultPainter1), awaitItem()) // 2 assertEquals(ImageEvent.Start, awaitItem()) assertEquals(ImageEvent.StartWithFetch, awaitItem()) - assertEquals(ImageResult.Painter(resultPainter2), awaitItem()) + assertEquals(ImageResult.OfPainter(resultPainter2), awaitItem()) // 3 - assertEquals(ImageResult.Painter(resultPainter3), awaitItem()) + assertEquals(ImageResult.OfPainter(resultPainter3), awaitItem()) awaitComplete() } } diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/BaseMapperTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/BaseMapperTest.kt new file mode 100644 index 00000000..dfda284b --- /dev/null +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/BaseMapperTest.kt @@ -0,0 +1,18 @@ +package com.seiko.imageloader.component.mapper + +import com.seiko.imageloader.option.Options +import kotlin.test.BeforeTest + +abstract class BaseMapperTest> { + + abstract fun createMapper(): M + + private lateinit var mapper: M + + @BeforeTest + fun onBefore() { + mapper = createMapper() + } + + fun map(data: Any, options: Options = Options()) = mapper.map(data, options) +} diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/KtorUrlMapperTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/KtorUrlMapperTest.kt new file mode 100644 index 00000000..ca09d41e --- /dev/null +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/KtorUrlMapperTest.kt @@ -0,0 +1,21 @@ +package com.seiko.imageloader.component.mapper + +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class KtorUrlMapperTest : BaseMapperTest() { + + override fun createMapper() = KtorUrlMapper() + + @Test + fun test() { + assertNull(map("data:image/png;base64,iVBOR...")) + assertNotNull(map("http://www.google.com")) + assertNotNull(map("https://www.google.com")) + assertNull(map("content://com.example.project:80/folder/etc")) + assertNull(map("imarsthink://www.marsthink.com/travel/oversea?id=1000")) + assertNull(map("tel:10086")) + assertNull(map("geo:52.76,-79.0342")) + } +} diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/StringUriMapperTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/StringUriMapperTest.kt new file mode 100644 index 00000000..0e408b8e --- /dev/null +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/component/mapper/StringUriMapperTest.kt @@ -0,0 +1,21 @@ +package com.seiko.imageloader.component.mapper + +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class StringUriMapperTest : BaseMapperTest() { + + override fun createMapper() = StringUriMapper() + + @Test + fun test() { + assertNull(map("data:image/png;base64,iVBOR...")) + assertNull(map("http://www.google.com")) + assertNull(map("https://www.google.com")) + assertNotNull(map("content://com.example.project:80/folder/etc")) + assertNotNull(map("imarsthink://www.marsthink.com/travel/oversea?id=1000")) + assertNotNull(map("tel:10086")) + assertNotNull(map("geo:52.76,-79.0342")) + } +} diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/model/ImageActionTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/model/ImageActionTest.kt new file mode 100644 index 00000000..f34ac3ff --- /dev/null +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/model/ImageActionTest.kt @@ -0,0 +1,137 @@ +package com.seiko.imageloader.model + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.painter.Painter +import com.seiko.imageloader.rememberImageAction +import com.seiko.imageloader.rememberImageActionPainter +import com.seiko.imageloader.rememberImageSuccessPainter + +class ImageActionTest { + + @Composable + fun test() { + val request = remember { ImageRequest("") } + val action by rememberImageAction(request) + when_normal_test(action) + when_image_action_test(action) + when_image_action_no_result_test(action) + when_image_event_test(action) + when_image_event_no_result_test(action) + when_image_result_test(action) + when_image_result_success_no_result_failure_test(action) + when_all_test(action) + } + + @Composable + private fun when_normal_test(action: ImageAction) { + when (action) { + is ImageAction.Loading -> LoadingUI() + is ImageAction.Success -> SuccessUI(rememberImageActionPainter(action)) + is ImageAction.Failure -> ErrorUI(action.error) + } + } + + @Composable + private fun when_image_action_test(action: ImageAction) { + when (action) { + is ImageEvent -> LoadingUI() + is ImageResult -> { + when (action) { + is ImageAction.Success -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageAction.Failure -> ErrorUI(action.error) + } + } + } + } + + @Composable + private fun when_image_action_no_result_test(action: ImageAction) { + when (action) { + is ImageEvent -> LoadingUI() + is ImageAction.Success -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageAction.Failure -> ErrorUI(action.error) + } + } + + @Composable + private fun when_image_event_test(action: ImageAction) { + when (action) { + is ImageEvent.Start, + is ImageEvent.StartWithMemory, + is ImageEvent.StartWithDisk, + is ImageEvent.StartWithFetch, + -> LoadingUI() + is ImageResult -> { + when (action) { + is ImageAction.Success -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageAction.Failure -> ErrorUI(action.error) + } + } + } + } + + @Composable + private fun when_image_event_no_result_test(action: ImageAction) { + when (action) { + is ImageEvent.Start, + is ImageEvent.StartWithMemory, + is ImageEvent.StartWithDisk, + is ImageEvent.StartWithFetch, + -> LoadingUI() + is ImageAction.Success -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageAction.Failure -> ErrorUI(action.error) + } + } + + @Composable + private fun when_image_result_test(action: ImageAction) { + when (action) { + is ImageEvent -> LoadingUI() + is ImageResult.OfImage -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfBitmap -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfPainter -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfError -> ErrorUI(action.error) + is ImageResult.OfSource -> ErrorUI(action.error) + } + } + + @Composable + private fun when_image_result_success_no_result_failure_test(action: ImageAction) { + when (action) { + is ImageAction.Loading -> LoadingUI() + is ImageResult.OfImage -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfBitmap -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfPainter -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageAction.Failure -> ErrorUI(action.error) + } + } + + @Composable + private fun when_all_test(action: ImageAction) { + when (action) { + is ImageEvent.Start, + is ImageEvent.StartWithMemory, + is ImageEvent.StartWithDisk, + is ImageEvent.StartWithFetch, + -> LoadingUI() + is ImageResult.OfImage -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfBitmap -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfPainter -> SuccessUI(rememberImageSuccessPainter(action)) + is ImageResult.OfError -> ErrorUI(action.error) + is ImageResult.OfSource -> ErrorUI(action.error) + } + } + + @Composable + private fun LoadingUI() = Unit + + @Suppress("UNUSED_PARAMETER") + @Composable + private fun SuccessUI(painter: Painter) = Unit + + @Suppress("UNUSED_PARAMETER") + @Composable + private fun ErrorUI(error: Throwable) = Unit +} diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/model/ImageRequestTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/model/ImageRequestTest.kt new file mode 100644 index 00000000..5979ffce --- /dev/null +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/model/ImageRequestTest.kt @@ -0,0 +1,111 @@ +package com.seiko.imageloader.model + +import com.seiko.imageloader.BitmapConfig +import com.seiko.imageloader.cache.CachePolicy +import com.seiko.imageloader.component.decoder.Decoder +import com.seiko.imageloader.component.fetcher.Fetcher +import com.seiko.imageloader.component.keyer.Keyer +import com.seiko.imageloader.component.mapper.Mapper +import com.seiko.imageloader.option.Options +import com.seiko.imageloader.option.Scale +import com.seiko.imageloader.option.takeFrom +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ImageRequestTest { + + @Test + fun image_request_data_test() { + assertEquals(ImageRequest {}.data, NullRequestData) + assertEquals(ImageRequest(1).data, 1) + assertEquals(ImageRequest("data").data, "data") + assertEquals(ImageRequest(ImageRequest("data")).data, "data") + assertEquals(ImageRequest(ImageRequest("data")) {}.data, "data") + } + + @Test + fun image_request_options_test() { + val request = ImageRequest { + options { + allowInexactSize = true + premultipliedAlpha = false + retryIfDiskDecodeError = false + bitmapConfig = BitmapConfig.ALPHA_8 + scale = Scale.FIT + memoryCachePolicy = CachePolicy.DISABLED + diskCachePolicy = CachePolicy.READ_ONLY + repeatCount = 5 + maxImageSize = 100 + extra { + put("a", "aa") + } + } + } + val options = Options { + takeFrom(request) + } + assertTrue(options.allowInexactSize) + assertFalse(options.premultipliedAlpha) + assertFalse(options.retryIfDiskDecodeError) + assertEquals(options.bitmapConfig, BitmapConfig.ALPHA_8) + assertEquals(options.scale, Scale.FIT) + assertEquals(options.memoryCachePolicy, CachePolicy.DISABLED) + assertEquals(options.diskCachePolicy, CachePolicy.READ_ONLY) + assertEquals(options.repeatCount, 5) + assertEquals(options.maxImageSize, 100) + assertEquals(options.extra["a"], "aa") + } + + @Test + fun image_request_other_params_test() { + val mapperFactory = Mapper { _, _ -> } + val keyerFactory = Keyer { _, _, _ -> null } + val fetcherFactory = Fetcher.Factory { _, _ -> + Fetcher { null } + } + val decoderFactory = Decoder.Factory { _, _ -> + Decoder { null } + } + val request = ImageRequest { + extra { + put("a", "aaa") + } + skipEvent = true + components { + add(mapperFactory) + add(keyerFactory) + add(fetcherFactory) + add(decoderFactory) + } + } + assertEquals(request.extra["a"], "aaa") + assertEquals(request.skipEvent, true) + assertNotNull(request.components) + assertEquals(request.components.mappers.first(), mapperFactory) + assertEquals(request.components.keyers.first(), keyerFactory) + assertEquals(request.components.fetcherFactories.first(), fetcherFactory) + assertEquals(request.components.decoderFactories.first(), decoderFactory) + } + + @Test + fun image_request_compare_test() { + val request1 = ImageRequest { + data("aa") + skipEvent = true + } + assertNotEquals(request1, ImageRequest("aa")) + assertEquals(request1, ImageRequest("aa") { skipEvent = true }) + val request2 = ImageRequest { + data("aa") + extra { + put("a", "b") + } + } + assertNotEquals(request2, ImageRequest("aa")) + assertEquals(request2, ImageRequest("aa") { extra { put("a", "b") } }) + } +} diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/option/SizeResolverTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/option/SizeResolverTest.kt new file mode 100644 index 00000000..020a96db --- /dev/null +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/option/SizeResolverTest.kt @@ -0,0 +1,31 @@ +package com.seiko.imageloader.option + +import androidx.compose.ui.geometry.Size +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class SizeResolverTest { + + @Test + fun compare_test() { + assertEquals( + SizeResolver(Size(10f, 10f)), + SizeResolver(Size(10.0f, 10.0f)), + ) + assertEquals( + SizeResolver(Size.Unspecified), + SizeResolver.Unspecified, + ) + } + + @Test + fun async_size_test() = runTest { + val sizeResolver = AsyncSizeResolver() + launch { + sizeResolver.setSize(Size(10f, 10f)) + } + assertEquals(Size(10f, 10f), sizeResolver.size()) + } +} diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlCommonTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlCommonTest.kt index 0de7e4e2..780f6bbd 100644 --- a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlCommonTest.kt +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlCommonTest.kt @@ -33,7 +33,7 @@ abstract class ChangeImageUrlCommonTest { 3 -> Color.Gray else -> Color.Red // no display } - ImageResult.Painter(ColorPainter(color)) + ImageResult.OfPainter(ColorPainter(color)) } } } diff --git a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotCommonTest.kt b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotCommonTest.kt index 7fb16a8b..7334c3fb 100644 --- a/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotCommonTest.kt +++ b/image-loader/src/commonTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotCommonTest.kt @@ -29,7 +29,7 @@ abstract class ComposeScreenShotCommonTest { addInterceptor( Interceptor { chain -> val color = if (url == chain.request.data) Color.Green else Color.Blue - ImageResult.Painter(ColorPainter(color)) + ImageResult.OfPainter(ColorPainter(color)) }, ) } @@ -58,7 +58,7 @@ abstract class ComposeScreenShotCommonTest { useDefaultInterceptors = false addInterceptor { delay(100) - ImageResult.Painter(ColorPainter(Color.Green)) + ImageResult.OfPainter(ColorPainter(Color.Green)) } } } @@ -81,7 +81,7 @@ abstract class ComposeScreenShotCommonTest { interceptor { useDefaultInterceptors = false addInterceptor { - ImageResult.Error(RuntimeException("error")) + ImageResult.OfError(RuntimeException("error")) } } } diff --git a/image-loader/src/desktopMain/singleton/com/seiko/imageloader/LocalImageLoader.desktop.kt b/image-loader/src/desktopMain/singleton/com/seiko/imageloader/LocalImageLoader.desktop.kt index 1f1635ce..20942199 100644 --- a/image-loader/src/desktopMain/singleton/com/seiko/imageloader/LocalImageLoader.desktop.kt +++ b/image-loader/src/desktopMain/singleton/com/seiko/imageloader/LocalImageLoader.desktop.kt @@ -5,6 +5,6 @@ import androidx.compose.runtime.staticCompositionLocalOf actual fun createImageLoaderProvidableCompositionLocal() = ImageLoaderProvidableCompositionLocal( delegate = staticCompositionLocalOf { // no disk cache in desktop - ImageLoader.Default + ImageLoader.createDefault() }, ) diff --git a/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt b/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt index 113e7076..8b7958be 100644 --- a/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt +++ b/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ChangeImageUrlTest.kt @@ -19,13 +19,13 @@ class ChangeImageUrlTest : ChangeImageUrlCommonTest() { @Before fun initRoborazziConfig() { RoborazziContext.setRuleOverrideOutputDirectory( - outputDirectory = "src/desktopTest/snapshots/images", + outputDirectory = "build/outputs/roborazzi/desktop", ) } - @OptIn(ExperimentalTestApi::class) + @OptIn(ExperimentalTestApi::class, ExperimentalRoborazziApi::class) @Test - fun test_image_change() = runDesktopComposeUiTest(width = 80, height = 80) { + fun desktop_test_image_change() = runDesktopComposeUiTest(width = 80, height = 80) { setContent { TestUI() } diff --git a/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt b/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt index 431ed763..59ba1e64 100644 --- a/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt +++ b/image-loader/src/desktopTest/kotlin/com/seiko/imageloader/screenshot/ComposeScreenShotTest.kt @@ -18,22 +18,22 @@ class ComposeScreenShotTest : ComposeScreenShotCommonTest() { @Before fun initRoborazziConfig() { RoborazziContext.setRuleOverrideOutputDirectory( - outputDirectory = "src/desktopTest/snapshots/images", + outputDirectory = "build/outputs/roborazzi/desktop", ) } @Test - fun test_load_image() = runMyDesktopComposeUiTest(width = 100 + 8 + 100) { + fun desktop_test_load_image() = runMyDesktopComposeUiTest(width = 100 + 8 + 100) { TestLoadImageUI() } @Test - fun test_placeholder_painter() = runMyDesktopComposeUiTest { + fun desktop_test_placeholder_painter() = runMyDesktopComposeUiTest { TestPlaceholderPainterUI() } @Test - fun test_error_painter() = runMyDesktopComposeUiTest { + fun desktop_test_error_painter() = runMyDesktopComposeUiTest { TestErrorPainterUI() } diff --git a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change.png b/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change.png deleted file mode 100644 index bab35f16..00000000 --- a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8efefc34190788fa550753f68a91e24901c35db9d9f5aae00f037c6d93ee6a81 -size 244 diff --git a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_2.png b/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_2.png deleted file mode 100644 index 0a6a9b59..00000000 --- a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_2.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0a0ddfea35a63c2e68784a0ce1eb7830e96e36b304476b480321cdfbdb165cb7 -size 246 diff --git a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_3.png b/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_3.png deleted file mode 100644 index 1a366673..00000000 --- a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_3.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:71a17cd9e761a358e8d6d3ee1d9549a21bf897c8d986f44eb443aea5c9fbfcb9 -size 246 diff --git a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_4.png b/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_4.png deleted file mode 100644 index f2765a23..00000000 --- a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ChangeImageUrlTest.test_image_change_4.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c517fa85a7044189415cde4815296aefb0a3cae686cffcb3f62a8552ad96ba90 -size 246 diff --git a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_error_painter.png b/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_error_painter.png deleted file mode 100644 index cd24e6a4..00000000 --- a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_error_painter.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e025c7f7560d3dcef1bb0d7371573048c5fc10f35435f30733f31ee5a55d6340 -size 310 diff --git a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_load_image.png b/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_load_image.png deleted file mode 100644 index 5a1c6035..00000000 --- a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_load_image.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6016a459f91e28f88dd32a57658a4897d0bf18ff17594a3ab585b2449698b642 -size 661 diff --git a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_placeholder_painter.png b/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_placeholder_painter.png deleted file mode 100644 index e2aeb82a..00000000 --- a/image-loader/src/desktopTest/snapshots/images/com.seiko.imageloader.screenshot.ComposeScreenShotTest.test_placeholder_painter.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a5154ac23b0ee9042e5f107c05782349f9aa544a8b0367b4145d2b767ef9480f -size 311 diff --git a/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/LruCache.kt b/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/LruCache.kt index 7919b203..ebfd7922 100644 --- a/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/LruCache.kt +++ b/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/LruCache.kt @@ -1,5 +1,10 @@ package com.seiko.imageloader.util +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.internal.SynchronizedObject +import kotlinx.coroutines.internal.synchronized + +@OptIn(InternalCoroutinesApi::class) actual open class LruCache actual constructor(maxSize: Int) { private var _maxSize = 0 @@ -12,7 +17,7 @@ actual open class LruCache actual constructor(maxSize: Int) { private val map: LinkedHashMap - private val syncObject = LockObject() + private val syncObject = SynchronizedObject() actual fun size() = synchronized(syncObject) { _size } diff --git a/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/Platform.js.kt b/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/Platform.js.kt index 2622cab4..e2088450 100644 --- a/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/Platform.js.kt +++ b/image-loader/src/jsMain/kotlin/com/seiko/imageloader/util/Platform.js.kt @@ -1,7 +1,5 @@ package com.seiko.imageloader.util -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.js.Js import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import okio.FileSystem @@ -28,14 +26,6 @@ actual class WeakReference actual constructor(referred: T) { } } -actual typealias LockObject = Any - -internal actual inline fun synchronized(lock: LockObject, block: () -> R): R { - return kotlinx.atomicfu.locks.synchronized(lock, block) -} - internal actual val ioDispatcher: CoroutineDispatcher get() = Dispatchers.Default -internal actual val httpEngine: HttpClientEngine get() = Js.create() - internal actual val defaultFileSystem: FileSystem? get() = null diff --git a/image-loader/src/jsMain/singleton/com/seiko/imageloader/LocalImageLoader.js.kt b/image-loader/src/jsMain/singleton/com/seiko/imageloader/LocalImageLoader.js.kt index 51414222..53f4d3b6 100644 --- a/image-loader/src/jsMain/singleton/com/seiko/imageloader/LocalImageLoader.js.kt +++ b/image-loader/src/jsMain/singleton/com/seiko/imageloader/LocalImageLoader.js.kt @@ -5,6 +5,6 @@ import androidx.compose.runtime.staticCompositionLocalOf actual fun createImageLoaderProvidableCompositionLocal() = ImageLoaderProvidableCompositionLocal( delegate = staticCompositionLocalOf { // no disk cache in js - ImageLoader.Default + ImageLoader.createDefault() }, ) diff --git a/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/ByteBufferFetcher.kt b/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/ByteBufferFetcher.kt index 7570cbcd..a0255f68 100644 --- a/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/ByteBufferFetcher.kt +++ b/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/ByteBufferFetcher.kt @@ -14,7 +14,7 @@ class ByteBufferFetcher private constructor( // Reset the position so we can read the byte buffer again. data.position(0) } - return FetchResult.Source( + return FetchResult.OfSource( source = source, ) } diff --git a/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/FileFetcher.kt b/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/FileFetcher.kt index d2b4e789..3e2afc9c 100644 --- a/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/FileFetcher.kt +++ b/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/component/fetcher/FileFetcher.kt @@ -12,7 +12,7 @@ class FileFetcher private constructor( private val data: File, ) : Fetcher { override suspend fun fetch(): FetchResult { - return FetchResult.Source( + return FetchResult.OfSource( source = data.source().buffer(), extra = extraData { mimeType(getMimeTypeFromExtension(data.extension)) diff --git a/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/util/Platform.jvm.kt b/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/util/Platform.jvm.kt index a566ac14..4223145d 100644 --- a/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/util/Platform.jvm.kt +++ b/image-loader/src/jvmMain/kotlin/com/seiko/imageloader/util/Platform.jvm.kt @@ -1,7 +1,5 @@ package com.seiko.imageloader.util -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.okhttp.OkHttp import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.jvm.javaio.toInputStream import okio.BufferedSource @@ -11,18 +9,10 @@ import okio.source actual typealias WeakReference = java.lang.ref.WeakReference -actual typealias LockObject = Any - -internal actual inline fun synchronized(lock: LockObject, block: () -> R): R { - return kotlin.synchronized(lock, block) -} - internal actual suspend fun ByteReadChannel.source(): BufferedSource { return toInputStream().source().buffer() } -internal actual val httpEngine: HttpClientEngine get() = OkHttp.create() - internal actual val defaultFileSystem: FileSystem? get() = FileSystem.SYSTEM internal expect fun getMimeTypeFromExtension(extension: String): String? diff --git a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt index 0f36064d..00549ccb 100644 --- a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt +++ b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/GifDecoder.kt @@ -16,7 +16,7 @@ class GifDecoder private constructor( val codec = source.use { Codec.makeFromData(Data.makeFromBytes(it.readByteArray())) } - return DecodeResult.Painter( + return DecodeResult.OfPainter( painter = GifPainter( codec = codec, repeatCount = options.repeatCount, @@ -25,7 +25,7 @@ class GifDecoder private constructor( } class Factory : Decoder.Factory { - override suspend fun create(source: DecodeSource, options: Options): Decoder? { + override fun create(source: DecodeSource, options: Options): Decoder? { if (!options.playAnimate) return null if (!isGif(source.source)) return null return GifDecoder(source.source, options) diff --git a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SkiaImageDecoder.kt b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SkiaImageDecoder.kt index 160dda00..6f314828 100644 --- a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SkiaImageDecoder.kt +++ b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SkiaImageDecoder.kt @@ -1,5 +1,6 @@ package com.seiko.imageloader.component.decoder +import androidx.compose.ui.geometry.isSpecified import com.seiko.imageloader.option.Options import com.seiko.imageloader.util.DEFAULT_MAX_PARALLELISM import com.seiko.imageloader.util.calculateDstSize @@ -11,7 +12,6 @@ import org.jetbrains.skia.Bitmap import org.jetbrains.skia.Canvas import org.jetbrains.skia.Image import org.jetbrains.skia.Rect -import org.jetbrains.skia.SamplingMode import org.jetbrains.skia.impl.use class SkiaImageDecoder private constructor( @@ -24,7 +24,7 @@ class SkiaImageDecoder private constructor( val image = source.use { Image.makeFromEncoded(it.readByteArray()) } - DecodeResult.Bitmap( + DecodeResult.OfBitmap( bitmap = image.toBitmap(), ) } @@ -32,16 +32,24 @@ class SkiaImageDecoder private constructor( // TODO wait to fix high probability crash on ios private fun Image.toBitmap(): Bitmap { val bitmap = Bitmap() - val (dstWidth, dstHeight) = calculateDstSize(width, height, options.maxImageSize) + + val srcWidth = width + val srcHeight = height + + val maxImageSize = if (options.size.isSpecified && !options.size.isEmpty()) { + minOf(options.size.width, options.size.height).toInt() + .coerceAtMost(options.maxImageSize) + } else { + options.maxImageSize + } + val (dstWidth, dstHeight) = calculateDstSize(srcWidth, srcHeight, maxImageSize) + bitmap.allocN32Pixels(dstWidth, dstHeight) Canvas(bitmap).use { canvas -> canvas.drawImageRect( this, - Rect.makeWH(width.toFloat(), height.toFloat()), + Rect.makeWH(srcWidth.toFloat(), srcHeight.toFloat()), Rect.makeWH(dstWidth.toFloat(), dstHeight.toFloat()), - SamplingMode.DEFAULT, - null, - true, ) } bitmap.setImmutable() @@ -54,7 +62,7 @@ class SkiaImageDecoder private constructor( private val parallelismLock = Semaphore(maxParallelism) - override suspend fun create(source: DecodeSource, options: Options): Decoder { + override fun create(source: DecodeSource, options: Options): Decoder { return SkiaImageDecoder( source = source.source, options = options, diff --git a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt index 84caa2ee..7124e316 100644 --- a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt +++ b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/component/decoder/SvgDecoder.kt @@ -20,18 +20,15 @@ class SvgDecoder private constructor( val data = source.use { Data.makeFromBytes(it.readByteArray()) } - val requestSize = options.sizeResolver.run { - density.size() - } - return DecodeResult.Painter( - painter = SVGPainter(SVGDOM(data), density, requestSize), + return DecodeResult.OfPainter( + painter = SVGPainter(SVGDOM(data), density, options.size), ) } class Factory( private val density: Density, ) : Decoder.Factory { - override suspend fun create(source: DecodeSource, options: Options): Decoder? { + override fun create(source: DecodeSource, options: Options): Decoder? { if (!isApplicable(source)) return null return SvgDecoder( source = source.source, diff --git a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/util/GifPainter.kt b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/util/GifPainter.kt index 6a5aedbf..4d0117fd 100644 --- a/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/util/GifPainter.kt +++ b/image-loader/src/skiaMain/kotlin/com/seiko/imageloader/util/GifPainter.kt @@ -21,7 +21,7 @@ internal class GifPainter( private val durations = codec.framesInfo.map { it.duration } private val totalDuration = durations.sum() - private var startTime = -1L + private var startTimeMillis = -1L private var frame by mutableStateOf(0) private var loopIteration = -1 @@ -52,14 +52,20 @@ internal class GifPainter( } override fun isPlay(): Boolean { - return totalDuration > 0 && (repeatCount == Options.REPEAT_INFINITE || loopIteration++ < repeatCount) + return totalDuration > 0 && (repeatCount == Options.REPEAT_INFINITE || repeatCount > 0) + } + + override fun nextPlay(): Boolean { + return totalDuration > 0 && (repeatCount == Options.REPEAT_INFINITE || loopIteration < repeatCount) } override fun update(frameTimeMillis: Long) { - if (startTime == -1L) { - startTime = frameTimeMillis + if (startTimeMillis == -1L) { + startTimeMillis = frameTimeMillis } - frame = frameOf(time = (frameTimeMillis - startTime) % totalDuration) + val playTimeMillis = frameTimeMillis - startTimeMillis + frame = frameOf(time = playTimeMillis % totalDuration) + loopIteration = (playTimeMillis / totalDuration).toInt() } // WARNING: it is not optimal @@ -87,7 +93,7 @@ internal class GifPainter( } private fun clear() { - startTime = -1 + startTimeMillis = -1 frame = 0 loopIteration = -1 bitmapCache?.close()