Oferta ograniczona czasowo: plany roczne Starter i Basic 50% taniej 🎉

Android Thumbnails from a URI: A Practical Developer Guide

Oct 13, 2026

Why Thumbnail Loading Is a Systems Problem

A photo grid is one of the most demanding screens an Android app can ship. It asks the app to bind hundreds of items into a scrolling list, decode pixels fast enough to hold a steady frame rate, and keep memory flat while the user flings through months of camera roll. Thumbnails sit directly on that critical path. When they are slow, the list stutters. When they are oversized, the process is killed. When they are mismatched, the user sees a receipt where a holiday photo should be.

Most images on a modern device are not plain files. They reach the app as content URIs: opaque identifiers that a content provider resolves on the app's behalf. That indirection is what makes Android storage safe and flexible, and it is also what makes naive thumbnail code fail in ways that are hard to reproduce on a fast test device with twenty photos in the camera roll.

A thumbnail pipeline has four jobs: resolve the URI, decode only the pixels the view needs, keep blocking work off the main thread, and remember results so scrolling stays smooth. Everything else is detail. The trouble is that each job has a failure mode that appears only on real libraries and real hardware. A device with 40,000 photos, a cloud-backed document provider, and a low-end chipset will expose problems that never show up in a demo app.

This guide walks through the whole pipeline in order. It covers URI semantics, stream handling, sampled decoding, coroutine scoping, caching layers, memory behavior, and the testing habits that catch regressions before users do. The code is written in Kotlin, but the reasoning applies equally to Java codebases and to cross-platform layers that call into Android storage.

What a Content URI Actually Points To

A content URI looks like content://media/external/images/media/12345 or content://com.example.provider/files/selfie.jpg. Unlike a file path, it carries no guarantee about where the bytes live. They might be in shared media storage, in another app's private sandbox, on a removable SD card, inside a cloud-backed document provider, or streamed from a remote source entirely.

That abstraction exists because of scoped storage. Since Android 10, apps cannot freely read arbitrary paths on external storage, and the media permissions were later split into separate read permissions for images, video, and audio. A content URI plus a granted permission is the supported way to ask for an image, and it is the only way that keeps working across providers.

Two consequences follow immediately.

  • Never convert a content URI into a file path and decode it. File(uri.path) may occasionally appear to work in permissive situations, but it fails for document providers, cloud-backed URIs, and most sandboxed sources. The failure is usually a FileNotFoundException or a null bitmap rather than a clear permission error, which makes it expensive to diagnose.
  • Assume the URI can expire. A URI obtained from the Storage Access Framework stays valid only for as long as the process lives, unless you explicitly take a persistable permission grant. If you store the URI string in a database for a later session, you must persist the grant too. Otherwise the thumbnail request becomes a security exception after a reboot or a low-memory process restart.

MediaStore URIs versus document URIs

MediaStore URIs are stable and queryable. A single cursor query returns the media identifier, byte size, MIME type, orientation, and modification date. That metadata is extremely useful for planning a decode before attempting one, because it tells you whether the source is a 400 KB JPEG or a 48 MB uncompressed capture.

Document URIs from ACTION_OPEN_DOCUMENT are more varied. Some providers report a size, some return minus one, and some expose data only through an open file descriptor. A robust loader treats metadata as a hint rather than a contract and degrades gracefully whenever a value is missing. If you build your cache key or your decode budget on an assumption that the size field is always populated, you will ship a bug that appears only for users of one particular cloud storage app.

Reading Bytes Safely Through ContentResolver

The ContentResolver is the entry point for everything that follows. It handles permission enforcement, provider inter-process communication, and stream lifecycle. Always consume the stream inside a use block so it is closed even when decoding throws:

fun openImage(resolver: ContentResolver, uri: Uri): Bitmap? {
    return resolver.openInputStream(uri)?.use { streamInput -
        BitmapFactory.decodeStream(streamInput)
    }
}

That snippet works, but it is also the version that eventually crashes on a 108 megapixel photo, because decodeStream allocates the full-size bitmap. The safe version asks the framework for metadata first and decodes at a target size.

For providers that support it, openAssetFileDescriptor returns both a stream and a declared length, which helps detect truncated files or estimate a decode budget. A defensive pattern combines a metadata query with a descriptor lookup:

val projection = arrayOf(
    MediaStore.Images.Media._ID,
    MediaStore.Images.Media.SIZE,
    MediaStore.Images.Media.MIME_TYPE,
    MediaStore.Images.Media.ORIENTATION
)

resolver.query(uri, projection, null, null, null)?.use { cursorData -
    if (cursorData.moveToFirst()) {
        val bytes = cursorData.getLong(
            cursorData.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
        )
        val mimeType = cursorData.getString(
            cursorData.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
        )
        // use bytes and mimeType to choose a decode strategy
    }
}

Querying the URI directly is cheap. It avoids the wasted work of opening a stream only to discover that the file is a RAW image you have no intention of decoding at full resolution.

Decoding at the Size the Screen Needs

The single biggest source of thumbnail crashes is decoding far more pixels than the view can display. A grid tile that occupies 300 by 300 pixels on screen should never be backed by a 4000 by 3000 bitmap. The tools to prevent that have existed in BitmapFactory.Options for years and still work well.

Two-pass bounds decoding

The first pass reads only the header. Set inJustDecodeBounds to true, decode with no pixel consumer, and read the reported width, height, and MIME type. No pixel memory is allocated:

val bounds = BitmapFactory.Options()
bounds.inJustDecodeBounds = true
resolver.openInputStream(uri)?.use { headerStream -
    BitmapFactory.decodeStream(headerStream, null, bounds)
}

val sourceWidth = bounds.outWidth
val sourceHeight = bounds.outHeight
// if either value is zero or negative, the stream is not a decodable image

The second pass sets inSampleSize. Sample sizes that are powers of two produce predictable quality, and the classic helper loops by doubling until the scaled dimensions drop below the requested size. The logic in words: start at one, double the candidate, and keep the largest candidate whose scaled width and scaled height both remain at least as large as the target. Because the ratio is rounded down to a power of two, the decoded bitmap is usually a little larger than the request, which is fine and often preferable to an extra scaling pass.

If you need tighter control, decode with the sample size first and then scale the result. Measure before optimizing, because the extra pass costs time and a second allocation.

Color configuration choices

The default configuration uses four bytes per pixel. For opaque photographic thumbnails, a 16-bit configuration halves memory at a visible but often acceptable quality cost, and it is a poor choice for images with transparency, smooth gradients, or sharp interface detail. Hardware-backed bitmaps store pixel data in graphics memory, reducing heap pressure and speeding up drawing. The trade-off is that they are immutable and cannot be read back pixel by pixel, so they are unsuitable when you need to post-process the result before display.

Use this decision order: opaque photo thumbnail in a grid, consider the 16-bit configuration; anything with transparency or brand-critical color, keep 32-bit; display-only tiles that never get edited, consider hardware bitmaps.

Provider Thumbnails or Hand-Rolled Decoding

Android offers a built-in shortcut on modern API levels: ContentResolver.loadThumbnail, which asks the provider to generate or reuse a cached thumbnail. This is frequently faster than decoding yourself because the work may already have been done when the media was scanned.

Reach for the provider path when:

  • The device runs a recent API level and the URI comes from MediaStore.
  • You need a quick grid preview and do not require exact pixel control.
  • You can pass a cancellation signal so abandoned requests stop early.

Decode manually when:

  • You need a specific aspect ratio or exact dimensions that the provider will not honor.
  • You must support older API levels with one code path.
  • The URI is not from MediaStore, or the provider throws an unsupported operation for thumbnails.
  • You want to transform the result before display, for example rounding corners or adjusting color.

A robust loader tries the fast path first and falls back. Wrap the fast path in a result-capturing helper rather than a bare call, because providers are third-party code. Some throw a missing-file exception, some throw an I/O exception, and some misbehave in ways the documentation never promised. Handle the failure without taking down the list.

Coroutines, Flows, and Cancellation in the UI Layer

Disk reads and decode work belong on an I/O dispatcher. Anything that touches a view belongs on the main dispatcher. Make the crossover explicit instead of assuming a dispatcher, because dispatcher inheritance depends on which scope launched the work.

viewModelScope.launch {
    val bitmap = withContext(Dispatchers.IO) {
        loadThumbnailCompat(contentResolver, uri, targetSize)
    }
    stateHolder.updateThumbnail(bitmap)
}

