Guides / Tutorials

Dog breed images in Jetpack Compose

By the Dog Image API team. Updated .

Request GET /api/v1/breeds/{breed} with Ktor or Retrofit, parse it with kotlinx.serialization, and hand a preview_512 URL to Coil's AsyncImage. Previews need no key. The app never holds one: full-size files are your backend's job.

The images are AI-generated illustrations of one adult dog, drawn from nine views on a transparent background. In Compose that transparency lets you place the dog on any surface color of your theme.

What stays out of the APK

An APK can be unpacked, and BuildConfig fields are plain text inside it. So the app calls only keyless URLs: the breed list, the breed detail and the preview files. Full-size PNG files need a key. Your server downloads them once, stores them and serves them to the app. Cache and self-host dog breed images describes the job.

Dependencies

The versions below were the ones shown in the Ktor and Coil documentation when this guide was written. Check the release notes before you copy them.

// build.gradle.kts (module)
plugins {
    // Use the same version as your Kotlin Gradle plugin.
    kotlin("plugin.serialization") version "<your Kotlin version>"
}

dependencies {
    implementation("io.ktor:ktor-client-okhttp:3.6.1")
    implementation("io.ktor:ktor-client-content-negotiation:3.6.1")
    implementation("io.ktor:ktor-serialization-kotlinx-json:3.6.1")

    implementation("io.coil-kt.coil3:coil-compose:3.6.3")
    implementation("io.coil-kt.coil3:coil-network-okhttp:3.6.3")
}

Coil 3 needs a network artifact next to coil-compose, as the Coil network documentation explains. Add the internet permission to the manifest too:

<uses-permission android:name="android.permission.INTERNET" />

About WebP

Android decodes WebP itself, and the Android supported formats page lists transparency support from Android 4.2.1. You add no WebP library for the previews. If a preview does not show, test it on an emulator or device first, then check the Coil error callback.

Models

Two @Serializable classes cover what this screen reads. Names with underscores map through @SerialName.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class Catalog(val breeds: List<BreedSummary>)

@Serializable
data class BreedSummary(
    val slug: String,
    val name: String,
    @SerialName("default_variant") val defaultVariant: String,
)

@Serializable
data class BreedDetail(
    val slug: String,
    val name: String,
    @SerialName("default_variant") val defaultVariant: String,
    val variants: List<Variant>,
)

@Serializable
data class Variant(val slug: String, val name: String, val views: Map<String, ViewImages>)

@Serializable
data class ViewImages(
    @SerialName("preview_512") val preview512: String,
    @SerialName("preview_1024") val preview1024: String,
)

The responses hold more fields, such as summary and full. The client below ignores unknown keys, so the models stay small.

The client

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

class DogImageApi {
    private val base = "https://dogimage.dev/api/v1"

    private val client = HttpClient(OkHttp) {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
    }

    suspend fun breeds(): List<BreedSummary> = client.get("$base/breeds").body<Catalog>().breeds

    suspend fun breed(slug: String): BreedDetail = client.get("$base/breeds/$slug").body()
}

If your project already uses Retrofit, the same models work with the kotlinx.serialization converter:

interface DogImageService {
    @GET("breeds")
    suspend fun breeds(): Catalog

    @GET("breeds/{slug}")
    suspend fun breed(@Path("slug") slug: String): BreedDetail
}

Build it with Retrofit.Builder().baseUrl("https://dogimage.dev/api/v1/") and a JSON converter factory.

A view model

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

data class BreedUiState(
    val breeds: List<BreedSummary> = emptyList(),
    val selected: BreedDetail? = null,
    val viewId: String = "front",
    val error: String? = null,
)

class BreedViewModel(private val api: DogImageApi = DogImageApi()) : ViewModel() {
    private val _state = MutableStateFlow(BreedUiState())
    val state: StateFlow<BreedUiState> = _state

    init {
        viewModelScope.launch {
            runCatching { api.breeds() }
                .onSuccess { list -> _state.value = _state.value.copy(breeds = list) }
                .onFailure { _state.value = _state.value.copy(error = "Could not load breeds.") }
        }
    }

    fun select(slug: String) = viewModelScope.launch {
        runCatching { api.breed(slug) }
            .onSuccess { _state.value = _state.value.copy(selected = it, error = null) }
            .onFailure { _state.value = _state.value.copy(error = "Could not load that breed.") }
    }

    fun showView(id: String) { _state.value = _state.value.copy(viewId = id) }
}

The Compose screen

Pick a breed from a list, then switch views with chips. The image uses the breed's default variant.

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage

@Composable
fun BreedScreen(vm: BreedViewModel = viewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    val uriHandler = LocalUriHandler.current
    val detail = state.selected
    val variant = detail?.variants?.firstOrNull { it.slug == detail.defaultVariant } ?: detail?.variants?.firstOrNull()

    Column(Modifier.fillMaxSize().padding(16.dp)) {
        if (detail != null && variant != null) {
            AsyncImage(
                model = variant.views[state.viewId]?.preview512,
                contentDescription = "${detail.name}, ${state.viewId} view",
                contentScale = ContentScale.Fit,
                modifier = Modifier.fillMaxWidth().height(260.dp),
            )
            TextButton(onClick = { uriHandler.openUri("https://dogimage.dev/dogs/${detail.slug}") }) {
                Text("${detail.name} image: Dog Image API")
            }
            LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                items(variant.views.keys.toList()) { id ->
                    FilterChip(selected = id == state.viewId, onClick = { vm.showView(id) }, label = { Text(id) })
                }
            }
        }
        state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
        LazyColumn(Modifier.weight(1f)) {
            items(state.breeds, key = { it.slug }) { breed ->
                Text(breed.name, Modifier.fillMaxWidth().clickable { vm.select(breed.slug) }.padding(vertical = 12.dp))
            }
        }
    }
}

AsyncImage takes a nullable model, so the image area stays empty until the data arrives. The credit button sits right under the image, and its text follows the form the Samoyed page uses for its snippet.

Next steps

The head view makes a good avatar, and a side view such as left suits a header. If the app needs files bigger than 1024 pixels, serve them from your own storage. The license and pricing pages explain what the full-size files allow and cost.

Questions

Does Android decode the WebP previews?

Yes. The Android platform decodes WebP, including transparency from Android 4.2.1. You add no WebP library to load the previews.

Should I put the API key in BuildConfig?

No. BuildConfig values ship inside the APK and can be read. Previews need no key. Full-size files should be downloaded by your backend and served from your own storage.

Can I use Retrofit instead of Ktor?

Yes. Both work with kotlinx.serialization. The models are the same, and only the request code changes.

Which preview size fits a Compose list row?

The 512 pixel preview suits a thumbnail or an avatar. Use the 1024 pixel one for a header image that spans the screen.

Next steps