Skip to content

Introduce landscapist-core and landscapist-image modules#809

Merged
skydoves merged 7 commits intomainfrom
feature/landscapist-core
Dec 26, 2025
Merged

Introduce landscapist-core and landscapist-image modules#809
skydoves merged 7 commits intomainfrom
feature/landscapist-core

Conversation

@skydoves
Copy link
Owner

@skydoves skydoves commented Dec 26, 2025

Introduce landscapist-core and landscapist-image modules.

landscapist-core

The foundation layer that handles the actual image loading pipeline:

  • Network fetching (downloading images)
  • Disk & memory caching
  • Image decoding (Bitmap, AnimatedImageDrawable)
  • No UI/Compose dependencies

landscapist

The Compose UI layer built on top of core:

  • DrawablePainter - Renders Android Drawables in Compose (handles animation)
  • ImageOptions - Alignment, contentScale, etc.
  • Plugins system (CrossfadePlugin, ShimmerPlugin, etc.)
  • Common Compose utilities

landscapist-image

The unified image composable that combines everything:

  • LandscapistImage - The main composable you use
  • Uses landscapist-core for loading
  • Uses landscapist for UI rendering and plugins

Summary by CodeRabbit

  • New Features

    • Comprehensive multiplatform image loader (loading, caching, decoding, progressive rendering) with painter integration and Compose-friendly LandscapistImage, LocalLandscapist and ProvideImageSource.
    • New placeholder plugins and decoders: BlurHash, ThumbHash, and Progressive loading.
    • Added image fetchers for many model types and improved memory-pressure handling.
  • API

    • Expanded public APIs: ImageOptions (placeholderAspectRatio), DataSource (INLINE/LOCAL/RESOURCE), and many loader/configuration types.
  • Build

    • BOM and build scripts updated; new modules included.
  • Tests

    • Extensive unit tests and new Android instrumentation UI tests.

✏️ Tip: You can customize this high-level summary in your review settings.

@skydoves skydoves self-assigned this Dec 26, 2025
@coderabbitai
Copy link

coderabbitai bot commented Dec 26, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Adds a new Kotlin Multiplatform image-loading system (landscapist-core) with caching, fetchers, decoders (static/progressive/region), scheduling, events, and Compose integration (landscapist-image), platform implementations and placeholder plugins, build-convention additions, demo app updates, and many unit/instrumentation tests.

Changes

Cohort / File(s) Summary
Build & Settings
settings.gradle.kts, gradle/libs.versions.toml, bom/build.gradle.kts, app/build.gradle.kts
New modules included; new version entries (okio, atomicfu); BOM adds :landscapist-core and :landscapist-image; app module adds testInstrumentationRunner and dependency on project(":landscapist-image").
Build Logic / Conventions
build-logic/convention/build.gradle.kts, build-logic/convention/src/main/kotlin/*KmpLibraryConventionPlugin.kt*
Registers new Gradle plugin id landscapist.library.kmp and adds KMP library configuration helper for multiplatform targets and JVM options.
Core Module Build
landscapist-core/build.gradle.kts
New multiplatform module build script with publishing, targets, dependencies (coroutines, ktor, okio, atomicfu) and compiler options.
Core Public API Surface
landscapist-core/api/..., many src/*/kotlin/com/skydoves/landscapist/core/**
Massive public API additions: Landscapist engine, LandscapistConfig, ImageRequest and Builder, ImageResult/FetchResult/DecodeResult types, ImageFetcher/KtorImageFetcher, RequestManager/Disposable, DecodeScheduler/PrioritizedRequest, EventListener/ImageLoadEvent, Interceptors, Transformation, Bitmap pool API, DiskCache/MemoryCache/implementations, CacheKey, and many platform expect/actual hooks.
Platform Implementations (Android/Desktop/Apple/Skia/Wasm)
landscapist-core/src/{androidMain,desktopMain,appleMain,skiaMain,wasmJsMain}/**
Platform-specific decoders (Android full decoder/progressive/region), bitmap pool (Android), region decoder (Android), Skia/desktop/apple/wasm variants (mostly pass-through RawImageData or no-op), platform disk cache factories.
Fetchers & Composite Fetcher
landscapist-core/src/androidMain/.../fetcher/*, .../network/CompositeFetcher.kt, KtorImageFetcher.kt
New set of fetchers (BitmapFetcher, DrawableFetcher, ByteArray/ByteBuffer/File/Uri/Resource/IntResource/DrawableResFetcher), CompositeFetcher to prioritize handlers, and KtorImageFetcher network implementation with timeouts/headers.
Caching Implementations
landscapist-core/src/commonMain/.../cache/{DiskCache.kt,DiskLruCache.kt,MemoryCache.kt,LruMemoryCache.kt,TwoTierMemoryCache.kt}
DiskCache interface and DiskLruCache implementation with transactional Editor/Snapshot; memory cache interfaces and LRU/Two-tier implementations.
Bitmap Pooling
landscapist-core/src/commonMain/.../bitmappool/*, *BitmapPool.android.kt, *BitmapPool.skia.kt, *BitmapPool.jvm.kt
Common BitmapPool API, GlobalBitmapPool, Android BitmapPool actual implementation (reuse/eviction/metrics), Skia no-op pool, JVM/apple weakref implementations.
Decoders & Progressive/Region tools
landscapist-core/src/commonMain/.../decoder/*
ImageDecoder/ProgressiveDecoder/RegionDecoder interfaces and result types, AnimatedImageDetector, ProgressiveImageDetector, tile/region utilities and expect/actual factories.
Interceptors & Pipeline
landscapist-core/src/commonMain/.../interceptor/Interceptor.kt
Adds chain-of-responsibility interceptors: Header, Logging, UrlRewrite, Retry and RealInterceptorChain.
Eventing & Memory Pressure
landscapist-core/src/commonMain/.../event/*, memory/MemoryPressureListener.kt
ImageLoadEvent sealed types, EventListener and implementations (Logging, Composite, Metrics), MemoryPressureManager and AndroidMemoryPressureHandler (androidMain).
Scheduling & Request Management
landscapist-core/src/commonMain/.../scheduler/*, request/*
DecodeScheduler with priority queue/parallelism controls, PrioritizedRequest; RequestManager and Disposable implementations.
Image Compose Integration Module
landscapist-image/build.gradle.kts, landscapist-image/src/commonMain/**, src/{androidMain,skiaMain}/**
New landscapist-image module: LandscapistImage composable, LandscapistImageState mapping, rememberLandscapistPainter expect/actual, convertToImageBitmap expect/actual, LocalLandscapist and ProvideImageSource platform implementations.
Placeholder Plugins
landscapist-placeholder/src/commonMain/**, src/{androidMain,skiaMain}/**
Adds BlurHashDecoder/Plugin, ThumbHashDecoder/Plugin, ProgressiveLoadingPlugin with platform decoders for Android/Skia.
Zoomable Plugin Changes
landscapist-zoomable/src/androidMain/.../ZoomableContent.android.kt
Asynchronously create region decoder on IO via LaunchedEffect, manage lifecycle of created decoder, adjust decoder selection logic.
Demo App Updates & Tests
app/src/main/kotlin/.../MainPosters.kt, app/src/androidTest/.../LandscapistImageTest.kt
Replaces Fresco/Glide with LandscapistImage in UI, adds instrumentation tests; app build.gradle adds landscapist-image dependency and Android test deps.
Unit Tests
landscapist-core/src/commonTest/** and many platform tests
Extensive unit test suite added covering config, requests, caches, decoders, event listeners, scheduler, request manager, bitmap pools, and more.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Compose UI
    participant LS as Landscapist
    participant MC as MemoryCache
    participant DC as DiskCache
    participant Fetcher as ImageFetcher
    participant Scheduler as DecodeScheduler
    participant Decoder as ImageDecoder

    UI->>LS: request load(ImageRequest)
    LS->>MC: lookup(cacheKey)
    alt memory hit
        MC-->>LS: CachedImage
        LS-->>UI: ImageResult.Success (MEMORY)
    else memory miss
        LS->>DC: get(cacheKey)
        alt disk hit
            DC-->>LS: bytes (Snapshot)
            LS->>Scheduler: schedule decode(bytes, priority)
            Scheduler->>Decoder: decode(bytes)
            Decoder-->>LS: DecodeResult.Success(bitmap)
            LS->>MC: set(cacheKey, CachedImage)
            LS-->>UI: ImageResult.Success (DISK)
        else disk miss
            LS->>Fetcher: fetch(request)
            Fetcher-->>LS: FetchResult.Success(bytes)
            alt progressive supported
                LS->>Decoder: decodeProgressive(bytes)
                Decoder-->>LS: ProgressiveDecodeResult.Intermediate
                LS-->>UI: ImageResult.Success (INTERMEDIATE)
                Decoder-->>LS: ProgressiveDecodeResult.Complete
            else
                LS->>Scheduler: schedule decode(bytes, priority)
                Scheduler->>Decoder: decode(bytes)
            end
            Decoder-->>LS: DecodeResult.Success(bitmap)
            LS->>DC: edit(cacheKey) -> write bytes
            LS->>MC: set(cacheKey, CachedImage)
            LS-->>UI: ImageResult.Success (NETWORK)
        end
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through bytes and cached delights,

I stitched raw pixels into shimmering lights,
From blur to thumb, I decoded with glee,
Multiplatform magic — a rabbit's small spree.
Hooray for loaders! 🥕📸

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.02% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Description check ❓ Inconclusive The PR description covers the Goal section well by explaining the three-layer architecture and module responsibilities. However, it lacks Implementation details, code examples, and specific instructions on how to prepare the PR for review (spotlessApply, apiDump). Add Implementation details explaining key architectural decisions, include code examples showing usage of the new modules, and document the preparation steps (spotlessApply, apiDump) that were performed.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: introducing two new modules (landscapist-core and landscapist-image) that form the foundation of the layered architecture.

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5896580 and 290593d.

📒 Files selected for processing (23)
  • app/src/androidTest/kotlin/com/github/skydoves/landscapistdemo/LandscapistImageTest.kt
  • landscapist-core/api/android/landscapist-core.api
  • landscapist-core/api/desktop/landscapist-core.api
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/Landscapist.android.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/AndroidContextProvider.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/AndroidFetchers.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/BitmapFetcher.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/ByteArrayFetcher.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/ByteBufferFetcher.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/DrawableFetcher.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/DrawableResFetcher.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/DrawableResModel.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/FileFetcher.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/IntResourceFetcher.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/fetcher/UriFetcher.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/Landscapist.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/model/DataSource.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/network/CompositeFetcher.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/network/ImageFetcher.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/DataSourceTest.kt
  • landscapist/api/android/landscapist.api
  • landscapist/api/desktop/landscapist.api
  • landscapist/src/commonMain/kotlin/com/skydoves/landscapist/DataSource.kt

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (10)
gradle/libs.versions.toml-49-51 (1)

49-51: Duplicate library declaration for ui-test-junit4.

Line 49 (androidx-compose-ui-test) and line 51 (androidx-compose-ui-test-junit4) both reference the same artifact ui-test-junit4. Consider removing one to avoid confusion.

🔎 Suggested fix
 androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" }
 androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
-androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }

Then update app/build.gradle.kts to use libs.androidx.compose.ui.test instead of libs.androidx.compose.ui.test.junit4.

landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/LandscapistConfig.kt-53-55 (1)

53-55: Documentation mismatch: comment says "25% of available memory, max 256MB" but value is 64MB.

The comment describes a dynamic calculation ("25% of available memory, max 256MB"), but the actual value is a hardcoded 64MB constant. Either update the comment to match the actual behavior, or implement the dynamic calculation if that's the intended design.

🔎 Suggested documentation fix
-    /** Default memory cache size: 25% of available memory, max 256MB. */
+    /** Default memory cache size: 64MB. */
    public const val DEFAULT_MEMORY_CACHE_SIZE: Long = 64 * 1024 * 1024L // 64MB
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/AnimatedImageDetector.kt-69-75 (1)

