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
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3