For reactive screens, a state flow of the current URI combined with a latest-value mapping operator gives you automatic cancellation. When the URI changes, the in-flight decode for the previous image is cancelled rather than completing and overwriting the new state. Add a small subscription timeout so a briefly backgrounded screen does not tear down and rebuild the whole pipeline.

The recycled-view trap

In a recycler-backed list, binding is asynchronous. A fast scroll can rebind a holder to a new URI before the previous decode finishes. If the callback blindly assigns the bitmap, the row shows the wrong image, and the bug is maddening to reproduce because it depends on scroll velocity.

The standard fixes are an identity check at assignment time and cancellation on rebind. Store the requested URI on the item view as a tag and compare it before drawing. Alternatively, keep a job reference per holder and cancel it in the rebind path. Popular image libraries solve this internally with request cancellation tied to the holder lifecycle. Rolling your own loader means owning the same problem explicitly.

Caching That Actually Pays Off

Reloading a thumbnail every time a row is recycled wastes battery and produces visible flicker. Two cache layers cover nearly every practical case.

Memory cache. Size an LRU cache against the available heap instead of guessing a fixed number. A workable starting point is one eighth of the maximum runtime memory, measured in kilobytes, with each entry weighted by its allocated byte count divided by 1024.

val maxKb = (Runtime.getRuntime().maxMemory() / 1024 / 8).toInt()
val memoryCache = object : LruCache(maxKb) {
    override fun sizeOf(key: String, value: Bitmap): Int =
        value.allocationByteCount / 1024
}

Disk cache. Store encoded thumbnails, not raw bitmaps, as JPEG or WebP files inside the cache directory. Key each file by a hash of the URI plus the target dimensions so the same photo requested at two sizes does not thrash the same slot.

Key design deserves care. A content URI can stay identical while the underlying pixels change. The user edits a photo and the media store assigns a new modification date while often keeping the same identifier. Include the modification date in the cache key when it is available. If it is not, accept that stale thumbnails are a known limitation and provide a manual refresh path. Also clear both caches on low-memory callbacks and, if your loader is scoped to an activity, on configuration changes.

Memory Behavior and Bitmap Lifecycle

On modern Android, bitmap pixel data lives on the managed heap rather than in a separate native allocation, so the old advice about aggressive recycling calls is largely obsolete. In current code, clearing references and letting the collector work is usually correct. Recycling a bitmap that is still attached to an image view produces a crash the next time the framework draws it.

What still matters is what you hold onto. A callback that captures an activity context inside a long-lived cache keeps that activity alive. Use the application context for anything cached, and tie request lifecycles to a scope that is cancelled when the screen goes away. If you are loading into a list, prefer a scope tied to the holder or to a view model rather than a global singleton, unless you are deliberately building a shared loader.

When you must handle very large source images, apply these tactics in order:

  1. Decode at the target size with a sample size and never allocate full resolution.
  2. Use the 16-bit configuration for opaque thumbnails where quality permits.
  3. Keep decoded bitmaps immutable so the system can use hardware-backed storage for display-only cases.
  4. Cap the number of concurrent decodes. Eight parallel 20 megapixel decodes will exhaust memory even when each resulting thumbnail is small.
  5. Prefer reusing an existing provider thumbnail over decoding a fresh one.

Building a Compact Loader End to End

Putting the pieces together, a minimal but production-minded loader resolves the URI, checks the memory cache, tries the provider thumbnail path, falls back to sampled decoding, and stores the result. The shape looks like this:

class ThumbnailLoader(
    private val resolver: ContentResolver,
    private val memoryCache: LruCache,
    private val io: CoroutineDispatcher = Dispatchers.IO
) {
    suspend fun load(uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? =
        withContext(io) {
            val key = uri.toString() + ":" + reqWidth + "x" + reqHeight
            memoryCache.get(key)?.let { cached -
                return@withContext cached
            }

            val decoded = try {
                fastThumbnail(uri, reqWidth, reqHeight)
                    ?: decodeSampled(uri, reqWidth, reqHeight)
            } catch (securityProblem: SecurityException) {
                null
            } catch (ioProblem: IOException) {
                null
            }

            decoded?.let { memoryCache.put(key, it) }
            decoded
        }
}

Notice what the loader does not do. It does not assume the URI is a file. It does not decode at full resolution. It does not hold an activity context. It does not crash when a provider misbehaves. The catch blocks stay narrow on purpose, because swallowing every exception hides genuine bugs while letting a security exception escape creates a crash loop that repeats on every rebind.

Two refinements are worth adding once the basic version works. First, de-duplicate identical requests so two rows asking for the same URI at the same size share one decode instead of racing. Second, expose a small diagnostics hook that records which path served each request. Knowing that 92 percent of tiles came from the provider thumbnail path tells you exactly where to optimize next.

Mistakes, Testing, and Troubleshooting

Mistakes that survive code review

  • Converting the URI to a path. It breaks on document providers and under scoped storage, and it fails silently in permissive cases.
  • Decoding inside the bind callback. Main-thread decoding causes dropped frames, and full-size decoding causes out-of-memory kills.
  • Ignoring orientation. Some providers supply images with rotation metadata. Handle it on the input stream or rely on the provider thumbnail path, which usually handles it already.
  • Ignoring process death. A persisted URI string without a persisted permission grant throws after a restart.
  • Recycling defensively. It saves nothing on modern Android and introduces crashes.
  • Unbounded concurrency. A dozen simultaneous decodes is a memory spike waiting to happen. Limit them with a semaphore or a fixed-size dispatcher.
  • Caching on the wrong key. A key built from the URI alone will serve a stale image after the user edits a photo.

A test matrix that finds real bugs

Create a stress folder containing deliberately awkward files: a very large panorama, a transparent PNG, a grayscale JPEG, a modern high-efficiency image from a recent phone, a zero-byte file, and a file whose extension does not match its contents. Scroll the grid quickly in both directions with the memory profiler attached. Watch for a sawtooth pattern that never returns to baseline, which points to a leak rather than ordinary garbage collection. Enable strict mode checks for disk reads and writes during development to catch accidental main-thread input and output. Finally, run everything on a low-end device or an emulator with a small heap. A flagship phone will happily hide problems that budget hardware exposes every day.

Benchmark the provider path and the fallback separately. If the provider path is consistently faster on your target devices, tune the grid to request sizes that align with the variants the provider caches. If manual decoding wins for your aspect ratios, invest in the disk cache so repeated scrolls avoid decoding entirely.

A quick troubleshooting checklist

When a thumbnail silently fails to appear, work outward from the source. Confirm the permission is actually granted at runtime. Confirm the URI came from the current process or carries a persisted grant. Confirm the provider is still installed and enabled. Then check the loader: is the bounds pass returning zero dimensions, is the decoded bitmap null, is the cache returning an entry that was evicted, and is the view still bound to the same URI. Most production failures land in one of those five spots, and checking them in order takes about two minutes.

Frequently asked questions

Why does opening the input stream return null? The provider refused the request or the URI is stale. Verify that the permission is granted, that the URI originates from the current process or a persisted grant, and that the provider still exists on the device.

Should I just use a third-party image library? For most apps, yes. Established libraries already implement sampled decoding, two-layer caching, request cancellation, and lifecycle awareness. Build a custom loader when you need tight control, when dependency size matters, or when you are integrating into an existing media pipeline that already owns caching.

How do I avoid duplicate requests during fast scrolling? Cancel in-flight work when a holder is rebound, and de-duplicate identical cache keys so two rows requesting the same URI at the same size share a single decode.

Does the built-in thumbnail path handle rotation? Usually for media store images, but not universally across providers. Verify with real files and keep a metadata fallback for cases where the result appears rotated.

What size should I request for a grid tile? Measure the tile in density-independent pixels, multiply by the display density, and add a small margin for rounding. Requesting close to the visible size keeps memory predictable without visible softness.

How do I handle a library with tens of thousands of images? Paginate the query rather than loading every URI at once, prefetch roughly one screen ahead, and let the caches carry the rest. A well-tuned loader keeps total bitmap memory proportional to the number of visible items, not to the size of the library.

Is a disk cache worth the complexity for a small app? Only if users revisit the same images across sessions. If a session is short and the memory cache already covers the visible window, skip the disk layer and keep the code smaller.

Thumbnail loading rewards discipline more than cleverness. Resolve through the provider, decode at the size you need, keep the work off the main thread, cache in two layers, and treat every provider as capable of failing. Do that and the gallery becomes the least interesting part of the codebase, which is exactly the outcome you want.

Alexander

Alexander