69-75: Incomplete PNG signature check - only validates first 4 of 8 bytes.

The PNG specification requires an 8-byte signature: 89 50 4E 47 0D 0A 1A 0A. This check only validates the first 4 bytes, which could match non-PNG data that happens to start with \x89PNG.

🔎 Suggested fix for complete PNG signature
  private fun isPng(data: ByteArray): Boolean {
    if (data.size < 8) return false
    return data[0] == 0x89.toByte() &&
      data[1] == 'P'.code.toByte() &&
      data[2] == 'N'.code.toByte() &&
-     data[3] == 'G'.code.toByte()
+     data[3] == 'G'.code.toByte() &&
+     data[4] == 0x0D.toByte() &&
+     data[5] == 0x0A.toByte() &&
+     data[6] == 0x1A.toByte() &&
+     data[7] == 0x0A.toByte()
  }
landscapist-core/src/desktopMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.desktop.kt-99-114 (1)

99-114: getScaledInstance may not complete scaling before drawImage returns.

Image.getScaledInstance() returns an Image that loads asynchronously. Passing null as the ImageObserver to drawImage() means the method may return before the scaled image is fully rendered, potentially resulting in incomplete or blank output.

Consider using Graphics2D.drawImage(image, x, y, width, height, observer) directly for synchronous scaling, or use MediaTracker to wait for the scaled instance.

🔎 Suggested fix using direct drawImage scaling
  private fun scaleImage(
    image: BufferedImage,
    targetWidth: Int,
    targetHeight: Int,
  ): BufferedImage {
    val scaledImage = BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_ARGB)
    val graphics = scaledImage.createGraphics()
+   graphics.setRenderingHint(
+     java.awt.RenderingHints.KEY_INTERPOLATION,
+     java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR,
+   )
-   graphics.drawImage(
-     image.getScaledInstance(targetWidth, targetHeight, java.awt.Image.SCALE_SMOOTH),
-     0,
-     0,
-     null,
-   )
+   graphics.drawImage(image, 0, 0, targetWidth, targetHeight, null)
    graphics.dispose()
    return scaledImage
  }
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/MemoryCache.kt-76-80 (1)

76-80: Default resize implementation doesn't update maxSize.

The default implementation only calls trimToSize when the new size is smaller but doesn't actually update the maxSize property. Implementations that rely on this default will trim entries but continue reporting the old maxSize, which could cause confusion.

🔎 Consider making resize abstract or documenting the limitation

Either make resize abstract to force implementations to handle maxSize updates, or document that implementations must override this method to update their internal maxSize:

  /**
   * Resizes the cache to the new maximum size.
   * If the new size is smaller than current size, entries will be evicted.
+  * 
+  * Note: Default implementation only trims entries. Implementations should
+  * override this method to also update their internal maxSize tracking.
   *
   * @param newMaxSize The new maximum size in bytes.
   */
  public fun resize(newMaxSize: Long) {

Committable suggestion skipped: line range outside the PR's diff.

landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.android.kt-111-163 (1)

111-163: Dead code: partialBitmap is never assigned.

The variable partialBitmap (line 111) is declared but never set. The OnPartialImageListener callback at line 133-138 doesn't provide access to partial bitmaps—it's called when decoding fails partway through, not for intermediate progressive scans. Lines 151-161 are unreachable.

Either remove the dead code or implement actual partial bitmap capture if the API supports it.

🔎 Suggested cleanup
  private fun decodeWithImageDecoder(
    data: ByteArray,
    originalWidth: Int,
    originalHeight: Int,
    targetWidth: Int?,
    targetHeight: Int?,
    config: LandscapistConfig,
  ): Flow<ProgressiveDecodeResult> = flow {
    val source = android.graphics.ImageDecoder.createSource(ByteBuffer.wrap(data))
-   var partialBitmap: Bitmap? = null

    try {
      val bitmap = android.graphics.ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
        // ... sizing logic ...
        decoder.setAllocator(android.graphics.ImageDecoder.ALLOCATOR_SOFTWARE)
        decoder.setOnPartialImageListener { exception ->
          false // Continue decoding
        }
      }

      emit(
        ProgressiveDecodeResult.Complete(
          bitmap = bitmap,
          width = originalWidth,
          height = originalHeight,
        ),
      )
    } catch (e: Exception) {
-     partialBitmap?.let { partial ->
-       emit(
-         ProgressiveDecodeResult.Intermediate(
-           bitmap = partial,
-           width = partial.width,
-           height = partial.height,
-           progress = 0.5f,
-           isPreview = true,
-         ),
-       )
-     }
      emit(ProgressiveDecodeResult.Error(e))
    }
  }
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/network/KtorImageFetcher.kt-106-123 (1)

106-123: Potential resource leak: HttpClient is never closed.

The HttpClient created in create() is never explicitly closed. In long-running applications, this could leak connections. Consider:

  1. Implementing Closeable/AutoCloseable on KtorImageFetcher
  2. Documenting that callers should manage the HttpClient lifecycle
  3. Or ensuring the client is reused globally and closed on application termination
🔎 Suggested approach
+import java.io.Closeable
+
 public class KtorImageFetcher(
   private val httpClient: HttpClient,
   private val networkConfig: NetworkConfig,
-) : ImageFetcher {
+) : ImageFetcher, Closeable {
+
+  override fun close() {
+    httpClient.close()
+  }
   // ...
 }

Committable suggestion skipped: line range outside the PR's diff.

landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/scheduler/DecodeScheduler.kt-207-233 (1)

207-233: Potential double-counting and logic issues in cancelByTag.

  1. Line 212-214: Filters pendingRequests by tag, then looks up those IDs in activeRequests. But a request's lifecycle moves from pending → active → completed. If a request is in activeRequests, it may no longer be in pendingRequests.

  2. Line 231: Returns count + idsToRemove.size, but if the same request is found in both lookups, it's double-counted.

Consider simplifying:

🔎 Suggested fix
  public suspend fun cancelByTag(tag: String): Int {
    return mutex.withLock {
-     var count = 0
-
-     // Cancel active requests with this tag
-     val activeToRemove = pendingRequests.entries
-       .filter { it.value.tag == tag }
-       .mapNotNull { activeRequests[it.key] }
-
-     for (deferred in activeToRemove) {
-       deferred.cancel()
-       count++
-     }
-
-     // Remove pending requests with this tag
-     val idsToRemove = pendingRequests.entries
-       .filter { it.value.tag == tag }
-       .map { it.key }
-
-     for (id in idsToRemove) {
-       pendingRequests.remove(id)
-       activeRequests.remove(id)
-     }
-
-     count + idsToRemove.size
+     val idsToRemove = pendingRequests.entries
+       .filter { it.value.tag == tag }
+       .map { it.key }
+
+     for (id in idsToRemove) {
+       pendingRequests.remove(id)
+       activeRequests.remove(id)?.cancel()
+     }
+
+     idsToRemove.size
    }
  }
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/interceptor/Interceptor.kt-99-113 (1)

99-113: Missing priority and progressiveEnabled fields when rebuilding ImageRequest.

The HeaderInterceptor rebuilds the ImageRequest but doesn't copy the priority and progressiveEnabled fields from the original request. This causes these settings to be reset to their defaults.

The same issue exists in UrlRewriteInterceptor (lines 132-141).

🔎 Proposed fix for HeaderInterceptor
     val newRequest = ImageRequest(
       model = request.model,
       memoryCachePolicy = request.memoryCachePolicy,
       diskCachePolicy = request.diskCachePolicy,
       headers = headers + request.headers,
       transformations = request.transformations,
       targetWidth = request.targetWidth,
       targetHeight = request.targetHeight,
       tag = request.tag,
+      priority = request.priority,
+      progressiveEnabled = request.progressiveEnabled,
     )
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/Landscapist.kt-224-232 (1)

224-232: diskPath calculated before commit may reference non-existent file.

The diskPath is computed on line 230 before editor.commit() is called on line 229. While the path will eventually be valid after commit, if there's an error during commit, the diskPath would reference a non-existent file that gets passed to subsequent code.

Consider moving the path calculation after the commit, or using the editor's dataPath for consistency.

🔎 Proposed fix
         if (request.diskCachePolicy.writeEnabled && diskCache != null) {
           diskCache.edit(cacheKey)?.use { editor ->
             diskCache.fileSystem.sink(editor.dataPath).buffer().use { sink ->
               sink.write(fetchResult.data)
             }
             editor.commit()
-            diskPath = (diskCache.directory / cacheKey.diskKey).toString()
+            diskPath = editor.dataPath.toString()
           }
         }
🧹 Nitpick comments (34)
landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/LandscapistPainter.skia.kt (1)

25-27: Consider enhancing the documentation.

The KDoc could be more comprehensive by mentioning the supported data type (Skia Bitmap), the fallback behavior for unsupported types (EmptyPainter), and how null values are handled.

🔎 Suggested documentation enhancement
 /**
- * Creates and remembers a [Painter] from Skia Bitmap data.
+ * Creates and remembers a [Painter] from Skia Bitmap data.
+ *
+ * @param data The data to convert to a Painter. Supported types:
+ *   - [org.jetbrains.skia.Bitmap]: Converted to [BitmapPainter]
+ *   - Other types (including null): Returns [EmptyPainter]
+ * @return A memoized [Painter] instance
  */
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.android.kt (1)

204-221: Consider iteration over recursion for edge-case safety.

The recursive call on line 209 handles empty entries, but if bitmapsBySize somehow contains multiple consecutive empty lists, recursion could stack up. Converting to iteration would be more defensive.

🔎 Suggested iterative approach
  private fun evictOne(): Boolean {
-   val firstEntry = bitmapsBySize.firstEntry() ?: return false
-   val bitmaps = firstEntry.value
-   if (bitmaps.isEmpty()) {
-     bitmapsBySize.remove(firstEntry.key)
-     return evictOne()
-   }
+   while (true) {
+     val firstEntry = bitmapsBySize.firstEntry() ?: return false
+     val bitmaps = firstEntry.value
+     if (bitmaps.isEmpty()) {
+       bitmapsBySize.remove(firstEntry.key)
+       continue
+     }

-   val pooled = bitmaps.removeAt(0)
-   if (bitmaps.isEmpty()) {
-     bitmapsBySize.remove(firstEntry.key)
-   }
+     val pooled = bitmaps.removeAt(0)
+     if (bitmaps.isEmpty()) {
+       bitmapsBySize.remove(firstEntry.key)
+     }

-   pooled.bitmap.recycle()
-   _currentSize.addAndGet(-pooled.byteCount.toLong())
-   _evictions.incrementAndGet()
-   return true
+     pooled.bitmap.recycle()
+     _currentSize.addAndGet(-pooled.byteCount.toLong())
+     _evictions.incrementAndGet()
+     return true
+   }
  }
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.kt (1)

153-167: Consider using @Volatile or synchronized initialization.

The race condition is acknowledged, but without @Volatile, the pool assignment on line 165 may not be visible to other threads immediately, potentially causing multiple pool creations. While the impact is minor (extra initialization), adding @Volatile would be a low-cost improvement.

