What Happens When Your App Opens a 108MP Photo? The Limits Behind Large Images on Android

skydovesJaewoong Eum (skydoves)||21 min read

What Happens When Your App Opens a 108MP Photo? The Limits Behind Large Images on Android

A user taps "Change photo", picks a picture from the gallery, and your app decodes it with BitmapFactory.decodeStream(). It works for every image you tested, until someone picks a shot from a 108MP camera and the screen crashes with Canvas: trying to draw too large(432000000bytes) bitmap., a cropped result never makes it back through the activity result, or a selfie comes back mirrored. None of these are bugs in a single line of code. They are fixed limits in the platform, and Crayfish, a Compose Multiplatform image cropper, had to get past every one of them.

In this article, you'll start from the decode call you would write yourself and follow a large photo down through the platform: the canvas size limit, decode budgets and sample sizes, region decoding behind a byte budgeted tile cache, the second buffer a rotated crop needs, the Binder buffer an activity result travels through, and the four Exif orientations that are mirrors.

The fundamental problem: File size says nothing about memory

Here is the loading code most apps start with. It opens the Uri the picker returned and decodes the whole image:

fun loadPhoto(context: Context, uri: Uri): Bitmap? =
    context.contentResolver.openInputStream(uri)?.use { stream ->
        BitmapFactory.decodeStream(stream)
    }

A decoded bitmap does not care how the file was compressed. With the default ARGB_8888 config every pixel costs four bytes, so the memory a photo needs is its width times its height times four:

  • A 48MP photo (8000 × 6000): 192,000,000 bytes, about 183 MiB.
  • A 108MP photo (12000 × 9000): 432,000,000 bytes, about 412 MiB.
  • A 200MP photo: 800,000,000 bytes, about 763 MiB.

The file on disk gives you no warning. A 12000 × 9000 image filled with one flat color is 4.1 KiB as a lossless WebP, 326 KiB as a PNG, and 619 KiB as a JPEG at quality 75. All three decode to the same 412 MiB, because a decoder produces every pixel no matter how cheaply the file described them. A rule like "reject files over 10 MB" measures the wrong thing.

So where does the oversized bitmap fail? On Android 8.0 and higher, bitmap pixels live in native memory rather than on the Java heap, so on a device with enough free memory the decode itself can succeed. The failure arrives later, when the bitmap is drawn. If you examine RecordingCanvas, the canvas that records drawing commands for hardware accelerated rendering, you'll find a size ceiling:

private static int getPanelFrameSize() {
    final int DefaultSize = 150 * 1024 * 1024; // 150 MB;
    return Math.max(SystemProperties.getInt("ro.hwui.max_texture_allocation_size", DefaultSize),
            DefaultSize);
}

public static final int MAX_BITMAP_SIZE = getPanelFrameSize();

The drawBitmap overloads that draw at a position or into a rectangle, which is what Compose's Image ends up calling, check the bitmap against it:

@Override
protected void throwIfCannotDraw(Bitmap bitmap) {
    super.throwIfCannotDraw(bitmap);
    int bitmapSize = bitmap.getByteCount();
    if (bitmap.getConfig() != Bitmap.Config.HARDWARE && bitmapSize > MAX_BITMAP_SIZE) {
        throw new RuntimeException(
                "Canvas: trying to draw too large(" + bitmapSize + "bytes) bitmap.");
    }
}

Three details in these two snippets matter in practice:

  1. The limit depends on the Android version. The check arrived in Android 7.0 as a fixed 100 MiB. Android 15 raised the default to the 150 MiB shown here and exempted HARDWARE bitmaps, whose pixels live in graphics memory.
  2. A device can raise the ceiling but never lower it. Since Android 12, Math.max takes the larger of the system property and the default, so a manufacturer can allow more, but your code cannot count on it.
  3. The exception comes from drawing, not decoding. The stack trace points at the draw pass of a view or a composable, far away from the decodeStream call that created the problem.

Now compare the ceiling with the numbers above. A 48MP photo is already over 150 MiB, a 108MP photo is almost three times over it, and a 200MP photo is five times over. At full resolution these images are not slow to draw. They cannot be drawn at all, so the fix is not a larger heap. The fix is never creating that bitmap.

Decode budgets: Choosing the sample size before the decoder allocates

The first step is to learn the size of the image without paying for its pixels. BitmapFactory supports this with inJustDecodeBounds, which parses the header and reports the dimensions without allocating a bitmap:

val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { stream ->
    BitmapFactory.decodeStream(stream, null, bounds)
}
val width = bounds.outWidth
val height = bounds.outHeight

With the dimensions known, you choose inSampleSize, which shrinks each side by that factor. A sample size of 2 produces a quarter of the pixels, and 4 produces a sixteenth. The documentation of BitmapFactory.Options still says "the decoder uses a final value based on powers of 2, any other value will be rounded down to the nearest power of 2", but that describes Android 6.0 and earlier. Since Android 7.0, BitmapFactory scales the result to match the requested value, so a sample size of 3 gives a third of each side. Crayfish keeps every sample size a power of two anyway, so the scale it reports matches the pixels it gets back on decoders that round and on decoders that do not.

Crayfish writes its limits down as a type. A DecodeBudget holds two caps, one for bytes and one for the longest side:

public data class DecodeBudget(
  public val maxByteCount: Long,
  public val maxDimension: Int,
) {
  public companion object {
    public val ForDisplay: DecodeBudget = DecodeBudget(
      maxByteCount = 64L * 1024 * 1024,
      maxDimension = 4096,
    )
    public val ForOutput: DecodeBudget = DecodeBudget(
      maxByteCount = 192L * 1024 * 1024,
      maxDimension = 32_766,
    )
  }
}

The two budgets serve different jobs. ForDisplay covers anything drawn on screen, and its two numbers agree with each other: a 4096 × 4096 ARGB_8888 bitmap costs exactly 64 MiB, comfortably under the canvas ceiling with room left for the rest of the app. ForOutput covers the pixels of a finished crop that is encoded and handed back rather than drawn, so it allows a larger transient buffer.

Why keep dimensions separate from bytes? Because neither limit implies the other. A common 13MP portrait, 3120 × 4160, is only about 50 MiB, well inside the byte cap, yet its long side is past 4096. The GPU's maximum texture size differs between devices, and parts of the rendering pipeline depend on it: HWUI will not promote a view larger than that to a hardware layer (RenderProperties.fitsOnLayer()), and the OpenGL renderer used before Android 9 skipped drawing any bitmap that exceeded it. Capping every displayed bitmap at 4096 per side keeps the preview independent of both.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor