Blog

Back to all articles

Load Images From data: URI Using Coil in Android or Compose Multiplatform

A data: URI embeds a file payload directly in the URI string:

data:[<mediatype>][;base64],<data>

This is commonly used in web contexts, but also shows up in mobile apps when an API returns inline images, or when you want to drive Compose previews with hardcoded assets.

Coil, the popular image loading library for Android and Compose Multiplatform, doesn't support this scheme out of the box. This short post shows how to add support for it with a custom Coil Fetcher.

Parsing the URI

The first step is a small Kotlin value class that wraps the raw URI string and exposes the two pieces Coil needs: the MIME type and the raw bytes.

import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import java.net.URLDecoder
import java.util.Base64

/**
 * A parsed [RFC 2397](https://datatracker.ietf.org/doc/html/rfc2397) data URI of the form
 * `data:[<mediatype>][;base64],<data>`.
 *
 * @param uri The full data URI string.
 *
 * @throws IllegalArgumentException if [uri] does not start with `data:` or is missing the `,` separator.
 */
@JvmInline
value class DataUri(val uri: String) {

  companion object {
    internal const val BASE64_SUFFIX = ";base64"
    const val SCHEME = "data"
  }

  init {
    require(uri.startsWith("$SCHEME:")) {
      "Not a data URI: expected '$SCHEME:' scheme"
    }
    require(uri.indexOf(',', startIndex = SCHEME.length + 1) >= 0) {
      "Invalid data URI: missing ',' separator"
    }
  }

  /** MIME type declared in the URI header, or `null` if omitted. */
  val mediaType: MediaType?
    get() {
      val header = uri.removePrefix("$SCHEME:").substringBefore(',')

      return header.removeSuffix(BASE64_SUFFIX).toMediaTypeOrNull()
    }

  /**
   * Decoded payload bytes. Base64-encoded data is decoded directly; URL-encoded text is
   * percent-decoded using the charset from [mediaType], falling back to UTF-8.
   */
  val data: ByteArray
    get() {
      val (header, encodedData) = uri.removePrefix("$SCHEME:").split(",", limit = 2)
      val isBase64 = header.endsWith(BASE64_SUFFIX)

      return if (isBase64) {
        Base64.getDecoder().decode(encodedData)
      } else {
        val charset = mediaType?.charset() ?: Charsets.UTF_8

        URLDecoder.decode(encodedData, charset.name()).toByteArray(charset)
      }
    }

  operator fun component1(): MediaType? = mediaType

  operator fun component2(): ByteArray = data
}

Using a value class keeps this zero-cost — no extra object allocation at runtime, just a typed wrapper around the String.

The Coil Fetcher

A Coil Fetcher is responsible for translating a model into a stream of bytes. The inner Factory class acts as a guard — it returns null for anything that isn't a data: URI, letting other fetchers handle the rest.

import coil3.ImageLoader
import coil3.Uri
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import okio.BufferedSource
import okio.FileSystem

class DataUriFetcher(val data: Uri) : Fetcher {

  override suspend fun fetch(): FetchResult? {
    val (mediaType, bytes) = try {
      DataUri(data.toString())
    } catch (_: IllegalArgumentException) {
      return null
    }

    val source = ImageSource(
      source = bytes.bufferedSource(),
      fileSystem = FileSystem.SYSTEM,
      metadata = null,
    )

    return SourceFetchResult(
      source = source,
      mimeType = mediaType?.toString(),
      dataSource = DataSource.MEMORY,
    )
  }

  class Factory : Fetcher.Factory<Any> {
    override fun create(data: Any, options: Options, imageLoader: ImageLoader): Fetcher? {
      return if (data is Uri? && data.scheme == DataUri.SCHEME) DataUriFetcher(data) else null
    }
  }
}

private fun ByteArray.bufferedSource(): BufferedSource =
  okio.Buffer().write(this)

Two details worth noting:

  • DataSource.MEMORY — the data is already decoded in RAM, so Coil skips any disk-cache write for this result.
  • mimeType forwarding — passing the MIME type to SourceFetchResult lets Coil pick the right decoder automatically. A data:application/pdf URI will be routed to a PDF decoder, while data:image/png goes to the standard bitmap decoder.

Wiring It Into a Composable

Register the fetcher via ImageRequest.Builder.fetcherFactory(). Because the factory only activates for data: scheme URIs, adding it to a single request is safe — it won't interfere with normal URL loading.

@Composable
fun ImageView(uri: Uri, modifier: Modifier = Modifier) {
  val context = LocalContext.current
  val imageRequest = ImageRequest.Builder(context)
    .data(uri)
    .crossfade(true)
    .fetcherFactory(DataUriFetcher.Factory())
    //.decoderFactory(PdfPageDecoder.Factory())
    .build()

  AsyncImage(
    model = imageRequest,
    contentDescription = null,
    modifier = modifier,
    filterQuality = FilterQuality.High,
  )
}

The PdfPageDecoder.Factory() line is optional — it adds support for data:application/pdf URIs. Drop it if you only need raster image formats.

Result

With this in place, all the following URI types load through the same AsyncImage call:

data:image/png;base64,<bytes>
data:image/webp;base64,<bytes>
data:image/gif;base64,<bytes>
data:application/pdf;base64,<bytes>

No extra dependencies, no network call, no disk I/O — the bytes go straight from the URI string to the Coil decode pipeline.

Author: Matej Hlatký

Are you planning a new app?

Let's build something great together.

Wesley Brewer