🔎 Suggested fix
 public object GlobalBitmapPool {
-  private var pool: BitmapPool? = null
+  @Volatile
+  private var pool: BitmapPool? = null
landscapist-zoomable/src/androidMain/kotlin/com/skydoves/landscapist/zoomable/internal/ZoomableContent.android.kt (2)

86-92: Consider closing on background thread for consistency.

For consistency with the suggested fix above, the disposal cleanup could also close the decoder on a background thread. However, since this runs during teardown, blocking briefly may be acceptable.


94-101: Redundant variable assignment.

currentDecoder on line 98 is immediately assigned from decoder (line 95) and used in the same scope without any intervening mutation. This appears to be leftover from refactoring.

🔎 Proposed simplification
   // Use explicit decoder if provided, otherwise use the one we created
   val decoder = explicitDecoder ?: createdDecoder

   // Use sub-sampling when enabled and decoder is available
-  val currentDecoder = decoder
-  if (config.enableSubSampling && currentDecoder != null) {
+  if (config.enableSubSampling && decoder != null) {
     val subSamplingState = rememberSubSamplingState(
-      decoder = currentDecoder,
+      decoder = decoder,
       config = config.subSamplingConfig,
     )
landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashDecoder.kt (1)

299-327: Consider extracting base64Decode to a shared utility.

This base64Decode implementation is nearly identical to the one in ThumbHashPlugin.kt (shown in relevant snippets). The only difference is that the plugin version also strips \n and \r characters.

Consider consolidating into a single shared utility to avoid duplication and ensure consistent behavior.

🔎 Suggested consolidation
 private fun base64Decode(input: String): ByteArray? {
   val base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
-  val cleanInput = input.replace("=", "")
+  val cleanInput = input.replace("=", "").replace("\n", "").replace("\r", "")

   if (cleanInput.isEmpty()) return null
landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashPlugin.kt (1)

98-128: Duplicate base64 decoding logic.

This base64Decode implementation is nearly identical to the one in ThumbHashDecoder.kt (lines 298-326). Consider consolidating into a single shared utility to avoid code duplication and potential divergence.

The only difference is the additional newline stripping here (.replace("\n", "").replace("\r", "")), which could be added to a shared implementation.

landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/transformation/Transformation.kt (1)

29-35: Consider documenting expected input types per platform.

Using Any is a pragmatic multiplatform approach, but implementations will need runtime casting which risks ClassCastException if passed unexpected types. Consider adding KDoc specifying what types each platform expects (e.g., Bitmap on Android, UIImage on Apple) to guide implementers.

🔎 Suggested documentation enhancement
   /**
    * Transforms the input image.
    *
-   * @param input The input bitmap data (platform-specific type).
+   * @param input The input bitmap data (platform-specific type):
+   *   - Android: `android.graphics.Bitmap`
+   *   - iOS/macOS: `platform.UIKit.UIImage` or `platform.AppKit.NSImage`
+   *   - Desktop: `org.jetbrains.skia.Image`
    * @return The transformed bitmap data.
    */
   public suspend fun transform(input: Any): Any
landscapist-core/build.gradle.kts (1)

44-49: Minor redundancy in opt-in declarations.

The RequiresOptIn and ExperimentalCoroutinesApi opt-ins are declared twice: once via languageSettings.optIn (lines 47-48) for all source sets, and again via freeCompilerArgs (lines 117-118) for JVM compiles. This is harmless but redundant.

🔎 Consider consolidating opt-ins

Keep the languageSettings.optIn approach (lines 47-48) and remove the duplicates from freeCompilerArgs:

 tasks.withType<KotlinJvmCompile>().configureEach {
   compilerOptions {
     jvmTarget.set(JvmTarget.JVM_17)
     freeCompilerArgs.addAll(
       "-Xexplicit-api=strict",
-      "-opt-in=kotlin.RequiresOptIn",
-      "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
     )
   }
 }

Also applies to: 112-121

landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/ImageBitmapConverter.skia.kt (1)

30-35: Consider logging the exception before returning null.

The caught exception is swallowed without any logging or error context, making it difficult to diagnose image decoding failures in production. Consider logging the exception with appropriate context (e.g., data size, platform).

🔎 Suggested improvement
     is RawImageData -> {
       try {
         val skiaImage = Image.makeFromEncoded(data.data)
         skiaImage.toComposeImageBitmap()
       } catch (e: Exception) {
+        // Log for debugging: println("Failed to decode image: ${e.message}, size: ${data.data.size}")
         null
       }
     }
landscapist-image/build.gradle.kts (1)

46-50: Opt-in annotations are duplicated.

The opt-in annotations are applied twice:

  1. Lines 47-49 for all source sets via languageSettings.optIn()
  2. Lines 103-105 for KotlinJvmCompile tasks via freeCompilerArgs

The source set configuration should be sufficient. The task-level configuration is redundant unless there's a specific need for JVM-only targets.

🔎 Consider removing duplicate opt-ins from task configuration
 tasks.withType<KotlinJvmCompile>().configureEach {
   compilerOptions {
     jvmTarget.set(JvmTarget.JVM_17)
     freeCompilerArgs.addAll(
       "-Xexplicit-api=strict",
-      "-opt-in=kotlin.RequiresOptIn",
-      "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
-      "-opt-in=com.skydoves.landscapist.InternalLandscapistApi",
     )
   }
 }

Also applies to: 98-107

landscapist-image/src/androidMain/kotlin/com/skydoves/landscapist/image/LocalLandscapist.android.kt (1)

35-38: Consider using applicationContext for more stable caching.

Using LocalContext.current directly as a remember key may lead to unnecessary recreations when the context instance changes (e.g., Activity recreation). Using applicationContext provides a more stable reference:

🔎 Proposed fix
   val context = LocalContext.current
-  return remember(context) {
-    Landscapist.builder(context).build()
+  val appContext = context.applicationContext
+  return remember(appContext) {
+    Landscapist.builder(appContext).build()
   }

This ensures the Landscapist instance survives configuration changes and isn't unnecessarily recreated. However, if Activity-specific context features are needed (unlikely for disk cache initialization), the current approach would be appropriate.

landscapist-core/src/desktopMain/kotlin/com/skydoves/landscapist/core/Landscapist.desktop.kt (1)

26-34: Consider platform-specific cache directories for improved Desktop cross-platform compatibility.

While the current implementation using ~/.cache/landscapist works correctly across platforms (directory creation is handled automatically), it follows Linux XDG conventions. On macOS and Windows, users expect cache to be stored in platform-specific locations:

  • Windows: %LOCALAPPDATA%\landscapist
  • macOS: ~/Library/Caches/landscapist

Using platform-specific conventions would improve the user experience on non-Linux desktops without affecting functionality.

landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.android.kt (1)

206-214: Consider logging the swallowed IllegalArgumentException for debugging.

When bitmap reuse fails, the exception is caught and retried without inBitmap, which is correct. However, logging this at debug level would help diagnose bitmap pool issues without affecting production behavior.

🔎 Proposed enhancement
     var bitmap = try {
       BitmapFactory.decodeByteArray(data, 0, data.size, decodeOptions)
     } catch (e: IllegalArgumentException) {
       // inBitmap was not compatible, try again without reuse
+      // Log.d("AndroidImageDecoder", "Bitmap reuse failed, retrying without inBitmap", e)
       decodeOptions.inBitmap = null
       BitmapFactory.decodeByteArray(data, 0, data.size, decodeOptions)
     } ?: return DecodeResult.Error(
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/model/ImageResult.kt (2)

39-41: data: Any sacrifices type safety for flexibility.

Using Any for the image data accommodates platform-specific types (Android Bitmap, Skia Image, etc.), but consumers must cast. Consider documenting expected types or using a type parameter if feasible in future iterations.


93-96: Consider making at least one failure property required.

Both throwable and message are nullable with defaults. A Failure with null for both provides no diagnostic information. Consider requiring at least one, or adding validation.

🔎 Alternative approach
public data class Failure(
  val throwable: Throwable? = null,
  val message: String? = null,
) : ImageResult() {
  init {
    require(throwable != null || message != null) {
      "Failure must have either throwable or message"
    }
  }
}
landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LandscapistImageState.kt (1)

95-99: Consider adding error data field for consistency.

Looking at the existing ImageLoadState.Failure in landscapist/src/commonMain/kotlin/com/skydoves/landscapist/ImageLoadState.kt (lines 37-38), it includes both data: Any? and reason: Throwable?. The new LandscapistImageState.Failure only has reason. If error placeholders or fallback images are needed, the data field might be required.

🔎 Optional: Add data field for consistency
 public data class Failure(
+  val data: Any? = null,
   val reason: Throwable?,
 ) : LandscapistImageState()
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.kt (1)

55-60: Consider documenting expected bitmap types per platform.

The bitmap: Any field loses compile-time type safety. Consider adding KDoc listing the expected types per platform (e.g., android.graphics.Bitmap on Android, org.jetbrains.skia.Image on Skia platforms) to help consumers understand what to expect.

landscapist-core/src/skiaMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.skia.kt (2)

51-57: Document that width/height are placeholders.

The values width = 0 and height = 0 are intentional placeholders, but consumers may not expect this. The inline comment helps, but consider adding this to the ProgressiveDecodeResult.Complete documentation or ensuring the UI layer explicitly handles zero dimensions.


63-66: supportsProgressiveDecode returning true may be misleading.

This implementation doesn't actually perform progressive decoding - it wraps raw data for the Compose layer to decode. Returning true suggests progressive JPEG/PNG decoding is supported, which could mislead callers. Consider returning false or renaming the semantic.

🔎 Alternative: Return false to indicate no native progressive support
  override fun supportsProgressiveDecode(data: ByteArray, mimeType: String?): Boolean {
-   // Progressive effect is simulated in UI layer for Skia platforms
-   return true
+   // Skia platforms don't support native progressive decoding.
+   // The UI layer simulates progressive effect via blur animation.
+   return false
  }
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/scheduler/DecodePriority.kt (1)

65-71: Lambda field in data class affects equality semantics.

The decoder suspend lambda is included in the auto-generated equals/hashCode. Two PrioritizedRequest instances with identical id, priority, createdAt, and tag but different lambda instances will not be equal, even if they represent the same logical request. If request deduplication by id is needed, consider excluding decoder from equality:

🔎 Optional: Custom equals/hashCode excluding decoder
public data class PrioritizedRequest<T>(
  val id: String,
  val priority: DecodePriority,
  val createdAt: Long,
  val tag: String? = null,
  val decoder: suspend () -> T,
) : Comparable<PrioritizedRequest<T>> {

  override fun equals(other: Any?): Boolean {
    if (this === other) return true
    if (other == null || this::class != other::class) return false
    other as PrioritizedRequest<*>
    return id == other.id
  }

  override fun hashCode(): Int = id.hashCode()
  // ... compareTo remains unchanged
}
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/TwoTierMemoryCache.kt (1)

54-80: Minor: Unconditional weak cache cleanup is correct but could use a clarifying comment.

Line 76 removes the key from weakCache after the ?.get() check returns null. This is correct cleanup of collected references, but the control flow might be clearer with a brief comment.

🔎 Optional clarification
      // Clean up null weak reference
+     // (reached only when weakCache[memoryKey]?.get() returned null)
      weakCache.remove(memoryKey)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/LruMemoryCache.kt (1)

98-107: Consider extracting shared eviction logic to reduce duplication.

This evictIfNeeded implementation is nearly identical to the one in TwoTierMemoryCache. Consider extracting common eviction logic to a shared utility or abstract base class for maintainability.

landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.android.kt (2)

253-305: Code duplication with ImageDecoder.android.kt.

Both calculateSampleSize and hasAlpha logic are duplicated from ImageDecoder.android.kt (see lines 239-272 in that file). Consider extracting these to a shared utility to maintain consistency and reduce maintenance burden.


307-309: Remove custom emitAll extension; use the built-in implementation from kotlinx.coroutines.flow.

Kotlin's standard library already provides emitAll for FlowCollector with identical behavior. Replace this custom implementation by importing and using the standard version.

+import kotlinx.coroutines.flow.emitAll
...
-  private suspend fun <T> kotlinx.coroutines.flow.FlowCollector<T>.emitAll(flow: Flow<T>) {
-    flow.collect { emit(it) }
-  }
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/scheduler/DecodeScheduler.kt (1)

145-160: Inefficient busy-loop in waitForTurn.

The while(true) loop with yield() consumes CPU cycles polling for priority changes. For high-throughput scenarios with many pending requests, consider using a Channel or suspend-based signaling mechanism to wake waiters only when priorities change.

landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.kt (1)

35-54: Consider documenting platform-specific bitmap types.

The bitmap property is typed as Any, which is necessary for KMP but loses type safety. Consider adding KDoc noting the expected types per platform (e.g., android.graphics.Bitmap on Android, org.jetbrains.skia.Image on desktop).

🔎 Suggested documentation
  /**
   * An intermediate image during progressive decode.
   *
-  * @property bitmap The partially decoded bitmap.
+  * @property bitmap The partially decoded bitmap. Platform-specific type:
+  *   - Android: `android.graphics.Bitmap`
+  *   - Desktop/iOS: `org.jetbrains.skia.Image`
   * @property width The width of the image.
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.android.kt (2)

46-48: Consider logging swallowed exceptions for debuggability.

When BitmapRegionDecoder.newInstance throws, the exception is silently discarded. This makes debugging image loading failures harder. Consider logging the exception or providing a result type that includes error details.

🔎 Proposed improvement
   } catch (e: Exception) {
+    // Log for debugging: e.g., Log.w("RegionDecoder", "Failed to create decoder", e)
     null
   }

129-148: Original exception context is lost in fallback path.

When inBitmap decoding fails with IllegalArgumentException, the original exception e is discarded. If the fallback also fails, only e2 is reported. Consider preserving context by using addSuppressed or logging the original exception.

🔎 Proposed improvement
     } catch (e: IllegalArgumentException) {
       // inBitmap was not suitable, try without reuse
       options.inBitmap = null
       try {
         val bitmap = decoder.decodeRegion(rect, options)
         // ... success handling
       } catch (e2: Exception) {
+        e2.addSuppressed(e) // Preserve original inBitmap failure context
         RegionDecodeResult.Error(throwable = e2, region = region)
       }
     }
landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LandscapistImage.kt (1)

86-91: Lambda key in remember may cause unnecessary recompositions.

The requestBuilder lambda is used as a key in remember. Since lambdas capture their context and create new instances on each composition, this may cause the request to be rebuilt more often than necessary. Consider using a stable key or documenting that callers should use remembered lambdas.

landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/DiskLruCache.kt (1)

251-266: Potential inconsistency if metadata() fails after atomicMove.

After atomicMove succeeds (line 259), if fileSystem.metadata(finalPath) throws, abort() is called which attempts to delete tempPath (which no longer exists). This leaves an orphaned file at finalPath that isn't tracked in entries. The file will be recovered on next initialization, but currentSize may be inaccurate until then.

Consider handling this edge case by attempting to delete finalPath in the catch block or tracking the entry with size 0 as a fallback.

🔎 Proposed improvement
       try {
         // Move temp file to final location
         if (fileSystem.exists(tempPath)) {
           fileSystem.delete(finalPath)
           fileSystem.atomicMove(tempPath, finalPath)
           val size = fileSystem.metadata(finalPath).size ?: 0L
           onCommit(size)
         }
       } catch (_: IOException) {
-        abort()
+        // Cleanup: try to remove the potentially orphaned final file
+        try {
+          fileSystem.delete(finalPath)
+        } catch (_: IOException) {
+          // Best effort cleanup failed; file will be recovered on next init
+        }
       }
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/interceptor/Interceptor.kt (1)

149-175: Edge case: negative maxRetries returns Loading without attempting the request.

If maxRetries is set to a negative value, the while loop condition attempts <= maxRetries (0 <= -1) is false, and the function returns ImageResult.Loading without ever attempting to load the image.

Consider adding validation or using coerceAtLeast(0):

🔎 Proposed fix
 public class RetryInterceptor(
-  private val maxRetries: Int = 3,
+  private val maxRetries: Int = 3.coerceAtLeast(0),
   private val retryOnError: (Throwable?) -> Boolean = { true },
 ) : Interceptor {

Or add a require check:

init {
  require(maxRetries >= 0) { "maxRetries must be non-negative" }
}
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/Landscapist.kt (2)

298-309: Request ID based on hashCode may cause scheduling conflicts for different models.

The requestId construction using hashCode() could result in ID collisions when different image models produce the same hash value with identical dimensions. While this primarily affects scheduling order rather than correctness, it could cause unexpected priority behavior.

Consider using a more unique identifier like the model's toString() or a combination with a unique counter.


535-538: Consider using Dispatchers.IO for I/O operations.

The default dispatcher is Dispatchers.Default, which is optimized for CPU-bound work. Image loading involves network I/O and disk I/O operations which are better suited for Dispatchers.IO.

While decoding is CPU-bound (making Default appropriate for that phase), the overall flow includes significant I/O work. Consider defaulting to Dispatchers.IO or documenting why Dispatchers.Default was chosen.

🔎 Proposed change
-    private var dispatcher: CoroutineDispatcher = Dispatchers.Default
+    private var dispatcher: CoroutineDispatcher = Dispatchers.IO
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b853ab9 and cc0ddb7.

⛔ Files ignored due to path filters (1)
  • kotlin-js-store/wasm/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (97)
  • app/build.gradle.kts
  • app/src/androidTest/kotlin/com/github/skydoves/landscapistdemo/LandscapistImageTest.kt
  • app/src/main/kotlin/com/github/skydoves/landscapistdemo/ui/MainPosters.kt
  • bom/build.gradle.kts
  • build-logic/convention/build.gradle.kts
  • build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
  • build-logic/convention/src/main/kotlin/com/skydoves/landscapist/KmpLibrary.kt
  • gradle/libs.versions.toml
  • landscapist-core/api/android/landscapist-core.api
  • landscapist-core/api/desktop/landscapist-core.api
  • landscapist-core/build.gradle.kts
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/Landscapist.android.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.android.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.android.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.android.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.android.kt
  • landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/memory/AndroidMemoryPressureHandler.kt
  • landscapist-core/src/appleMain/kotlin/com/skydoves/landscapist/core/Landscapist.apple.kt
  • landscapist-core/src/appleMain/kotlin/com/skydoves/landscapist/core/cache/WeakRef.apple.kt
  • landscapist-core/src/appleMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.apple.kt
  • landscapist-core/src/appleMain/kotlin/com/skydoves/landscapist/core/event/TimeUtils.apple.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/DownsamplingStrategy.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/ImageRequest.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/Landscapist.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/LandscapistConfig.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/CacheKey.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/DiskCache.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/DiskLruCache.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/LruMemoryCache.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/MemoryCache.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/TwoTierMemoryCache.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/WeakRef.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/AnimatedImageDetector.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/event/EventListener.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/event/ImageLoadEvent.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/interceptor/Interceptor.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/memory/MemoryPressureListener.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/model/CachePolicy.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/model/DataSource.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/model/ImageResult.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/network/ImageFetcher.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/network/KtorImageFetcher.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/request/Disposable.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/request/RequestManager.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/scheduler/DecodePriority.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/scheduler/DecodeScheduler.kt
  • landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/transformation/Transformation.kt
  • landscapist-core/src/desktopMain/kotlin/com/skydoves/landscapist/core/Landscapist.desktop.kt
  • landscapist-core/src/desktopMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.desktop.kt
  • landscapist-core/src/jvmMain/kotlin/com/skydoves/landscapist/core/cache/WeakRef.jvm.kt
  • landscapist-core/src/jvmMain/kotlin/com/skydoves/landscapist/core/event/TimeUtils.jvm.kt
  • landscapist-core/src/skiaMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.skia.kt
  • landscapist-core/src/skiaMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.skia.kt
  • landscapist-core/src/skiaMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.skia.kt
  • landscapist-core/src/wasmJsMain/kotlin/com/skydoves/landscapist/core/Landscapist.wasmJs.kt
  • landscapist-core/src/wasmJsMain/kotlin/com/skydoves/landscapist/core/cache/WeakRef.wasmJs.kt
  • landscapist-core/src/wasmJsMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.wasmJs.kt
  • landscapist-core/src/wasmJsMain/kotlin/com/skydoves/landscapist/core/event/TimeUtils.wasmJs.kt
  • landscapist-image/api/android/landscapist-image.api
  • landscapist-image/api/desktop/landscapist-image.api
  • landscapist-image/build.gradle.kts
  • landscapist-image/src/androidMain/kotlin/com/skydoves/landscapist/image/ImageBitmapConverter.android.kt
  • landscapist-image/src/androidMain/kotlin/com/skydoves/landscapist/image/LandscapistPainter.android.kt
  • landscapist-image/src/androidMain/kotlin/com/skydoves/landscapist/image/LocalLandscapist.android.kt
  • landscapist-image/src/androidMain/kotlin/com/skydoves/landscapist/image/ProvideImageSource.android.kt
  • landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/EmptyPainter.kt
  • landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/ImageBitmapConverter.kt
  • landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LandscapistImage.kt
  • landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LandscapistImageState.kt
  • landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LandscapistPainter.kt
  • landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LocalLandscapist.kt
  • landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/ProvideImageSource.kt
  • landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/ImageBitmapConverter.skia.kt
  • landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/LandscapistPainter.skia.kt
  • landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/LocalLandscapist.skia.kt
  • landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/ProvideImageSource.skia.kt
  • landscapist-placeholder/api/android/landscapist-placeholder.api
  • landscapist-placeholder/api/desktop/landscapist-placeholder.api
  • landscapist-placeholder/src/androidMain/kotlin/com/skydoves/landscapist/placeholder/blurhash/BlurHashPlugin.android.kt
  • landscapist-placeholder/src/androidMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashPlugin.android.kt
  • landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/blurhash/BlurHashDecoder.kt
  • landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/blurhash/BlurHashPlugin.kt
  • landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/progressive/ProgressiveLoadingPlugin.kt
  • landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashDecoder.kt
  • landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashPlugin.kt
  • landscapist-placeholder/src/skiaMain/kotlin/com/skydoves/landscapist/placeholder/blurhash/BlurHashPlugin.skia.kt
  • landscapist-placeholder/src/skiaMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashPlugin.skia.kt
  • landscapist-zoomable/src/androidMain/kotlin/com/skydoves/landscapist/zoomable/internal/ZoomableContent.android.kt
  • landscapist/api/android/landscapist.api
  • landscapist/api/desktop/landscapist.api
  • landscapist/src/commonMain/kotlin/com/skydoves/landscapist/ImageOptions.kt
  • landscapist/src/commonMain/kotlin/com/skydoves/landscapist/constraints/ConstraintsExtensions.kt
  • settings.gradle.kts
🧰 Additional context used
🧬 Code graph analysis (16)
landscapist-image/build.gradle.kts (1)
build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt (2)
  • apply (27-48)
  • apply (28-47)
landscapist-image/src/androidMain/kotlin/com/skydoves/landscapist/image/ProvideImageSource.android.kt (1)
landscapist/src/androidMain/kotlin/com/skydoves/landscapist/LocalImageSourceFile.kt (1)
  • ProvideImageSourceFile (49-57)
build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt (1)
build-logic/convention/src/main/kotlin/com/skydoves/landscapist/KmpLibrary.kt (1)
  • configureKmpLibrary (28-96)
landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/ProvideImageSource.skia.kt (1)
landscapist/src/skiaMain/kotlin/com/skydoves/landscapist/LocalImageSourceBytes.kt (1)
  • ProvideImageSourceBytes (48-56)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/TwoTierMemoryCache.kt (2)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/LruMemoryCache.kt (2)
  • evictIfNeeded (98-107)
  • trimToSize (82-91)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/cache/MemoryCache.kt (1)
  • trimToSize (68-68)
landscapist-zoomable/src/androidMain/kotlin/com/skydoves/landscapist/zoomable/internal/ZoomableContent.android.kt (1)
landscapist-zoomable/src/commonMain/kotlin/com/skydoves/landscapist/zoomable/subsampling/SubSamplingState.kt (1)
  • rememberSubSamplingState (45-56)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/network/KtorImageFetcher.kt (1)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/ImageRequest.kt (1)
  • headers (82-85)
landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashPlugin.kt (3)
landscapist-placeholder/src/androidMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashPlugin.android.kt (1)
  • decodeThumbHashToImageBitmap (22-28)
landscapist-placeholder/src/skiaMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashPlugin.skia.kt (1)
  • decodeThumbHashToImageBitmap (26-40)
landscapist-placeholder/src/commonMain/kotlin/com/skydoves/landscapist/placeholder/thumbhash/ThumbHashDecoder.kt (1)
  • base64Decode (299-327)
app/src/main/kotlin/com/github/skydoves/landscapistdemo/ui/MainPosters.kt (1)
landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LandscapistImage.kt (1)
  • LandscapistImage (72-77)
landscapist-core/build.gradle.kts (1)
build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt (2)
  • apply (27-48)
  • apply (28-47)
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.android.kt (1)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.kt (1)
  • decodeRegion (133-133)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/event/ImageLoadEvent.kt (3)
landscapist-core/src/appleMain/kotlin/com/skydoves/landscapist/core/event/TimeUtils.apple.kt (1)
  • currentTimeMillis (21-22)
landscapist-core/src/jvmMain/kotlin/com/skydoves/landscapist/core/event/TimeUtils.jvm.kt (1)
  • currentTimeMillis (18-18)
landscapist-core/src/wasmJsMain/kotlin/com/skydoves/landscapist/core/event/TimeUtils.wasmJs.kt (1)
  • currentTimeMillis (21-21)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.kt (2)
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.android.kt (1)
  • createBitmapPool (29-37)
landscapist-core/src/skiaMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.skia.kt (1)
  • createBitmapPool (25-25)
app/src/androidTest/kotlin/com/github/skydoves/landscapistdemo/LandscapistImageTest.kt (1)
landscapist-image/src/commonMain/kotlin/com/skydoves/landscapist/image/LandscapistImage.kt (1)
  • LandscapistImage (72-77)
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ProgressiveDecoder.android.kt (1)
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.android.kt (2)
  • calculateSampleSize (240-267)
  • hasAlpha (269-273)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/scheduler/DecodeScheduler.kt (2)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/Landscapist.kt (1)
  • decoder (531-533)
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.android.kt (1)
  • decoder (65-169)
🪛 detekt (1.23.8)
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/request/Disposable.kt

[warning] 72-72: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)


[warning] 73-73: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)

landscapist-image/src/skiaMain/kotlin/com/skydoves/landscapist/image/ImageBitmapConverter.skia.kt

[warning] 33-33: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/RegionDecoder.android.kt

[warning] 46-46: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 129-129: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

landscapist-core/src/skiaMain/kotlin/com/skydoves/landscapist/core/bitmappool/BitmapPool.skia.kt

[warning] 45-45: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)


[warning] 47-47: This empty block of code can be removed.

(detekt.empty-blocks.EmptyFunctionBlock)

landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/decoder/ImageDecoder.android.kt

[warning] 208-208: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Build and Tests
  • GitHub Check: API check
  • GitHub Check: Spotless check

Comment on lines +89 to +92
assert(loaded) {
"Image failed to load within timeout. " +
"State history: $stateHistory, Final state: ${imageState::class.simpleName}"
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use JUnit assertions instead of Kotlin assert().

Kotlin's assert() can be disabled with JVM flags and may silently pass. Use JUnit's Assert.assertTrue() or assertThat() for reliable test behavior.

🔎 Proposed fix
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
 ...
     // Wait for image to load (max 10 seconds)
     val loaded = latch.await(10, TimeUnit.SECONDS)
-    assert(loaded) {
-      "Image failed to load within timeout. " +
-        "State history: $stateHistory, Final state: ${imageState::class.simpleName}"
-    }
+    assertTrue(
+      "Image failed to load within timeout. State history: $stateHistory, Final state: ${imageState::class.simpleName}",
+      loaded
+    )
 ...
     composeTestRule.runOnIdle {
-      assert(imageState is LandscapistImageState.Success) {
-        "Expected Success state but got: $imageState"
-      }
+      assertTrue(
+        "Expected Success state but got: $imageState",
+        imageState is LandscapistImageState.Success
+      )
     }

Also applies to: 100-102

🤖 Prompt for AI Agents
In
app/src/androidTest/kotlin/com/github/skydoves/landscapistdemo/LandscapistImageTest.kt
around lines 89-92 (and similarly lines 100-102), the test uses Kotlin's
assert() which can be disabled at runtime; replace these with JUnit assertions
(e.g., org.junit.Assert.assertTrue or assertThat) so the test always fails when
the condition is false, passing the same failure message and condition; add the
necessary JUnit import if missing.


// Wait for image to load (max 10 seconds)
val loaded = latch.await(10, TimeUnit.SECONDS)
assert(loaded) { "Image failed to load within timeout - this indicates ANR or freeze!" }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Same issue: replace assert() with JUnit assertions.

The second test method also uses assert() which should be replaced with JUnit assertions for consistency and reliability.

Also applies to: 142-144

🤖 Prompt for AI Agents
In
app/src/androidTest/kotlin/com/github/skydoves/landscapistdemo/LandscapistImageTest.kt
around lines 136 and 142-144, replace usages of Kotlin's assert() with JUnit
assertions: change the assert(loaded) check to a JUnit-style assertion (e.g.,
assertTrue or assertThat with a clear failure message) and similarly convert any
other assert() calls in the 142-144 range to appropriate JUnit assertions so
tests are consistent and reliable in instrumentation runs.

Comment on lines +37 to +49
wasmJs {
browser {
testTask {
enabled = false
}
}
nodejs {
testTask {
enabled = false
}
}
binaries.library()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's locate the file and examine its full content
find . -name "KmpLibrary.kt" -type f

Repository: skydoves/landscapist

Length of output: 143


🏁 Script executed:

# Read the entire KmpLibrary.kt file to examine the structure and verify the claim
cat -n ./build-logic/convention/src/main/kotlin/com/skydoves/landscapist/KmpLibrary.kt

Repository: skydoves/landscapist

Length of output: 2990


🏁 Script executed:

# Check for js() target declarations in similar files
rg -n "js\s*\{" --type=kotlin -A 2 -B 2

Repository: skydoves/landscapist

Length of output: 1638


🏁 Script executed:

# Search for withJs() in hierarchy templates to understand the pattern
rg "withJs\(\)" --type=kotlin -B 5 -A 5

Repository: skydoves/landscapist

Length of output: 3504


🏁 Script executed:

# Check what targets are declared in ComposeMultiplatform.kt and ComposeMultiplatformWasm.kt
echo "=== ComposeMultiplatform.kt targets ===" && 
head -60 ./build-logic/convention/src/main/kotlin/com/skydoves/landscapist/ComposeMultiplatform.kt | grep -E "^\s*(js|wasmJs|android|jvm|ios|macos)\s*\(" &&
echo "=== ComposeMultiplatformWasm.kt targets ===" &&
head -60 ./build-logic/convention/src/main/kotlin/com/skydoves/landscapist/ComposeMultiplatformWasm.kt | grep -E "^\s*(js|wasmJs|android|jvm|ios|macos)\s*\("

Repository: skydoves/landscapist

Length of output: 185


🏁 Script executed:

# Get more context - check the first 100 lines of both files for target declarations
echo "=== ComposeMultiplatform.kt (first 100 lines) ===" &&
head -100 ./build-logic/convention/src/main/kotlin/com/skydoves/landscapist/ComposeMultiplatform.kt &&
echo -e "\n=== ComposeMultiplatformWasm.kt (first 100 lines) ===" &&
head -100 ./build-logic/convention/src/main/kotlin/com/skydoves/landscapist/ComposeMultiplatformWasm.kt

Repository: skydoves/landscapist

Length of output: 4889


🏁 Script executed:

# Check if there are any js() target declarations anywhere in the build-logic directory
rg "^\s*js\s*\(" build-logic/ --type=kotlin

Repository: skydoves/landscapist

Length of output: 46


🏁 Script executed:

# Double-check by searching for any js configuration across the entire codebase
rg "js\(" --type=kotlin | grep -v "nodejs\|wasmJs" | head -20

Repository: skydoves/landscapist

Length of output: 46


🏁 Script executed:

# Search for any gradle build files or documentation that might explain the withJs() pattern
find . -name "build.gradle.kts" -o -name "README.md" | head -10

Repository: skydoves/landscapist

Length of output: 345


Remove withJs() from the hierarchy template or add a js() target declaration.

The hierarchy template includes withJs() (line 79), but only wasmJs is declared as a target (lines 37-49). The js() target is never configured. This pattern exists across multiple configuration files (ComposeMultiplatform.kt, ComposeMultiplatformWasm.kt). Since only wasmJs is supported, remove the withJs() call and rely on withWasmJs() instead, or add an explicit js() target if JS support is intended.

🤖 Prompt for AI Agents
In build-logic/convention/src/main/kotlin/com/skydoves/landscapist/KmpLibrary.kt
around lines 37-49 (and note the hierarchy call at ~line 79), the project config
declares only a wasmJs target but the shared hierarchy template includes
withJs(); either remove the withJs() call from the hierarchy/template or add an
explicit js() target declaration to match the hierarchy. To fix: if you only
support Wasm+JS, remove the withJs() invocation and rely on withWasmJs()/wasmJs
{ ... } blocks; if you intend to support plain JS as well, add a js { ... }
target configuration matching the wasmJs setup so the hierarchy and targets are
consistent.

Comment on lines +28 to +29
okio = "3.10.2"
atomicfu = "0.27.0"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check latest versions of okio and atomicfu
echo "=== Okio latest version ==="
curl -s "https://api.github.com/repos/square/okio/releases/latest" | jq -r '.tag_name'

echo ""
echo "=== AtomicFU latest version ==="
curl -s "https://api.github.com/repos/Kotlin/kotlinx-atomicfu/releases/latest" | jq -r '.tag_name'

Repository: skydoves/landscapist

Length of output: 136


🏁 Script executed:

#!/bin/bash
# Try alternative method for okio and check the actual file
echo "=== Checking okio via Maven Central ==="
curl -s "https://repo1.maven.org/maven2/com/squareup/okio/okio/maven-metadata.xml" | grep -oP '(?<=<latest>)[^<]+' | head -1

echo ""
echo "=== Checking gradle/libs.versions.toml ==="
cat -n gradle/libs.versions.toml | sed -n '25,35p'
cat -n gradle/libs.versions.toml | sed -n '82,90p'

Repository: skydoves/landscapist

Length of output: 1240


Update dependency versions to latest stable releases.

okio should be updated to 3.16.4 (currently 3.10.2) and atomicfu to 0.29.0 (currently 0.27.0).

🤖 Prompt for AI Agents
In gradle/libs.versions.toml around lines 28 to 29, the pinned dependency
versions are out of date: update okio from "3.10.2" to "3.16.4" and atomicfu
from "0.27.0" to "0.29.0". Edit those two entries to use the new version strings
and save the file; run a dependency refresh/build afterwards to ensure
compatibility.

Comment on lines +28 to +34
public class AndroidMemoryPressureHandler(
private val manager: MemoryPressureManager,
) : ComponentCallbacks2 {

private var registered = false
private var application: Application? = null

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add thread safety for concurrent register/unregister calls.

The registered flag and application field are accessed without synchronization, creating a race condition if register() or unregister() are called concurrently from multiple threads. This could lead to duplicate registrations or incomplete cleanup.

🔎 Proposed fix
 public class AndroidMemoryPressureHandler(
   private val manager: MemoryPressureManager,
 ) : ComponentCallbacks2 {
 
-  private var registered = false
-  private var application: Application? = null
+  @Volatile
+  private var registered = false
+  @Volatile
+  private var application: Application? = null

Additionally, consider wrapping the register/unregister logic in synchronized(this) blocks to prevent interleaved execution.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public class AndroidMemoryPressureHandler(
private val manager: MemoryPressureManager,
) : ComponentCallbacks2 {
private var registered = false
private var application: Application? = null
public class AndroidMemoryPressureHandler(
private val manager: MemoryPressureManager,
) : ComponentCallbacks2 {
@Volatile
private var registered = false
@Volatile
private var application: Application? = null
🤖 Prompt for AI Agents
In
landscapist-core/src/androidMain/kotlin/com/skydoves/landscapist/core/memory/AndroidMemoryPressureHandler.kt
around lines 28-34, the mutable fields `registered` and `application` are
accessed without synchronization causing race conditions when
register()/unregister() are called concurrently; fix by wrapping the register
and unregister logic in synchronized(this) blocks so reads/writes to
`registered` and `application` are atomic (check-and-set, assign application,
and flip registered flag inside the lock), ensure register is idempotent (no
duplicate register calls) and unregister clears `application` and `registered`
inside the same synchronized block, and if you must call
application.unregisterComponentCallbacks take care to either invoke it inside
the lock or capture the application reference inside the lock then call
unregisterComponentCallbacks outside to avoid holding the lock during platform
callbacks.

Comment on lines +69 to +81
public class Landscapist private constructor(
public val config: LandscapistConfig,
private val memoryCache: MemoryCache,
private val diskCache: DiskCache?,
private val fetcher: ImageFetcher,
private val decoder: ImageDecoder,
private val dispatcher: CoroutineDispatcher,
public val requestManager: RequestManager = RequestManager(),
public val memoryPressureManager: MemoryPressureManager = MemoryPressureManager(),
) {

private val scope = CoroutineScope(SupervisorJob() + dispatcher)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

CoroutineScope without lifecycle management may cause resource leaks.

The scope created on line 80 uses SupervisorJob() but is never cancelled. When a Landscapist instance is replaced via setInstance() or removed via removeInstance(), the old scope's coroutines may continue running indefinitely.

Consider adding a shutdown() or close() method to cancel the scope when the instance is being replaced or removed.

🔎 Proposed addition
 public class Landscapist private constructor(
   ...
 ) {
 
   private val scope = CoroutineScope(SupervisorJob() + dispatcher)
+  private val supervisorJob = SupervisorJob()
+  private val scope = CoroutineScope(supervisorJob + dispatcher)
+
+  /**
+   * Shuts down this Landscapist instance, cancelling all pending requests.
+   * Call this before discarding the instance to prevent resource leaks.
+   */
+  public fun shutdown() {
+    requestManager.cancelAll()
+    supervisorJob.cancel()
+  }

Then call shutdown() from setInstance() and removeInstance() before replacing/removing the instance.

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +125 to +133
// Track active request
scope.async {
mutex.withLock {
activeRequests[id] = deferred
}
}

return deferred
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition: activeRequests update may complete after the request finishes.

The separate async block (lines 126-130) that adds to activeRequests races with the main decode operation. The request could complete and be removed from activeRequests before it's ever added, causing cancel(id) to miss active requests.

🔎 Suggested fix

Add to activeRequests synchronously before launching the async:

+   // Track active request immediately
+   mutex.withLock {
+     activeRequests[id] = deferred
+   }
+
    val deferred = scope.async {
      // Add to pending
      mutex.withLock {
        pendingRequests[id] = request
      }
      // ... rest of decode logic
    }

-   // Track active request
-   scope.async {
-     mutex.withLock {
-       activeRequests[id] = deferred
-     }
-   }

    return deferred

Note: This requires restructuring since deferred isn't available before it's created. Consider using CompletableDeferred or restructuring the tracking logic.

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +264 to +275
private var globalScheduler: DecodeScheduler? = null

/**
* Gets the global decode scheduler, creating it if necessary.
*/
public fun global(): DecodeScheduler {
globalScheduler?.let { return it }

val newScheduler = DecodeScheduler()
globalScheduler = newScheduler
return newScheduler
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition in global() singleton initialization.

Two threads calling global() concurrently could both see globalScheduler as null, create two instances, and one would be lost. Use double-checked locking or an atomic reference.

🔎 Suggested fix using atomicfu
+import kotlinx.atomicfu.atomic
+
  public companion object {
    public const val DEFAULT_PARALLELISM: Int = 4

-   private var globalScheduler: DecodeScheduler? = null
+   private val globalScheduler = atomic<DecodeScheduler?>(null)

    public fun global(): DecodeScheduler {
-     globalScheduler?.let { return it }
-
-     val newScheduler = DecodeScheduler()
-     globalScheduler = newScheduler
-     return newScheduler
+     globalScheduler.value?.let { return it }
+     val newScheduler = DecodeScheduler()
+     return if (globalScheduler.compareAndSet(null, newScheduler)) {
+       newScheduler
+     } else {
+       globalScheduler.value!!
+     }
    }

    public fun initialize(parallelism: Int = DEFAULT_PARALLELISM) {
-     globalScheduler = DecodeScheduler(parallelism)
+     globalScheduler.value = DecodeScheduler(parallelism)
    }
  }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
landscapist-core/src/commonMain/kotlin/com/skydoves/landscapist/core/scheduler/DecodeScheduler.kt
around lines 264 to 275, the global() singleton initialization has a race: two
threads can both see globalScheduler as null and create separate instances; make
initialization thread-safe by replacing the current check-with-assignment with a
concurrent-safe pattern — either use double-checked locking (check
globalScheduler, synchronize on a private lock, re-check and assign) or use an
atomic reference (atomicfu or java.util.concurrent.atomic.AtomicReference) and
perform a compare-and-set to install the new DecodeScheduler, returning the
existing instance if another thread wins; ensure the chosen approach is
multiplatform-safe (use atomicfu for KMM) and avoids creating and discarding
multiple instances under concurrency.

Comment on lines +62 to +76
public class ProgressiveLoadingPlugin(
private val initialBlurRadius: Float = 16f,
private val transitionDuration: Int = 300,
) : ImagePlugin.LoadingStatePlugin {

@Composable
override fun compose(
modifier: Modifier,
imageOptions: ImageOptions,
executor: @Composable (IntSize) -> Unit,
): ImagePlugin = apply {
// Request a tiny thumbnail for fast preview
val previewSize = IntSize(32, 32)
executor.invoke(previewSize)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Constructor parameters are unused.

initialBlurRadius and transitionDuration are stored but never used within the compose method. The plugin only invokes the executor with a fixed 32x32 preview size. This appears to be an incomplete implementation.

Either:

  1. Remove the unused parameters if the blur logic is meant to be handled externally
  2. Integrate the blur rendering logic within the compose method similar to how ProgressiveImage does it

Comment on lines +66 to 84
LaunchedEffect(sourceFile) {
// Close any previously created decoder first
createdDecoder?.close()
createdDecoder = null

// Only create decoder from source file if no explicit decoder is provided
if (explicitDecoder == null && sourceFile != null) {
val newDecoder = sourceFile.let { file ->
if (file.exists() && file.canRead()) {
withContext(Dispatchers.IO) {
ImageRegionDecoder.create(file.absolutePath)
}
} else {
null
}
}
createdDecoder = newDecoder
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

LaunchedEffect key should include explicitDecoder dependency.

The effect checks explicitDecoder == null inside, but only uses sourceFile as the key. If explicitDecoder transitions from non-null to null (e.g., parent stops providing it), the effect won't re-run and no decoder will be created from sourceFile.

Also, createdDecoder?.close() on line 68 runs on the main thread. If close() performs blocking I/O, consider moving it inside withContext(Dispatchers.IO).

🔎 Proposed fix
-  LaunchedEffect(sourceFile) {
-    // Close any previously created decoder first
-    createdDecoder?.close()
-    createdDecoder = null
+  LaunchedEffect(sourceFile, explicitDecoder == null) {
+    // Close any previously created decoder on IO thread
+    createdDecoder?.let { decoder ->
+      withContext(Dispatchers.IO) {
+        decoder.close()
+      }
+    }
+    createdDecoder = null

     // Only create decoder from source file if no explicit decoder is provided
     if (explicitDecoder == null && sourceFile != null) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
LaunchedEffect(sourceFile) {
// Close any previously created decoder first
createdDecoder?.close()
createdDecoder = null
// Only create decoder from source file if no explicit decoder is provided
if (explicitDecoder == null && sourceFile != null) {
val newDecoder = sourceFile.let { file ->
if (file.exists() && file.canRead()) {
withContext(Dispatchers.IO) {
ImageRegionDecoder.create(file.absolutePath)
}
} else {
null
}
}
createdDecoder = newDecoder
}
}
LaunchedEffect(sourceFile, explicitDecoder == null) {
// Close any previously created decoder on IO thread
createdDecoder?.let { decoder ->
withContext(Dispatchers.IO) {
decoder.close()
}
}
createdDecoder = null
// Only create decoder from source file if no explicit decoder is provided
if (explicitDecoder == null && sourceFile != null) {
val newDecoder = sourceFile.let { file ->
if (file.exists() && file.canRead()) {
withContext(Dispatchers.IO) {
ImageRegionDecoder.create(file.absolutePath)
}
} else {
null
}
}
createdDecoder = newDecoder
}
}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (10)
landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/request/DisposableTest.kt (1)

112-117: Consider removing or clarifying the trivial assertion.

The assertion assertTrue(true) on line 116 is always true and doesn't validate any meaningful behavior. The actual test is whether await() completes without blocking indefinitely (which would cause a test timeout). Consider either removing this line or replacing it with a comment explaining that successful completion proves non-blocking behavior.

🔎 Proposed fix
   @Test
   fun `ImmediateDisposable await completes immediately`() = runTest {
-    // Should not block
     ImmediateDisposable.await()
-    assertTrue(true)
+    // Test passes if await() completes without blocking
   }
landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/event/EventListenerTest.kt (1)

28-36: Consider removing the trivial assertion.

The test correctly validates that the NONE listener doesn't throw. However, assertTrue(true) adds no value—the test passes by virtue of reaching the end without throwing.

🔎 Suggested simplification
   fun `NONE listener does not throw`() {
     val listener = EventListener.NONE
     val event = ImageLoadEvent.Started(model = "test", timestamp = 0L)
 
-    // Should not throw
     listener.onEvent(event)
-    assertTrue(true)
   }
landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/DownsamplingStrategyTest.kt (2)

32-49: Consider consolidating enum presence tests.

The four separate tests checking for AUTO, NONE, FIT, and FILL could be consolidated into a single test that iterates over expected values, reducing verbosity.

🔎 Suggested consolidation
  @Test
- fun `DownsamplingStrategy contains AUTO`() {
-   assertTrue(DownsamplingStrategy.entries.contains(DownsamplingStrategy.AUTO))
- }
-
- @Test
- fun `DownsamplingStrategy contains NONE`() {
-   assertTrue(DownsamplingStrategy.entries.contains(DownsamplingStrategy.NONE))
- }
-
- @Test
- fun `DownsamplingStrategy contains FIT`() {
-   assertTrue(DownsamplingStrategy.entries.contains(DownsamplingStrategy.FIT))
- }
-
- @Test
- fun `DownsamplingStrategy contains FILL`() {
-   assertTrue(DownsamplingStrategy.entries.contains(DownsamplingStrategy.FILL))
+ fun `DownsamplingStrategy contains all expected values`() {
+   val expected = listOf(
+     DownsamplingStrategy.AUTO,
+     DownsamplingStrategy.NONE,
+     DownsamplingStrategy.FIT,
+     DownsamplingStrategy.FILL
+   )
+   expected.forEach { value ->
+     assertTrue(DownsamplingStrategy.entries.contains(value))
+   }
  }

68-69: Consider verifying complete enum ordering.

The test only checks that AUTO has ordinal 0. You might want to validate the complete ordering of all enum values to ensure the sequence remains stable.

🔎 Suggested enhancement
  @Test
- fun `AUTO is first in enum`() {
+ fun `enum values have expected ordinals`() {
    assertEquals(0, DownsamplingStrategy.AUTO.ordinal)
+   assertEquals(1, DownsamplingStrategy.NONE.ordinal)
+   assertEquals(2, DownsamplingStrategy.FIT.ordinal)
+   assertEquals(3, DownsamplingStrategy.FILL.ordinal)
  }
landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/decoder/AnimatedImageDetectorTest.kt (1)

145-205: Consider documenting placeholder values.

The PNG helper functions use placeholder CRC values (all zeros), which is acceptable for detection logic testing since the detector only checks chunk signatures. Adding a brief comment explaining this choice would clarify intent for future maintainers.

Example documentation
 private fun buildPngWithoutAnimation(): ByteArray {
-  // PNG signature + IHDR chunk + IDAT chunk (simplified)
+  // PNG signature + IHDR chunk + IDAT chunk (simplified)
+  // Note: CRC values are placeholders (0x00) since detection only checks chunk names
   return byteArrayOf(
landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/request/RequestManagerTest.kt (1)

1-2: Consider updating the copyright year range.

The copyright header shows "2020-2023" but this PR is from 2025. Consider updating to "2020-2025" for consistency.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/scheduler/PrioritizedRequestTest.kt (1)

22-184: Optional: Consider test helper functions for request creation.

The test suite provides excellent coverage of PrioritizedRequest and DecodePriority behavior. To reduce duplication in request instantiation across multiple tests, consider extracting a helper function:

private fun createTestRequest(
  id: String = "test-id",
  priority: DecodePriority = DecodePriority.NORMAL,
  createdAt: Long = 0L,
  tag: String? = null,
  decoder: () -> Any = { }
) = PrioritizedRequest(id, priority, createdAt, tag, decoder)

This would make individual tests more concise while preserving clarity. However, the current explicit approach is also valid for test independence and readability.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/DataSourceTest.kt (1)

24-73: Consider simplifying enum tests.

These tests comprehensively verify Kotlin's built-in enum behavior (valueOf, name, ordinal, entries). While they ensure API stability and catch unexpected changes, most of this functionality is guaranteed by the language. Consider consolidating into fewer tests focused on the enum's actual contract:

  • Combine the individual "contains X" tests (lines 31-49) into a single test that verifies all expected values exist
  • The valueOf and name tests could potentially be parametrized or combined

However, if the goal is to maintain strict API guarantees or achieve 100% coverage for the core module, these tests serve that purpose.

Example consolidated approach
  @Test
- fun `DataSource has four values`() {
-   val values = DataSource.entries
-
-   assertEquals(4, values.size)
- }
-
- @Test
- fun `DataSource contains MEMORY`() {
-   assertTrue(DataSource.entries.contains(DataSource.MEMORY))
- }
-
- @Test
- fun `DataSource contains DISK`() {
-   assertTrue(DataSource.entries.contains(DataSource.DISK))
- }
-
- @Test
- fun `DataSource contains NETWORK`() {
-   assertTrue(DataSource.entries.contains(DataSource.NETWORK))
- }
-
- @Test
- fun `DataSource contains UNKNOWN`() {
-   assertTrue(DataSource.entries.contains(DataSource.UNKNOWN))
- }
+ fun `DataSource has expected values`() {
+   val expected = setOf(
+     DataSource.MEMORY,
+     DataSource.DISK, 
+     DataSource.NETWORK,
+     DataSource.UNKNOWN
+   )
+   
+   assertEquals(expected, DataSource.entries.toSet())
+ }
landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/CachePolicyTest.kt (1)

64-78: Consider consolidating built-in enum behavior tests.

Similar to DataSourceTest, the valueOf and name tests verify Kotlin's built-in enum functionality. While thorough, these could be consolidated or simplified since the language guarantees this behavior. However, they serve a purpose for API stability verification.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/ImageResultTest.kt (1)

54-54: Consider using assertNotNull for clearer failure messages.

The force unwrap !! works but provides a less informative error message if rawData is unexpectedly null. Consider using assertNotNull for better debugging.

Alternative approach
-    assertTrue(rawData.contentEquals(success.rawData!!))
+    assertNotNull(success.rawData)
+    assertTrue(rawData.contentEquals(success.rawData))
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cc0ddb7 and 5896580.

📒 Files selected for processing (16)
  • landscapist-core/build.gradle.kts
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/DownsamplingStrategyTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/ImageRequestBuilderTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/LandscapistConfigTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/cache/CacheKeyTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/cache/LruMemoryCacheTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/cache/TwoTierMemoryCacheTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/decoder/AnimatedImageDetectorTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/event/EventListenerTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/event/ImageLoadEventTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/CachePolicyTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/DataSourceTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/ImageResultTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/request/DisposableTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/request/RequestManagerTest.kt
  • landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/scheduler/PrioritizedRequestTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • landscapist-core/build.gradle.kts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: API check
  • GitHub Check: Spotless check
  • GitHub Check: Build and Tests
🔇 Additional comments (24)
landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/request/DisposableTest.kt (1)

29-93: LGTM! Comprehensive JobDisposable test coverage.

The test suite thoroughly verifies JobDisposable behavior including id matching, disposal states for active/cancelled/completed jobs, explicit disposal via dispose(), and awaiting completion. All tests properly clean up resources and use appropriate coroutine testing patterns.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/LandscapistConfigTest.kt (1)

26-163: LGTM! Comprehensive test coverage for configuration APIs.

The test suite thoroughly validates the configuration classes with appropriate coverage of:

  • Default values for all configuration types
  • Custom value preservation
  • Nested configuration behavior
  • Data class copy semantics

The tests are well-structured, clearly named, and use meaningful assertions.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/event/ImageLoadEventTest.kt (1)

23-252: LGTM! Excellent test coverage for the event hierarchy.

The test suite provides comprehensive validation of the ImageLoadEvent API:

  • Individual tests for all 12 event types verify correct property storage
  • Collection test validates polymorphic behavior across the hierarchy
  • Exhaustive when expression test ensures complete handling of all cases
  • Default timestamp behavior is validated

The tests are well-organized and provide strong guarantees about the event system's correctness.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/event/EventListenerTest.kt (1)

38-207: LGTM! Comprehensive coverage of EventListener implementations.

The test suite thoroughly validates:

  • NONE listener and factory behavior
  • LoggingEventListener with single and multiple events
  • CompositeEventListener dispatch and factory composition, including edge cases
  • MetricsEventListener state tracking for cache hits, durations, data sources, and reset behavior

The tests provide strong guarantees about the correctness of the event listener system.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/cache/CacheKeyTest.kt (1)

1-165: Excellent test coverage for CacheKey!

The test suite comprehensively validates CacheKey behavior including:

  • Hash consistency and uniqueness
  • Suffix generation for dimensions and transformations
  • Memory key composition
  • Factory method variants
  • Equality semantics

The tests are well-structured, focused, and use clear naming conventions.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/cache/TwoTierMemoryCacheTest.kt (1)

1-263: Strong test coverage for TwoTierMemoryCache!

The test suite thoroughly validates two-tier cache behavior including strong/weak cache interactions, LRU eviction, size management, and configuration variants. The helper methods provide clean test data construction.

Note: The weak reference cleanup test (lines 230-242) only verifies the method doesn't crash, as forcing GC in tests isn't feasible. The comment appropriately documents this limitation.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/ImageRequestBuilderTest.kt (1)

1-241: Comprehensive builder test coverage!

Excellent test suite that thoroughly validates:

  • Default value initialization
  • Individual builder methods
  • Fluent chaining behavior
  • Header immutability (lines 188-199)
  • CachePolicy read/write semantics
  • DecodePriority ordering

The tests demonstrate good practices including immutability verification and comprehensive builder pattern validation.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/cache/LruMemoryCacheTest.kt (1)

1-245: Excellent LRU cache test suite!

The tests comprehensively cover LruMemoryCache behavior including:

  • Basic CRUD operations
  • Size and count tracking
  • LRU eviction logic (lines 157-176 effectively validate that access patterns affect eviction)
  • Value replacement with size updates
  • Dynamic trimming and resizing

The helper methods provide clean, reusable test data construction. Well done!

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/decoder/AnimatedImageDetectorTest.kt (2)

24-141: Comprehensive test coverage!

The test suite thoroughly covers animated image detection across GIF, APNG, and WebP formats, including both MIME type and signature-based detection. Edge cases like empty and small data are also tested.


207-247: WebP test data correctly structured.

The WebP helper functions correctly construct RIFF/WEBP format with the animation flag (0x02) properly set in the VP8X chunk for animated WebP. The distinction between animated (VP8X with flag) and static (VP8 without VP8X) is accurately represented.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/request/RequestManagerTest.kt (3)

29-48: Excellent test coverage for initial state and registration.

These tests correctly verify the manager starts empty and that registration produces a valid disposable with the expected ID format. The use of runTest and proper job cleanup is appropriate.

Note: Line 44 asserts on the specific ID format prefix ("request-"), which couples the test to an implementation detail. This is acceptable for unit tests but consider whether this assertion adds sufficient value versus the brittleness if ID generation changes.


50-127: Comprehensive cancellation test coverage.

Excellent test coverage of all cancellation scenarios:

  • By ID (positive and negative cases)
  • By tag (selective cancellation verified)
  • By model
  • All active requests

Each test correctly verifies both return values and side effects (job cancellation state), and includes proper resource cleanup.


129-211: Well-designed query and lifecycle tests.

These tests provide excellent coverage of:

  • Active request queries (by model, all IDs, by tag)
  • Automatic cleanup on job completion using Job().complete() and join()
  • Request ID uniqueness across 100 registrations

The use of Job() factory (which returns CompletableJob) and calling complete() followed by join() is the correct approach for testing the automatic removal mechanism.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/scheduler/PrioritizedRequestTest.kt (6)

24-43: Excellent priority hierarchy coverage.

The DecodePriority tests clearly validate the priority ordering and boundary values. The slight overlap between the first two tests enhances readability and makes each test's intent explicit.


45-72: Property storage tests are solid.

These tests correctly verify that PrioritizedRequest stores all constructor parameters, including the nullable tag field. Not executing the decoder lambda in these tests is appropriate since they focus on storage semantics.


74-128: Comparison logic thoroughly validated.

These tests clearly demonstrate the expected ordering behavior: priority takes precedence, with FIFO ordering for equal priorities. The IMMEDIATE priority test nicely confirms that timestamp is irrelevant for the highest priority level.


130-146: CompareTo semantics correctly verified.

This test appropriately confirms that requests with identical priority and timestamp compare as equal (return 0) regardless of decoder instance, which is correct for priority-queue ordering. The test focuses on compareTo rather than structural equality, which aligns with the Comparable contract.


148-165: Comprehensive sorting validation.

This integration test excellently validates the complete ordering behavior, including the FIFO guarantee for requests with equal priority. The explicit assertions and inline comment (line 161) make the expected behavior crystal clear.


167-183: Copy semantics properly tested.

The test correctly verifies that copy() creates a new instance with the updated priority while preserving other properties.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/CachePolicyTest.kt (2)

32-62: Excellent coverage of CachePolicy semantics.

These tests thoroughly verify the custom readEnabled and writeEnabled properties for each policy variant, which is the core behavior of this enum. The test cases correctly validate the expected read/write permissions matrix.


80-96: Good use of higher-level matrix validation.

These matrix tests provide valuable verification by filtering and confirming the complete set of policies with each capability. This catches potential issues if the enum is extended or modified in the future.

landscapist-core/src/commonTest/kotlin/com/skydoves/landscapist/core/model/ImageResultTest.kt (3)

36-58: Comprehensive property verification.

This test thoroughly validates that all Success properties are correctly stored and accessible, including edge cases like rawData byte array comparison. Well done.


99-183: Excellent equality and hashCode coverage.

These tests thoroughly verify the custom equals implementation with multiple edge cases:

  • Identical rawData byte arrays (structural equality)
  • Different rawData content
  • Null rawData handling
  • Mixed null/non-null scenarios
  • hashCode consistency with equals contract

This is exemplary testing for data classes with array properties.


239-255: Good practice for sealed class exhaustiveness.

This test verifies that all sealed subtypes are covered in when expressions, which is valuable for catching issues if new subtypes are added in the future. The test structure ensures compilation fails if a new subtype is added without updating the when expression.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant