Jetpack Compose
Kotlin and Compose mirroring the iOS app name for name — Hilt singletons exposing StateFlow, a view model per screen, Room behind cached reads, one way to build an HTTP client, and a design-system guard in every build.
Verified 2026-10-03 · Against a production Compose super-app: Kotlin 2.3.10, AGP 9.0.1, KSP2, compile and target SDK 36, min SDK 24, Java 17, Compose BOM 2025.01, Hilt 2.59, Room 2.8, Retrofit 2.11, OkHttp 4.12, Coil 3.0. Excerpts are adapted from that codebase, with names changed. Blocks titled
examples/mobile/android/…are that example in this handbook: the iOS example's feature mirrored name for name, which its gate builds and tests on the JVM (Gradle 9.2, JDK 25 compiling for Java 17), never on an emulator. The rules both apps share are on native mobile; this page is what is particular to Android.
Libraries and versions
One version catalog, gradle/libs.versions.toml, is the only place a version is
written. Repositories are declared once in settings
(RepositoriesMode.FAIL_ON_PROJECT_REPOS).
| Concern | Choice | Version |
|---|---|---|
| Language, build | Kotlin, AGP, KSP2, Java 17 with desugaring | 2.3.10, 9.0.1 |
| UI | Compose BOM, Material 3 (inside the design system only) | 2025.01.01 |
| Navigation | navigation-compose, typed @Serializable routes | 2.8.5 |
| Lifecycle | lifecycle, activity-compose | 2.8.7 |
| Injection | Hilt, hilt-navigation-compose, hilt-work | 2.59.2 |
| Background work | WorkManager | 2.12.0 |
| Local storage | Room | 2.8.4 |
| HTTP | Retrofit, OkHttp, kotlinx-serialization (Cronet for HTTP/3) | 2.11.0, 4.12.0, 1.7.3 |
| Concurrency | kotlinx-coroutines | 1.9.0 |
| Images | Coil (compose, okhttp, svg) | 3.0.4 |
| Push, attestation, analytics | Firebase BOM (messaging, analytics, App Check Play Integrity) | 33.9.0 |
| Sign-in | Credential Manager, googleid | 1.6.0, 1.2.0 |
| Secrets | Android Keystore, biometric | 1.1.0 |
| Tests | JUnit 4, MockWebServer, Paparazzi | 4.13.2, —, 2.0 |
| Store upload | Gradle Play Publisher | 4.0.0 |
The example's catalog, the one place its versions are written:
# The only place a version is written. The production app's catalog, cut to what this example uses.
[versions]
agp = "9.0.1"
kotlin = "2.3.10"
ksp = "2.3.6"
composeBom = "2025.01.01"
activityCompose = "1.9.3"
lifecycle = "2.8.7"
navigationCompose = "2.8.5"
hilt = "2.59.2"
hiltNavigationCompose = "1.2.0"
room = "2.8.4"
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
kotlinxSerializationConverter = "1.0.0"
coroutines = "1.9.0"
coreDesugaring = "2.1.4"
junit = "4.13.2"
paparazzi = "2.0.0-alpha05"
[libraries]
android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "coreDesugaring" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "kotlinxSerializationConverter" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
Never: LiveData; GlobalScope; an OkHttpClient() or HttpURLConnection built
outside the one client factory; a Material component, .dp or .sp in a feature
screen; a second image loader; a version written outside the catalog.
Mirroring iOS
Every Android file has an iOS counterpart and the same name, except that a
SwiftUI XView becomes XScreen plus XViewModel. Read the Swift file before
writing the Kotlin one. Never invent an Android-only component or token name.
domain/model/Project.kt # the Project value
data/remote/ProjectsApi.kt # stands in for the generated client
data/remote/ProjectsRemoteSource.kt # the generated client's reply, mapped onto Project
data/local/ProjectEntity.kt, ProjectsDao.kt
data/local/ProjectsLocalStore.kt # the storage port and its Room implementation
data/repository/ProjectsRepository.kt # @Singleton, StateFlow<State>
ui/ProjectsScreen.kt, ProjectDetailScreen.kt
ui/ProjectsViewModel.kt # @HiltViewModel
ui/components/ProjectCard.kt # composed from design-system pieces onlyThe iOS example's Features/Projects/ holds the same names, with XView for XScreen.
Composition
@HiltAndroidAppapplication class installs the process-wide network rule beforesuper.onCreate(), so no request is built without it.- One
@AndroidEntryPointactivity injects the root stores and provides the shared ones to the tree asCompositionLocals (translations, feature flags, icon family). Screens read them; only the root fetches. - Stores are constructor-injected
@Singletons, withdagger.Lazywhere a store should not be built at launch.
Hilt's modules play the part of the iOS composition root, in the same order: the rule, then the client, then the generated clients.
package com.example.projects.core.di
import android.content.Context
import com.example.projects.BuildConfig
import com.example.projects.core.auth.IdentityExchange
import com.example.projects.core.auth.IdentitySession
import com.example.projects.core.auth.PreferencesTokenStore
import com.example.projects.core.config.AppConfig
import com.example.projects.core.network.AppError
import com.example.projects.core.network.PreferencesUpdateRefusalMemory
import com.example.projects.core.network.RequestPolicy
import com.example.projects.core.network.UpdateRefusalMemory
import com.example.projects.core.network.platformHttpClient
import com.example.projects.core.network.platformRetrofit
import com.example.projects.features.projects.data.remote.ProjectsApi
import com.example.projects.features.projects.data.remote.ProjectsRemoteSource
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.OkHttpClient
import javax.inject.Singleton
/**
* The network tiers of the composition root, in iOS `AppComposition`'s order: the outbound rule with
* this build's remembered refusal before any client exists, then the session and the one client
* (tier 1), then the remote sources (tier 2). Repositories (tier 3) are `@Singleton`s built on first use.
*/
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun config(): AppConfig = AppConfig(
apiBaseUrl = BuildConfig.API_BASE_URL,
apiVersion = BuildConfig.API_VERSION,
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
)
@Provides
@Singleton
fun refusals(@ApplicationContext context: Context): UpdateRefusalMemory =
PreferencesUpdateRefusalMemory(context.getSharedPreferences("network", Context.MODE_PRIVATE))
@Provides
@Singleton
fun standing(config: AppConfig, memory: UpdateRefusalMemory): BuildStanding =
BuildStanding(memory.isRefused(config.appVersion))
// Before any request is built: the outbound rule, with this build's remembered refusal.
@Provides
@Singleton
fun policy(config: AppConfig, memory: UpdateRefusalMemory, standing: BuildStanding): RequestPolicy =
RequestPolicy(config, memory, onChange = standing::change)
// Tier 1 — infrastructure
@Provides
@Singleton
fun session(@ApplicationContext context: Context): IdentitySession = IdentitySession(
PreferencesTokenStore(context.getSharedPreferences("projects.session", Context.MODE_PRIVATE)),
NoIdentityExchange,
)
@Provides
@Singleton
fun client(policy: RequestPolicy, session: IdentitySession): OkHttpClient = platformHttpClient(policy, session)
// Tier 2 — remote sources
@Provides
@Singleton
fun projectsRemote(config: AppConfig, client: OkHttpClient): ProjectsRemoteSource =
ProjectsRemoteSource(platformRetrofit(config, client).create(ProjectsApi::class.java), BuildConfig.WORKSPACE_ID)
}
/** Whether this build is refused, for the must-update cover. Mirrors iOS `BuildStanding`. */
class BuildStanding(refused: Boolean) {
private val mutableRefused = MutableStateFlow(refused)
val refused: StateFlow<Boolean> = mutableRefused.asStateFlow()
fun change(refused: Boolean) {
mutableRefused.value = refused
}
}
/**
* The identity endpoints' attested client (Play Integrity through App Check) is the tenant app's to
* supply, as iOS's app target supplies `exchange`. This example has none: every sign-in finds no answer.
*/
private object NoIdentityExchange : IdentityExchange {
override suspend fun signInAsGuest(): String = throw AppError.Offline
override suspend fun productToken(session: String): String = throw AppError.Offline
}
State: repository flows, view model per screen
A repository is a @Singleton that owns a StateFlow<State>, its own
supervised coroutine scope on Dispatchers.IO, and the refresh cycle. A view
model passes the repository's state through and launches work in
viewModelScope.
package com.example.projects.features.projects.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.projects.features.projects.data.repository.ProjectsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** The projects pages' view model: the repository's state, passed through. iOS's `ProjectsView` does this in `.task`. */
@HiltViewModel
class ProjectsViewModel @Inject constructor(
private val repository: ProjectsRepository,
) : ViewModel() {
/** Seen before, the projects draw at once and refresh in place; the skeleton is for a first visit only. */
val state: StateFlow<ProjectsRepository.State> = repository.state
init {
viewModelScope.launch {
repository.loadIfNeeded()
repository.refresh()
}
}
fun reload() {
viewModelScope.launch { repository.refresh() }
}
}
package com.example.projects.features.projects.data.repository
import com.example.projects.core.data.LoadPhase
import com.example.projects.core.data.LoadStatus
import com.example.projects.core.data.isStale
import com.example.projects.core.data.phase
import com.example.projects.core.network.AppError
import com.example.projects.features.projects.data.local.ProjectsLocalStore
import com.example.projects.features.projects.data.remote.ProjectsRemoteSource
import com.example.projects.features.projects.domain.model.Project
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
/**
* The projects this phone holds. A screen draws them at once from storage, then the repository
* refreshes in place; a failed refresh keeps them and says so through [LoadStatus]. Mirrors iOS
* `ProjectsRepository`.
*/
@Singleton
class ProjectsRepository internal constructor(
private val local: ProjectsLocalStore,
private val remote: ProjectsRemoteSource,
private val dispatcher: CoroutineDispatcher,
) {
@Inject
constructor(local: ProjectsLocalStore, remote: ProjectsRemoteSource) : this(local, remote, Dispatchers.IO)
data class State(
val projects: List<Project> = emptyList(),
override val isLoading: Boolean = false,
override val hasLoaded: Boolean = false,
override val error: AppError? = null,
) : LoadStatus {
val phase: LoadPhase get() = phase(hasContent = projects.isNotEmpty())
val isStale: Boolean get() = isStale(hasContent = projects.isNotEmpty())
}
private val mutableState = MutableStateFlow(State())
val state: StateFlow<State> = mutableState.asStateFlow()
private val hasRead = AtomicBoolean(false)
/** What storage holds, on first access. Safe to call on every appearance. */
suspend fun loadIfNeeded() {
if (!hasRead.compareAndSet(false, true)) return
val kept = withContext(dispatcher) { local.projects() }
mutableState.update { it.copy(projects = kept) }
}
/** Reads the server's list and keeps it. A failure keeps what is on screen. */
suspend fun refresh() {
loadIfNeeded()
mutableState.update { it.copy(isLoading = true) }
try {
val kept = withContext(dispatcher) {
local.replace(remote.projects())
local.projects()
}
mutableState.update { it.copy(projects = kept, error = null) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
mutableState.update { it.copy(error = failure as? AppError ?: AppError.Decoding) }
} finally {
mutableState.update { it.copy(isLoading = false, hasLoaded = true) }
}
}
fun project(id: String): Project? = state.value.projects.firstOrNull { it.id == id }
}
The repository's state implements the same LoadStatus as iOS, so the screen
turns it into the same four LoadPhase cases — content, loading, failed, empty —
and draws cached content first.
- Account changes: each private store collects the identity store's
userIdFlowand re-binds itself. The screen only composes:
package com.example.projects.features.projects.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.projects.core.config.LocalTranslations
import com.example.projects.core.navigation.Route
import com.example.projects.designsystem.components.AppLink
import com.example.projects.designsystem.components.LoadedSection
import com.example.projects.designsystem.components.PageLayout
import com.example.projects.designsystem.components.PageSection
import com.example.projects.features.projects.ui.components.ProjectCard
/** The projects page: what the phone holds at once, refreshed in place. Mirrors iOS `ProjectsView`. */
@Composable
fun ProjectsScreen(onOpen: (Route) -> Unit, viewModel: ProjectsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val t = LocalTranslations.current
PageLayout(title = t("projects.title")) {
PageSection(title = t("projects.section.all")) {
LoadedSection(
phase = state.phase,
isStale = state.isStale,
emptyMessage = t("projects.empty"),
failedMessage = if (state.isStale) t("projects.stale") else t("projects.failed"),
retryTitle = t("projects.retry"),
retry = viewModel::reload,
) {
state.projects.forEach { project ->
AppLink(route = Route.Project(project.id), onOpen = onOpen) { ProjectCard(project) }
}
}
}
}
}
- App-wide lifecycle work runs in
ProcessLifecycleOwner.lifecycleScopewith aDefaultLifecycleObserver, the counterpart of iOS'sscenePhase.
Navigation
- Routes are
@Serializabletypes used withcomposable<T>; no string routes. FeatureRouteris anobjectreturning the route for an entity reference, the same map as iOS.- Presentation goes through the design system:
AppSheet,AppFullScreenCover, the shared confirm sheet and form dialog.AlertDialog,ModalBottomSheetandDialogare refused in screens.
The network
platformHttpClient {} is the one way to build an HTTP client. It derives
from a root client, so every client shares the connection pool and carries the
same interceptors in the same order.
package com.example.projects.core.network
import android.content.SharedPreferences
import com.example.projects.core.config.AppConfig
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import retrofit2.Retrofit
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* The one outbound rule. Every send asks it to admit the request, then settles the answer with it:
*
* - a request to the platform's origin names the API version and the build; any other request, and
* any redirect away, carries neither, whoever set them;
* - after a must-update answer, requests to the platform stay on the phone, except the startup reads
* and one probe at a time, until an answer names its version again.
*
* `tooling/check.py` refuses an `OkHttpClient` built anywhere but `core/network`, so no request goes
* around it. [onChange] hears every change of standing, so the must-update cover opens and closes
* while the app runs, not only at launch. Mirrors iOS `RequestPolicy`.
*/
class RequestPolicy(
private val config: AppConfig,
private val memory: UpdateRefusalMemory,
private val startupPaths: Set<String> = setOf("/api/startup"),
private val onChange: (Boolean) -> Unit = {},
) {
/** What a send carries from [admitted] to [settle]; [isProbe]: the one probe let through while refused. */
class Admission(val request: Request, val isProbe: Boolean)
private val platform = config.apiBaseUrl.toHttpUrl()
private val lock = Any()
private var refused = memory.isRefused(config.appVersion)
private var probing = false
val isRefused: Boolean get() = synchronized(lock) { refused }
/** Whether [url] is on the platform's origin: same scheme, host and port. */
fun isPlatform(url: HttpUrl?): Boolean =
url != null && url.scheme == platform.scheme && url.host == platform.host && url.port == platform.port
/**
* [request] as it may leave. Also applied to a redirect's new request ([PlatformRedirects]), so a
* hop away from the platform carries neither header.
*/
fun prepared(request: Request): Request {
val builder = request.newBuilder().removeHeader(VERSION_HEADER).removeHeader(BUILD_HEADER)
if (isPlatform(request.url)) builder.header(VERSION_HEADER, config.apiVersion).header(BUILD_HEADER, config.appVersion)
return builder.build()
}
/**
* [request] as it may leave, or null when it must stay on the phone. A caller answers null as the
* must-update error without sending anything.
*/
fun admitted(request: Request): Admission? {
val prepared = prepared(request)
if (!isPlatform(prepared.url) || prepared.url.encodedPath in startupPaths) return Admission(prepared, isProbe = false)
return synchronized(lock) {
when {
!refused -> Admission(prepared, isProbe = false)
probing -> null
else -> {
probing = true
Admission(prepared, isProbe = true)
}
}
}
}
/**
* Tells the rule what the answer to an admitted request said, or that none came ([response] null).
* Only an answer from the platform counts: must-update refuses this build, any other answer naming
* its version lifts the refusal.
*/
fun settle(admission: Admission, response: Response?) {
val verdict = response?.takeIf { isPlatform(it.request.url) }?.let {
when {
it.code == AppError.UPDATE_REQUIRED_STATUS &&
AppError.of(it.code, it.peekBody(PEEKED_BYTES).string()) == AppError.UpdateRequired -> true
it.header(VERSION_HEADER) != null -> false
else -> null
}
}
val changed = synchronized(lock) {
if (admission.isProbe) probing = false
if (verdict == null || verdict == refused) return@synchronized false
refused = verdict
true
}
if (!changed || verdict == null) return
memory.remember(verdict, config.appVersion)
onChange(verdict)
}
companion object {
const val VERSION_HEADER = "API-Version"
const val BUILD_HEADER = "App-Version"
private const val PEEKED_BYTES = 4_096L
}
}
/** Where a must-update refusal is kept, so the cover opens at every launch of a refused build. */
interface UpdateRefusalMemory {
fun isRefused(build: String): Boolean
fun remember(refused: Boolean, build: String)
}
class PreferencesUpdateRefusalMemory(private val preferences: SharedPreferences) : UpdateRefusalMemory {
override fun isRefused(build: String): Boolean = preferences.getString(KEY, null) == build
override fun remember(refused: Boolean, build: String) {
preferences.edit().putString(KEY, if (refused) build else null).apply()
}
private companion object {
const val KEY = "network.refusedBuild"
}
}
/** Admits each attempt a send makes (a refused build's request stays here) and settles it with its answer. */
class PlatformOutbound(private val policy: RequestPolicy) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val admission = policy.admitted(chain.request()) ?: throw AppError.UpdateRequired
val response = try {
chain.proceed(admission.request)
} catch (error: IOException) {
policy.settle(admission, null)
throw error
}
policy.settle(admission, response)
return response
}
}
/** Re-applies the rule to every hop, so a redirect away from the platform carries neither header. */
class PlatformRedirects(private val policy: RequestPolicy) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(policy.prepared(chain.request()))
}
/**
* The one way the app builds an HTTP client: the send loop ([APIClient]), then the outbound rule for
* each attempt, and the rule again on every redirect hop. OkHttp's own retry is off, so one owner
* retries; no cookies and no cache are kept. Mirrors iOS `URLSessionTransport`.
*/
fun platformHttpClient(
policy: RequestPolicy,
credentials: CredentialProviding,
pause: (attempt: Int) -> Unit = { attempt -> Thread.sleep(250L shl attempt) },
): OkHttpClient = OkHttpClient.Builder()
.retryOnConnectionFailure(false)
.readTimeout(15, TimeUnit.SECONDS)
.addInterceptor(APIClient(credentials, pause))
.addInterceptor(PlatformOutbound(policy))
.addNetworkInterceptor(PlatformRedirects(policy))
.build()
private val replies = Json { ignoreUnknownKeys = true }
/** The generated clients' Retrofit, on the one client and the platform's origin. */
fun platformRetrofit(config: AppConfig, client: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl(config.apiBaseUrl)
.client(client)
.addConverterFactory(replies.asConverterFactory("application/json".toMediaType()))
.build()
package com.example.projects.core.network
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
/** Answers a send with the bearer token to carry, and renews it once when the server refused it. */
interface CredentialProviding {
suspend fun bearer(): String
suspend fun renew()
}
/** Marks a request as a public read: no credentials, so a cache in front may share the answer. */
data object PublicRead
fun Request.Builder.publicRead(): Request.Builder = tag(PublicRead::class.java, PublicRead)
/**
* The one way a request reaches the platform: carrying credentials unless it is a public read,
* retried by one owner, and turned into an [AppError] on failure. The client's first interceptor, so
* the outbound rule after it admits every attempt. Mirrors iOS `APIClient`.
*
* - A `401` renews the credentials once and sends again; a second `401` is `Unauthorized`.
* - Only a read, or a request carrying an `Idempotency-Key`, is repeated after a retryable failure.
*/
class APIClient(
private val credentials: CredentialProviding,
private val pause: (attempt: Int) -> Unit,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val publicRead = request.tag(PublicRead::class.java) != null
val repeatable = request.method == "GET" || request.header("Idempotency-Key") != null
var renewed = false
var attempt = 0
while (true) {
try {
return sendOnce(chain, request, publicRead)
} catch (error: AppError) {
when {
error == AppError.Unauthorized && !publicRead && !renewed -> {
renewed = true
runBlocking { credentials.renew() }
}
error.isRetryable && repeatable && attempt + 1 < ATTEMPTS -> {
pause(attempt)
attempt += 1
}
else -> throw error
}
}
}
}
private fun sendOnce(chain: Interceptor.Chain, request: Request, publicRead: Boolean): Response {
val signed = request.newBuilder().removeHeader(AUTHORIZATION)
if (!publicRead) signed.header(AUTHORIZATION, "Bearer ${runBlocking { credentials.bearer() }}")
val response = try {
chain.proceed(signed.build())
} catch (error: AppError) {
throw error
} catch (error: IOException) {
throw AppError.Offline
}
if (response.isSuccessful) return response
val body = response.use { it.body?.string().orEmpty() }
throw AppError.of(response.code, body) ?: AppError.Server(response.code, null)
}
companion object {
/** How many times a repeatable request is tried in all. */
const val ATTEMPTS = 3
private const val AUTHORIZATION = "Authorization"
}
}
The send loop is an interceptor ahead of the rule, so the rule admits and settles
every attempt, as the iOS client's loop does. AppError is an IOException, so OkHttp
hands it to Retrofit's suspend call instead of crashing a background thread.
- OkHttp's own retry and redirect following are off. One retry interceptor owns retries; the rule owns redirects.
- Generated clients: the platform SDK is generated from the OpenAPI contracts, locked by revision and source digest in a lock file, fetched into a bare git cache, checked and built atomically. A local SDK override is refused for release builds.
- Call a product through its generated client, checked against its
/api/<product>/mount. No hand-written DTO or Retrofit interface for a product API.
Storage and secrets
- Room behind a snapshot cache feeds the repository's flow. Before the first release, schema changes reset the database destructively; after it, they ship as migrations.
- Tokens live in encrypted preferences with a Keystore key, a biometric prompt guarding the biometric copy.
- App Check: Play Integrity in release; the debug provider only in the
debugsource set.
The design-system guard runs in every build
The guard is a preBuild dependency, so a violation fails assembleDebug as
well as the commit hook and CI. Outside the design system it refuses about ninety
native Material and foundation names, decoration modifiers (clickable among them),
Color, RoundedCornerShape, .dp and .sp, override parameters (shape =,
colors =) unless given a design-system value, Material and Coil imports, and a
screen or view model placed inside the design system.
Compose has no compile-time alias trick, so the build-time guard is the equivalent of iOS's unavailable type aliases.
val composeRules by tasks.registering(Exec::class) {
workingDir = projectDir
commandLine("python3", "tooling/check.py", "--root", ".")
}
tasks.named("preBuild") { dependsOn(composeRules) }#!/usr/bin/env python3
"""The Compose rules the compiler cannot enforce, refused in every Kotlin file outside the design system.
python3 tooling/check.py [--root DIR]
A file whose path has a `designsystem` folder owns Material, styling and painting; every other file
composes it. Requests leave only from `core`, through platformHttpClient and the request rule. A
literal that is a translation key ("projects.title") or a mark with no letter ("·") is not words. Tests, test support and build output are not
scanned. There is no allowlist: a rule that does not fit is changed here, with its reason, never
skipped in a file. Compose has no unavailable-alias trick, so this is iOS's compile-time guard too.
"""
import argparse
import re
import sys
from pathlib import Path
SKIP = {"build", ".gradle", ".git", ".kotlin", "tooling", "node_modules", "testing"}
TEST_SOURCES = {"test", "androidTest"}
# (rule, pattern, what to do instead). Matched on code with comments removed and string contents blanked.
OUTSIDE_DESIGN_SYSTEM = [
("native-control", r"\bimport\s+androidx\.compose\.material|\b(Text|Button|TextField|OutlinedTextField|Card|Surface|"
r"Scaffold|TopAppBar|AlertDialog|ModalBottomSheet|Dialog|LazyColumn)\s*[({]",
"compose a design-system component (AppText, AppButton, AppLink…)"),
("styling", r"\.(background|clip|border|shadow|alpha)\s*\(|\.clickable\b",
"styling belongs to the design system: use or extend one of its components"),
("paint", r"\bColor\s*\(|\bColor\.(Red|Green|Blue|Black|White|Gray|LightGray|DarkGray|Yellow|Cyan|Magenta)\b|"
r"\bRoundedCornerShape\s*\(|\bCircleShape\b|\bBrush\.",
"use AppColors and the design system's shapes"),
("literal-size", r"\b\d[\d_.]*f?\s*\.\s*(dp|sp)\b", "use an AppSpacing or AppTypography token"),
("spinner", r"\b(CircularProgressIndicator|LinearProgressIndicator)\b",
"a first load draws LoadedSection's skeleton, never a spinner"),
("image-loader", r"\bAsyncImage\b|\bimport\s+coil\d*\.", "use the design system's one image loader"),
("literal-words", r'\bAppText\(\s*(text\s*=\s*)?""|\bAppButton\(\s*(title\s*=\s*)?""',
"every word is translated: read it from LocalTranslations"),
]
EVERYWHERE = [
("live-data", r"\b(Mutable)?LiveData\b", "expose a StateFlow"),
("global-scope", r"\bGlobalScope\b", "launch in viewModelScope or the repository's own scope"),
]
NETWORK = ("network-door", r"\bOkHttpClient(\.Builder)?\s*\(|\bHttpURLConnection\b|\bRetrofit\.Builder\s*\(",
"requests leave only through core (platformHttpClient and the request rule)")
KEY = re.compile(r"[a-z0-9_]+(\.[a-z0-9_]+)+")
def plain_code(source):
"""The source with comments removed and string contents blanked, line breaks kept. A string with a
template keeps a marker, so a pure literal ("Projects") and a composed one ("$count left") stay apart."""
out = []
i = _code(source, 0, out, until=None)
out.append(source[i:])
return "".join(out)
def _code(source, i, out, until):
"""Copies code from `i` until the brace that closes a `${` template (or the end), blanking as it goes."""
depth = 0
while i < len(source):
if source.startswith("//", i):
end = source.find("\n", i)
i = len(source) if end < 0 else end
elif source.startswith("/*", i):
end = source.find("*/", i + 2)
end = len(source) if end < 0 else end + 2
out.append("\n" * source.count("\n", i, end))
i = end
elif source[i] == '"':
i = _string(source, i, out)
elif source[i] == "'":
match = re.compile(r"'(?:[^'\\\n]|\\.)*'").match(source, i)
out.append("''")
i = match.end() if match else i + 1
elif until and source[i] == "}" and depth == 0:
return i
else:
depth += {"{": 1, "}": -1}.get(source[i], 0)
out.append(source[i])
i += 1
return i
def _string(source, i, out):
"""Blanks the string opening at `i` ("…" or \"\"\"…\"\"\"), templates included; returns where it ends."""
quote = '"""' if source.startswith('"""', i) else '"'
i += len(quote)
start = i
templated, lines = False, 0
while i < len(source) and not source.startswith(quote, i):
if quote == '"' and source[i] == "\n":
break
if source[i] == "\\" and quote == '"':
i += 2
elif source.startswith("${", i):
templated = True
i = _code(source, i + 2, [], until="}") + 1
elif source[i] == "$" and re.match(r"\$[A-Za-z_]", source[i:i + 2]):
templated, i = True, i + 1
else:
lines += source[i] == "\n"
i += 1
text = source[start:i]
if templated:
out.append('"$"')
elif KEY.fullmatch(text) or not re.search(r"[^\W\d_]", text):
out.append('"key"')
else:
out.append('""')
out.append("\n" * lines)
return i + len(quote)
def kotlin_files(root):
for path in sorted(root.rglob("*.kt")):
parts = path.relative_to(root).parts[:-1]
in_tests = any(a == "src" and b in TEST_SOURCES for a, b in zip(parts, parts[1:]))
if in_tests or any(p in SKIP for p in parts):
continue
yield path
def violations(root):
root = Path(root).resolve()
out = []
for path in kotlin_files(root):
rel = path.relative_to(root).as_posix()
parts = path.relative_to(root).parts[:-1]
in_design_system = "designsystem" in parts
in_core = "core" in parts
rules = list(EVERYWHERE) + ([] if in_design_system else OUTSIDE_DESIGN_SYSTEM)
rules += [] if in_core else [NETWORK]
for number, plain in enumerate(plain_code(path.read_text(encoding="utf-8")).splitlines(), start=1):
for rule, pattern, instead in rules:
if re.search(pattern, plain):
out.append(f"{rel}:{number}: {rule}: {instead}")
return out
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--root", default=".", help="the folder to scan (default: here)")
args = parser.parse_args(argv)
found = violations(args.root)
for line in found:
print(line)
if found:
print(f"❌ {len(found)} Compose rule violation(s)")
return 1
print("✅ Compose rules hold")
return 0
if __name__ == "__main__":
sys.exit(main())
A translation key ("projects.title") and a mark with no letter ("·") are not words;
anything else written in a screen is refused.
Proof
# The handbook's Jetpack Compose example: built and tested on this Mac's JVM, never on an emulator or a
# device. `gradle` is the one on PATH because binary files stay out of the handbook; a real project
# commits the Gradle wrapper and calls ./gradlew.
.DEFAULT_GOAL := check
.PHONY: check test rules rules-tests mirror
# The Android SDK: local.properties' sdk.dir when it exists, else ANDROID_HOME, else the usual Mac place.
export ANDROID_HOME ?= $(HOME)/Library/Android/sdk
check: test rules rules-tests mirror ## the tests, every Compose rule and the rules' own tests, then the mirror rule
test: ## The JVM tests and the screenshot tests against the committed pictures (Paparazzi), run every time, each named
gradle testDebugUnitTest --rerun verifyPaparazziDebug --console=plain
rules: ## What the compiler cannot refuse: Material, styling, painting, words and requests outside their owners
@python3 tooling/check.py --root .
rules-tests: ## The rules themselves fail on planted violations
@python3 tooling/test_check.py
mirror: ## Android mirrors iOS name for name: screens, components, data roles, tokens; then the rule's own tests
@python3 tooling/check_mirror.py --root ..
@python3 tooling/test_check_mirror.py
- Logic and data: JUnit with literal inputs, MockWebServer behind the real client, and an in-memory storage port. The tests carry the iOS tests' names, so the two runs read as the same list.
package com.example.projects.features.projects.data.repository
import com.example.projects.core.data.LoadPhase
import com.example.projects.core.network.AppError
import com.example.projects.core.network.FailureKind
import com.example.projects.core.network.RequestPolicy
import com.example.projects.core.network.platformHttpClient
import com.example.projects.core.network.platformRetrofit
import com.example.projects.features.projects.data.remote.ProjectsApi
import com.example.projects.features.projects.data.remote.ProjectsRemoteSource
import com.example.projects.testing.InMemoryProjectsStore
import com.example.projects.testing.MemoryRefusals
import com.example.projects.testing.StaticCredentials
import com.example.projects.testing.config
import com.example.projects.testing.noAnswer
import com.example.projects.testing.projectsReply
import com.example.projects.testing.reply
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class ProjectsRepositoryTest {
@get:Rule val server = MockWebServer()
private val store = InMemoryProjectsStore()
/** A repository over the one store, whose server answers with [answers] in turn. */
private fun TestScope.repository(vararg answers: MockResponse): ProjectsRepository {
answers.forEach(server::enqueue)
val client = platformHttpClient(RequestPolicy(config(server), MemoryRefusals()), StaticCredentials(), pause = {})
val api = platformRetrofit(config(server), client).create(ProjectsApi::class.java)
return ProjectsRepository(store, ProjectsRemoteSource(api, "w-1"), UnconfinedTestDispatcher(testScheduler))
}
private val ProjectsRepository.names get() = state.value.projects.map { it.name }
@Test
fun a_first_visit_draws_the_skeleton_then_the_projects() = runTest {
val repository = repository(reply(200, projectsReply(listOf("Bangui water"))))
repository.loadIfNeeded()
assertEquals(LoadPhase.loading, repository.state.value.phase)
repository.refresh()
assertEquals(LoadPhase.content, repository.state.value.phase)
assertEquals(listOf("Bangui water"), repository.names)
}
@Test
fun a_returning_visit_draws_the_kept_projects_before_any_answer() = runTest {
repository(reply(200, projectsReply(listOf("Bangui water", "Bimbo school")))).refresh()
val next = repository()
next.loadIfNeeded()
assertEquals(LoadPhase.content, next.state.value.phase)
assertEquals(setOf("Bangui water", "Bimbo school"), next.names.toSet())
}
@Test
fun a_failed_refresh_keeps_the_projects_and_says_they_are_stale() = runTest {
repository(reply(200, projectsReply(listOf("Bangui water")))).refresh()
val next = repository(noAnswer(), noAnswer(), noAnswer())
next.refresh()
assertEquals(LoadPhase.content, next.state.value.phase)
assertTrue(next.state.value.isStale)
assertEquals(FailureKind.noConnection, next.state.value.error?.failure)
assertEquals(listOf("Bangui water"), next.names)
}
@Test
fun nothing_kept_and_a_failure_offers_a_retry() = runTest {
val repository = repository(reply(500, "{}"), reply(500, "{}"), reply(500, "{}"))
repository.refresh()
assertEquals(LoadPhase.failed, repository.state.value.phase)
}
@Test
fun the_server_saying_none_is_empty_not_failed() = runTest {
val repository = repository(reply(200, projectsReply(emptyList())))
repository.refresh()
assertEquals(LoadPhase.empty, repository.state.value.phase)
}
@Test
fun a_reply_the_app_cannot_read_keeps_what_was_kept() = runTest {
repository(reply(200, projectsReply(listOf("Bangui water")))).refresh()
val next = repository(reply(200, """{"items":[{"id":"x"}]}"""))
next.refresh()
assertEquals(AppError.Decoding, next.state.value.error)
assertEquals(listOf("Bangui water"), next.names)
}
}
- Components and screens: Paparazzi screenshots with fake data, compared against pictures committed with the code. No device, no emulator.
package com.example.projects.features.projects.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import com.android.ide.common.rendering.api.SessionParams
import com.example.projects.core.config.LocalTranslations
import com.example.projects.core.config.Translations
import com.example.projects.designsystem.theme.AppColors
import com.example.projects.designsystem.theme.AppSpacing
import com.example.projects.features.projects.domain.model.Project
import org.junit.Rule
import org.junit.Test
import java.io.File
import java.time.Instant
/** The card drawn on the JVM with fake data, compared with the committed picture. No device, no emulator. */
class ProjectCardRenderTest {
@get:Rule val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5, renderingMode = SessionParams.RenderingMode.SHRINK)
private val translations = Translations.decode(File("src/main/assets/${Translations.DEFAULTS}").readText())
@Test
fun a_card_shows_the_name_the_country_and_the_status() {
val updated = Instant.parse("2026-09-01T10:00:00Z")
paparazzi.snapshot {
CompositionLocalProvider(LocalTranslations provides translations) {
Column(Modifier.background(AppColors.page).padding(AppSpacing.pageInset), verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)) {
ProjectCard(Project("p-1", "Bangui water", "CF", Project.Status.active, updated))
ProjectCard(Project("p-2", "Bimbo school", "CF", Project.Status.archived, updated))
}
}
}
}
}
- On a phone:
make runinstalls on a connected device, only when asked. Never an emulator.