Loading images from URIs in Android can be tricky, especially when supporting a wide range of API levels. If your app targets minSdk 21+ but wants to use the latest features on newer devices, you need a backward-compatible solution.

In this guide, we'll compare:

Modern approach (API 28+): ImageDecoder (better performance, more features)
Legacy approach (API 21+): BitmapFactory (works everywhere but less optimized)

Why Do We Need Different Approaches?

  • ImageDecoder (API 28+) is the modern way, offering:

  • Better memory handling

  • Support for animated WebP/AVIF
  • Post-processing during decode

  • BitmapFactory (API 1+) is the old way, but still reliable for older devices.

Problem: If you only use ImageDecoder, your app will crash on Android 8.1 and below.

2. The Solution: Backward-Compatible Image Loading

Here’s how to safely load a bitmap from a Uri while supporting all devices:

Step 1: Add Required Imports

import android.graphics.BitmapFactory
import android.os.Build
import androidx.annotation.RequiresApi

Step 2: Load the Image Safely

fun loadBitmapFromUri(context: Context, uri: Uri): Bitmap? {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        // Modern way (API 28+)
        loadWithImageDecoder(context.contentResolver, uri)
    } else {
        // Legacy way (API 21-27)
        loadWithBitmapFactory(context.contentResolver, uri)
    }
}

@RequiresApi(Build.VERSION_CODES.P)
private fun loadWithImageDecoder(contentResolver: ContentResolver, uri: Uri): Bitmap? {
    val source = ImageDecoder.createSource(contentResolver, uri)
    return ImageDecoder.decodeBitmap(source)
}

private fun loadWithBitmapFactory(contentResolver: ContentResolver, uri: Uri): Bitmap? {
    return try {
        contentResolver.openInputStream(uri)?.use { stream ->
            BitmapFactory.decodeStream(stream)
        }
    } catch (e: Exception) {
        e.printStackTrace()
        null
    }
}

Step 3: Usage Example

val imageUri: Uri = ... // From gallery, file, or camera
val bitmap = loadBitmapFromUri(requireContext(), imageUri)
imageView.setImageBitmap(bitmap)

3. Key Takeaways

Always check Build.VERSION.SDK_INT before using newer APIs.
Use try-catch when dealing with file operations (URIs can fail!).
Close streams properly with .use { } to avoid memory leaks.
Prefer ImageDecoder on newer devices for better performance.

4. Bonus: Handling Large Images Efficiently

Both methods can lead to OutOfMemoryError if the image is too large. To fix this:

Option 1: Downsample Before Loading

private fun loadWithBitmapFactoryDownsampled(
    contentResolver: ContentResolver,
    uri: Uri,
    reqWidth: Int,
    reqHeight: Int
): Bitmap? {
    return contentResolver.openInputStream(uri)?.use { stream ->
        val options = BitmapFactory.Options().apply {
            inJustDecodeBounds = true
            BitmapFactory.decodeStream(stream, null, this)
            stream.reset() // Rewind stream to read again
            inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
            inJustDecodeBounds = false
        }
        BitmapFactory.decodeStream(stream, null, options)
    }
}

private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val (height, width) = options.run { outHeight to outWidth }
    var inSampleSize = 1

    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}

Option 2: Use a Library (Recommended for Production)

Libraries like Glide or Coil handle all this complexity for you:

// Using Coil (Kotlin-first)
imageView.load(imageUri)

// Using Glide
Glide.with(context).load(imageUri).into(imageView)

Final Thoughts

  • For simple cases: Use the backward-compatible code above.
  • For production apps: Use Glide/Coil—they handle caching, memory, and threading automatically.

Hope this helps! 🚀