Blog › ICP guides

Android developer on retainer: Kotlin coroutines, Jetpack Compose stability, WorkManager constraints, and Play Store compliance on monthly retainer

August 8, 2026 · ~18 min read

A 40-person fintech startup shipped their Android app eighteen months ago. The codebase was idiomatic Kotlin, the architecture was MVVM with ViewModels and LiveData, and the initial release had received strong Play Store reviews. By the time they migrated to Jetpack Compose and Kotlin coroutines over the following year, two quiet problems had accumulated that no individual on the three-person Android team had the depth to diagnose cleanly.

The first problem surfaced when QA started reporting that the app drained battery faster after the Compose migration. A senior engineer opened Layout Inspector and found the main feed screen was triggering hundreds of recompositions per second — not per user action, but continuously, even when the UI was idle. The root cause took a fractional Android architect two hours to find: the FeedItem data class held a List<Tag> field. Kotlin’s standard List interface is mutable under the hood (the runtime type is typically ArrayList), and the Compose compiler conservatively infers any class holding a List as unstable. An unstable composable cannot be skipped during recomposition — it always recomposes when its parent recomposes, regardless of whether its inputs actually changed. With thirty FeedItem composables on screen, each holding a List<Tag>, the recomposition cascade was constant.

The second problem was quieter and more dangerous. After migrating from LiveData to Flow, the team collected their ViewModels’ StateFlow emissions directly in lifecycleScope.launch { viewModel.uiState.collect { ... } }. This looks correct — lifecycleScope is lifecycle-aware and cancels when the fragment is destroyed — but it does not pause collection when the app goes to the background. The Flow collection continues running while the screen is off, keeping the ViewModel subscription active and preventing downstream producers from being garbage collected. On devices with aggressive battery management (particularly certain Xiaomi and Huawei OEM builds), this pattern contributed to wakelock retention and background battery drain.

Android developers, Kotlin engineers, and Android architects on monthly retainer — fractional Android engineers, Compose consultants, and Kotlin coroutine advisors — do their highest-value work in the coroutine lifecycle design, Compose stability tuning, WorkManager background constraint architecture, and Play Store compliance governance that produces the stable, performant, policy-compliant app that survives both the OS and Google’s annual policy enforcement cycle. This guide covers Kotlin coroutines and Flow, Jetpack Compose’s stability system, the Android Gradle build system, WorkManager background work, the Google Play Integrity API, and Android testing — and how to structure an Android developer retainer that makes the hours behind each platform function visible.

Kotlin coroutines and Flow

Kotlin coroutines are the foundation of modern Android concurrency. Every network call, database query, and UI state emission in a contemporary Android app goes through a coroutine. The discipline gap between a developer who knows the coroutine APIs and an architect who understands their lifecycle semantics is where the battery drain bugs, the Flow subscription leaks, and the mysterious UI freezes on search originate.

StateFlow vs. SharedFlow: emit, tryEmit, replay, and overflow

StateFlow and SharedFlow are the two hot Flow implementations Android developers use for reactive state management. They are not interchangeable, and the choice between them has architectural consequences.

StateFlow is a state holder: it always has a current value, emits that value immediately to new collectors, and conflates emissions (if the upstream emits faster than the collector consumes, intermediate values are dropped and the collector always sees the latest value). It is the correct type for UI state:

class SearchViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
    val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()

    fun search(query: String) {
        viewModelScope.launch {
            _uiState.value = SearchUiState.Loading
            try {
                val results = repository.search(query)
                _uiState.value = SearchUiState.Success(results)
            } catch (e: Exception) {
                _uiState.value = SearchUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

SharedFlow is an event bus: it has no inherent current value, supports configurable replay (how many past emissions new collectors receive), and has configurable overflow behavior for when the buffer is full. It is the correct type for one-shot events (navigation events, toast messages, analytics events) that should not be replayed to new collectors:

class CheckoutViewModel : ViewModel() {
    // replay=0: new collectors don't receive past events (correct for navigation)
    // extraBufferCapacity=1: prevents tryEmit from dropping if no collector yet
    private val _events = MutableSharedFlow<CheckoutEvent>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST
    )
    val events: SharedFlow<CheckoutEvent> = _events.asSharedFlow()

    fun onPaymentSuccess(orderId: String) {
        // tryEmit is non-suspending; returns false if buffer full
        _events.tryEmit(CheckoutEvent.NavigateToConfirmation(orderId))
    }

    suspend fun onPaymentSuccessSuspending(orderId: String) {
        // emit suspends until a collector is ready to receive
        _events.emit(CheckoutEvent.NavigateToConfirmation(orderId))
    }
}

The critical distinction: emit is a suspending function that waits until the emission can be delivered; tryEmit is non-suspending and returns false if the buffer is full and no collector is ready. Using emit from a viewModelScope.launch block is fine; using emit from a non-coroutine callback (like a click listener or a third-party SDK callback) will crash because emit must be called from a coroutine context. In those cases, tryEmit with extraBufferCapacity=1 is the correct pattern.

The shareIn and stateIn operators convert cold Flows (from Room DAOs, Retrofit, or repository functions) into hot StateFlow/SharedFlow that are shared across multiple collectors without re-executing the upstream source for each collector:

class FeedViewModel(private val repository: FeedRepository) : ViewModel() {
    // stateIn: converts cold Flow to StateFlow, starts when first collector appears,
    // stops 5 seconds after last collector disappears (SharingStarted.WhileSubscribed(5000))
    val feedItems: StateFlow<List<FeedItem>> = repository.observeFeed()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000L),
            initialValue = emptyList()
        )
}

SharingStarted.WhileSubscribed(5_000L) keeps the upstream Flow active for 5 seconds after the last collector disappears. This handles the common case of a configuration change (screen rotation): the Activity is destroyed and recreated within a few hundred milliseconds, so the 5-second window keeps the network/database subscription active without restarting it for the new collector. Without this, every configuration change would restart the upstream data fetch.

viewModelScope vs. lifecycleScope: the repeatOnLifecycle requirement

This is the most consequential coroutine mistake in Android codebases: collecting a Flow in lifecycleScope without repeatOnLifecycle(Lifecycle.State.STARTED).

// WRONG: collects even when the app is in the background (screen off)
class FeedFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        lifecycleScope.launch {
            viewModel.feedItems.collect { items ->
                adapter.submitList(items)
            }
        }
    }
}

// CORRECT: pauses collection when lifecycle drops below STARTED,
// resumes when lifecycle returns to STARTED
class FeedFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.feedItems.collect { items ->
                    adapter.submitList(items)
                }
            }
        }
    }
}

Without repeatOnLifecycle, the collect block runs from the time onViewCreated fires until the fragment is destroyed (not just stopped or paused). The fragment is destroyed when the user navigates away permanently. But during normal app backgrounding, the fragment is stopped — its lifecycle drops to the CREATED state — and the collection continues running. On a StateFlow this means the adapter is receiving list updates even when no UI is displayed. On a SharedFlow connected to a real-time WebSocket, this means the socket stays subscribed. The repeatOnLifecycle(Lifecycle.State.STARTED) wrapper cancels the inner collect block when the lifecycle drops below STARTED and relaunches it when the lifecycle returns to STARTED.

Note also viewLifecycleOwner.lifecycleScope rather than plain lifecycleScope inside a fragment. The fragment has two lifecycles: its own (lifecycleScope) and its view’s (viewLifecycleOwner.lifecycleScope). Collecting from the fragment’s own lifecycle is incorrect because the fragment can be detached and reattached (the view destroyed and recreated) while the fragment instance stays alive — the collection would continue referencing the old, destroyed view.

collect vs. collectLatest: debounce semantics for search UIs

collectLatest cancels the previous collector block when a new emission arrives before the block has finished executing. This produces debounce-like behavior without a debounce operator:

class SearchViewModel : ViewModel() {
    val searchQuery = MutableStateFlow("")

    val searchResults = searchQuery
        .debounce(300L)          // wait 300ms of inactivity before emitting
        .filter { it.length >= 2 }
        .flatMapLatest { query -> // cancels previous search on new query
            repository.search(query)
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList())
}

// In the fragment:
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        // collect: waits for each emission's block to complete before taking the next
        // collectLatest: cancels the current block the instant a new emission arrives
        viewModel.searchResults.collectLatest { results ->
            // This block is cancelled and relaunched if searchResults emits again
            // before the adapter update completes — safe here because submitList is fast
            adapter.submitList(results)
        }
    }
}

The distinction matters in the flatMapLatest operator on the ViewModel side. flatMapLatest cancels the upstream Flow produced by the previous lambda when a new emission arrives from searchQuery. If the user types “andr” quickly, the network call for “and” is cancelled before the call for “andr” is launched, preventing out-of-order results from earlier slow queries overwriting later fast ones.

Coroutine cancellation: isActive, ensureActive, and NonCancellable

Kotlin coroutines implement cooperative cancellation: a coroutine is cancelled at the next suspension point. A CPU-heavy loop with no suspension points will not respond to cancellation until the loop finishes:

// PROBLEM: loop runs to completion even after coroutine is cancelled
suspend fun processLargeDataset(items: List<DataItem>) {
    for (item in items) {
        heavyComputation(item) // no suspension point; cancellation ignored
    }
}

// CORRECT: check isActive or call ensureActive() to cooperate with cancellation
suspend fun processLargeDataset(items: List<DataItem>) = withContext(Dispatchers.Default) {
    for (item in items) {
        ensureActive() // throws CancellationException if coroutine is cancelled
        heavyComputation(item)
    }
}

// For cleanup work that must complete even after cancellation:
suspend fun uploadWithCleanup(file: File) {
    try {
        upload(file)
    } finally {
        withContext(NonCancellable) {
            // This block runs even if the coroutine was cancelled
            // Use for cleanup: closing resources, logging, notifying server of abandonment
            cleanupTempFile(file)
        }
    }
}

ensureActive() is the idiomatic cooperative cancellation check. It throws CancellationException if the coroutine’s job is cancelled or completed, which propagates normally through the coroutine machinery. isActive is the manual check variant: if (!isActive) return. Use ensureActive() in loops; use isActive in conditional branches where the preferred response to cancellation is returning rather than throwing.

Dispatcher selection: IO vs. Default vs. Main

Dispatcher selection is not stylistic — it is load-balancing across the Android thread pool. The wrong dispatcher for a workload does not produce a crash; it produces jank or thread starvation.

class DataRepository(
    private val api: ApiService,
    private val db: AppDatabase
) {
    // Dispatchers.IO: thread pool sized for blocking I/O (64 threads by default)
    // Use for: network calls, file I/O, database queries, SharedPreferences
    suspend fun fetchRemoteData(): List<Item> = withContext(Dispatchers.IO) {
        api.getItems() // OkHttp/Retrofit blocking call
    }

    // Dispatchers.Default: thread pool sized to CPU core count
    // Use for: JSON parsing of large payloads, bitmap processing, sorting large lists
    suspend fun parseAndSortItems(json: String): List<Item> = withContext(Dispatchers.Default) {
        val items = Json.decodeFromString<List<Item>>(json)
        items.sortedBy { it.timestamp }
    }

    // Dispatchers.Main: Android main thread
    // Use for: View updates, RecyclerView adapter submissions, Toast display
    // Most Compose state updates happen automatically on Main via StateFlow
    suspend fun showError(message: String) = withContext(Dispatchers.Main) {
        Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
    }
}

The mistake to avoid: using Dispatchers.IO for CPU-intensive work. The IO dispatcher can expand to 64 threads simultaneously. Running CPU-heavy JSON parsing or image processing on 64 threads causes thread scheduler contention that is worse than running the same work on the 4–8 CPU-count threads of Dispatchers.Default. Conversely, using Dispatchers.Default for blocking network calls starves the CPU thread pool: a blocked Retrofit call sitting on a Dispatchers.Default thread prevents other CPU work from running until the network timeout fires.

Jetpack Compose stability system

Compose’s recomposition model is the defining performance characteristic of the Compose UI toolkit. Understanding it is not optional for a Kotlin/Compose engineer on retainer — it is the diagnostic framework for every jank investigation and every unexplained battery drain report.

Stable vs. unstable: how Compose infers stability

Compose’s compiler performs a stability inference pass on every class used as a composable parameter. The rules: a class is stable if all its fields are of stable types (primitives, String, and other stable classes) and the class has no mutable state visible outside the class. A class is unstable if it has a var field, holds a MutableList, MutableMap, MutableSet, or a standard Kotlin List/Map/Set (inferred unstable because the runtime type may be mutable), or has any field of an unstable type.

A composable whose parameters are all stable types is skippable: if the composable’s parameters have not changed since the last composition, Compose skips recomposing it entirely. A composable with any unstable parameter is always-recomposing: it recomposes every time its parent recomposes, regardless of whether its inputs changed. This is the battery drain source in the opening scenario.

// UNSTABLE: List<Tag> is inferred as unstable by the Compose compiler
data class FeedItem(
    val id: String,
    val title: String,
    val tags: List<Tag>   // unstable — makes FeedItem unstable
)

// STABLE: replace with kotlinx-collections-immutable ImmutableList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

data class FeedItem(
    val id: String,
    val title: String,
    val tags: ImmutableList<Tag>   // stable — ImmutableList is annotated @Immutable
)

// Alternative: wrap the class with @Immutable if you can guarantee
// no external mutations (data fetched from network, never mutated after construction)
@Immutable
data class FeedItem(
    val id: String,
    val title: String,
    val tags: List<Tag>   // still technically unstable at the type level,
                           // but @Immutable tells the Compose compiler to trust
                           // that this specific class will not mutate
)

@Stable and @Immutable: their contracts

@Immutable is a promise to the Compose compiler that the class and all its publicly readable properties will never change after construction. The compiler trusts this promise and treats the annotated class as stable for the purpose of skippability decisions. Breaking the contract — mutating a field on an @Immutable-annotated class — will not crash; it will produce silent stale UI because Compose does not detect the change.

@Stable is a weaker promise: the class can change, but if it does, Compose will be notified (through its snapshot system). A class annotated @Stable must notify Compose of any mutation to a readable property. This is correct for classes that hold MutableState internally and expose it through State reads:

@Stable
class SelectionState(initialIds: Set<String> = emptySet()) {
    // mutableStateSetOf notifies Compose snapshot on mutation —
    // satisfies the @Stable contract
    val selectedIds: SnapshotStateSet<String> = mutableStateSetOf(*initialIds.toTypedArray())

    fun toggle(id: String) {
        if (id in selectedIds) selectedIds.remove(id) else selectedIds.add(id)
    }
}

// In a composable — SelectionState is @Stable, so this composable is skippable
@Composable
fun SelectableItem(
    item: FeedItem,
    selectionState: SelectionState,
    onClick: () -> Unit
) {
    val isSelected = item.id in selectionState.selectedIds
    // ...
}

derivedStateOf: avoiding over-recomposition on computed state

derivedStateOf memoizes a computation based on Compose state reads. The computation re-runs only when the underlying state values it reads actually change, not on every recomposition of the composable that reads it:

@Composable
fun CartScreen(cartItems: ImmutableList<CartItem>) {
    val listState = rememberLazyListState()

    // WITHOUT derivedStateOf: showButton recomputes on EVERY scroll event
    // because listState.firstVisibleItemIndex changes constantly while scrolling
    val showScrollToTopButton = listState.firstVisibleItemIndex > 0

    // WITH derivedStateOf: the boolean result changes only when the threshold
    // is crossed (0→1 or 1→0), not on every pixel of scroll
    val showScrollToTopButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }

    // Another example: expensive filter computation
    val expensiveFilterResult by remember(cartItems) {
        derivedStateOf {
            cartItems.filter { it.price > 100.0 }
                     .sortedByDescending { it.price }
        }
    }
    // ...
}

The rule of thumb: use derivedStateOf when reading frequently-changing state (scroll position, animation values, high-frequency sensor data) to compute a result that changes rarely. Without it, Compose recomposes the entire composable on every state change; with it, Compose only recomposes when the derived boolean or computed value actually changes.

snapshotFlow: bridging Compose State to Kotlin Flow

snapshotFlow converts a Compose State read into a Kotlin Flow that emits whenever the state value changes. It is the bridge from the Compose snapshot system to the coroutine world, enabling you to collect Compose state changes in a coroutine:

@Composable
fun TrackScrollAnalytics(listState: LazyListState) {
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .filter { index -> index % 10 == 0 } // log every 10th item
            .collect { index ->
                analytics.logScrollDepth(index)
            }
    }
}

snapshotFlow runs the block inside a Compose snapshot, recording which state objects are read. When any of those state objects change, the Flow emits the new value. This is more precise than observing the entire composable for changes — it only reacts to the specific state reads inside the snapshotFlow block.

Compose compiler metrics: auditing skippability

The Compose compiler can emit detailed metrics about which composables are restartable, skippable, and what types it inferred as stable or unstable. This is the tooling that makes Compose stability work visible and auditable:

# In your app's build.gradle.kts, add the compiler flag:
android {
    kotlinOptions {
        freeCompilerArgs += listOf(
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.buildDir.absolutePath}/compose_metrics",
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.buildDir.absolutePath}/compose_metrics"
        )
    }
}

# Then run:
./gradlew assembleRelease -Pcompose.metrics.enabled=true

This produces files in build/compose_metrics/:

The workflow: run the metrics build, open composables.csv, filter for skippable=false composables that are in your hot recomposition path, trace back to their parameter types in classes.csv, find the Unstable class, and either annotate it with @Immutable/@Stable or replace List with ImmutableList. Re-run the metrics build to confirm the composable is now marked skippable=true.

Android Gradle build system

The Android Gradle build system is a standing retainer function: it requires active maintenance across Gradle version upgrades, Android Gradle Plugin releases, and the annual target SDK deadline. The build system knowledge gap between a developer who runs ./gradlew assembleRelease and an architect who designed the build variant structure, version catalog, and R8 configuration is where the mysterious ProGuard crash in the release build and the missing feature flags in the staging environment originate.

Gradle version catalogs: libs.versions.toml

Gradle version catalogs (libs.versions.toml) provide a centralized, type-safe dependency declaration for multi-module Android projects. The catalog lives at gradle/libs.versions.toml:

[versions]
kotlin = "2.0.0"
compose-bom = "2024.06.00"
hilt = "2.51.1"
retrofit = "2.11.0"
okhttp = "4.12.0"
room = "2.6.1"

[libraries]
# Compose BOM: import this, then omit versions for all compose-* libs
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }

# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }

# Retrofit + OkHttp
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }

[plugins]
android-application = { id = "com.android.application", version = "8.5.0" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version = "2.0.0-1.0.22" }

In module-level build.gradle.kts, the catalog is accessed via libs.*:

dependencies {
    val composeBom = platform(libs.androidx.compose.bom)
    implementation(composeBom)
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.material3)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    implementation(libs.retrofit.core)
    implementation(libs.retrofit.gson)
}

buildConfigField and productFlavors

buildConfigField injects constants into the generated BuildConfig class at compile time, allowing API base URLs, feature flags, and environment identifiers to vary per build type without runtime configuration files:

android {
    buildTypes {
        debug {
            buildConfigField("String", "API_BASE_URL", "\"https://api.staging.example.com/v1/\"")
            buildConfigField("Boolean", "FEATURE_NEW_CHECKOUT", "true")
            buildConfigField("Boolean", "LOGGING_ENABLED", "true")
        }
        release {
            buildConfigField("String", "API_BASE_URL", "\"https://api.example.com/v1/\"")
            buildConfigField("Boolean", "FEATURE_NEW_CHECKOUT", "false")
            buildConfigField("Boolean", "LOGGING_ENABLED", "false")
            minifyEnabled = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }

    flavorDimensions += "tier"
    productFlavors {
        create("free") {
            dimension = "tier"
            applicationIdSuffix = ".free"
            buildConfigField("Boolean", "IS_PREMIUM", "false")
        }
        create("premium") {
            dimension = "tier"
            buildConfigField("Boolean", "IS_PREMIUM", "true")
        }
    }
}

Access in Kotlin: BuildConfig.API_BASE_URL, BuildConfig.FEATURE_NEW_CHECKOUT. The build system generates the correct values at compile time for each variant combination (e.g., freeDebug, premiumRelease). This is the correct approach for environment configuration in Android — it is compiled-in, not readable from the APK as a plaintext config file, and is available from the first line of application code without any file I/O.

R8 and ProGuard: minification and keep rules

R8 is the Android build system’s default code shrinker, obfuscator, and optimizer. It runs in the release build pipeline when minifyEnabled = true. The most common production crash pattern after enabling minification: Gson (or Moshi without code generation) uses reflection to deserialize JSON into data classes, and R8 removes or renames the class fields it doesn’t see referenced from code paths it can trace. The fix is keep rules in proguard-rules.pro:

# proguard-rules.pro

# Keep Retrofit service interfaces (R8 can strip interface methods it doesn't see called directly)
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response

# Keep Gson model classes — either use @Keep annotation or a blanket keep rule:
# Option 1: annotate each model class with @Keep
# Option 2: blanket rule for a package
-keep class com.example.app.data.model.** { *; }

# @SerializedName fields must not be renamed (Gson uses the annotation value, not field name)
-keepclassmembers,allowobfuscation class * {
    @com.google.gson.annotations.SerializedName <fields>;
}

# Keep Parcelable implementations
-keep class * implements android.os.Parcelable {
    public static final android.os.Parcelable$Creator *;
}

# Keep Room database classes
-keep class * extends androidx.room.RoomDatabase { *; }
-keep @androidx.room.Entity class * { *; }
-keep @androidx.room.Dao interface * { *; }

# R8 full mode (enabled by default in AGP 8.x): stricter than ProGuard-compatible mode
# If migrating from ProGuard, add this to disable full mode if you encounter issues:
# -dontoptimize

The @Keep annotation (from androidx.annotation) on a class or field tells R8 not to remove or rename it, regardless of whether it is referenced in the traced code path. This is the correct annotation for Gson/Moshi model classes, Retrofit service return types, and any class instantiated by reflection. @SerializedName from Gson tells the JSON parser which JSON key maps to the field, but R8 does not know this — without the keep rule, R8 renames the field to a, b, c, and Gson’s @SerializedName annotation is preserved (R8 keeps annotations by default), but the field it references is renamed, so Gson cannot find it.

R8 full mode (the default in Android Gradle Plugin 8.x) is stricter than ProGuard-compatible mode: it performs more aggressive class merging, method inlining, and dead code elimination. Code that worked with ProGuard-compatible mode may break under R8 full mode, particularly reflection-heavy libraries. The proguard-android-optimize.txt file (the first argument to proguardFiles) includes the base rules from the Android SDK; your proguard-rules.pro adds project-specific rules on top.

WorkManager constraints and background work

WorkManager is the Android Jetpack library for deferrable, guaranteed background work. “Guaranteed” means WorkManager persists the work request to a Room database, so it survives process death and device reboots. “Deferrable” means WorkManager schedules the work when the system determines conditions are met — not necessarily immediately. This is the correct library for sync tasks, log uploads, periodic data refreshes, and any work that must complete eventually but does not need to run at a precise instant.

Constraints.Builder: network, battery, and charging

val uploadConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)   // any network (WiFi or cellular)
    .setRequiresBatteryNotLow(true)                  // don't run below battery warning threshold
    .setRequiresCharging(false)                      // run on battery too (true = only while charging)
    .setRequiresDeviceIdle(false)                    // true = only in Doze idle; conservative
    .build()

// For a large file upload that should only run on WiFi and while charging:
val largeUploadConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)   // WiFi or ethernet only
    .setRequiresCharging(true)
    .setRequiresBatteryNotLow(true)
    .build()

The constraint system integrates with Android’s JobScheduler and AlarmManager under the hood and adapts to Doze mode, App Standby buckets, and OEM battery optimization layers automatically. The practical consequence: a constraint of NetworkType.CONNECTED combined with requiresBatteryNotLow=true on a device in App Standby (active bucket but not recently used) may defer the work by hours on aggressive OEM builds. The Android architect tests WorkManager behavior across device tiers in CI using the WorkManager test APIs, not just the emulator.

PeriodicWorkRequest vs. OneTimeWorkRequest

// OneTimeWorkRequest: runs once, may be retried on failure
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(uploadConstraints)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES)
    .setInputData(workDataOf("user_id" to userId))
    .addTag("sync-$userId")
    .build()

// PeriodicWorkRequest: runs repeatedly at the specified interval (minimum 15 minutes)
// WorkManager may run it later than the interval to batch with other work
val periodicSyncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
    repeatInterval = 6,
    repeatIntervalTimeUnit = TimeUnit.HOURS,
    flexTimeInterval = 30,             // run within a 30-minute flex window at the end of interval
    flexTimeIntervalUnit = TimeUnit.MINUTES
)
    .setConstraints(uploadConstraints)
    .addTag("periodic-sync")
    .build()

// Enqueue as unique to prevent duplicates:
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "periodic-sync",
    ExistingPeriodicWorkPolicy.KEEP,   // keep existing if already enqueued
    periodicSyncRequest
)

ExistingPeriodicWorkPolicy.KEEP is the correct policy for most periodic background sync: if the device restarts and the app re-enqueues the periodic work in onCreate, this policy prevents creating a duplicate periodic request. ExistingPeriodicWorkPolicy.UPDATE (available since WorkManager 2.8) replaces the existing periodic work with new parameters, preserving the next scheduled run time when possible — use this when updating constraints or interval without resetting the schedule.

Work chaining and expedited work

// Chain: compress → encrypt → upload, sequentially
val compressWork = OneTimeWorkRequestBuilder<CompressWorker>().build()
val encryptWork = OneTimeWorkRequestBuilder<EncryptWorker>().build()
val uploadWork = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(uploadConstraints)
    .build()

WorkManager.getInstance(context)
    .beginWith(compressWork)
    .then(encryptWork)
    .then(uploadWork)
    .enqueue()

// Expedited work: runs with higher priority, closer to foreground service priority
// Use for time-sensitive work triggered by user action (e.g., send message, process payment)
val expeditedRequest = OneTimeWorkRequestBuilder<SendMessageWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .setInputData(workDataOf("message_id" to messageId))
    .build()

// Worker must override getForegroundInfo() for pre-API-31 compatibility:
class SendMessageWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        val messageId = inputData.getString("message_id") ?: return Result.failure()
        return try {
            messageRepository.send(messageId)
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) Result.retry() else Result.failure()
        }
    }

    override suspend fun getForegroundInfo(): ForegroundInfo {
        return ForegroundInfo(
            NOTIFICATION_ID,
            buildSendingNotification() // required for expedited work on API < 31
        )
    }
}

OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST tells WorkManager that if the app has exhausted its expedited work quota (Android 12+ limits expedited work to prevent abuse), the work should fall back to normal priority rather than being dropped. This is the safe fallback for message sending — the message will still send, just with normal priority delay.

Google Play Integrity API

The SafetyNet Attestation API was deprecated in June 2024 and shut down in January 2025. Any Android app that was using SafetyNet for device integrity verification or license checking must now use the Google Play Integrity API. For fintech, gaming, and enterprise apps with server-side device trust checks, this migration is a non-negotiable retainer item that carries Play Store policy consequences if delayed.

IntegrityManager.requestIntegrityToken: nonce design and token request

// build.gradle.kts dependency:
// implementation("com.google.android.play:integrity:1.3.0")

class PaymentViewModel(
    private val application: Application,
    private val paymentRepository: PaymentRepository
) : AndroidViewModel(application) {

    suspend fun initiatePayment(amount: Double, orderId: String): PaymentResult {
        // Step 1: Generate a nonce — must be:
        // - Base64-encoded, URL-safe, no padding (NO_WRAP | URL_SAFE)
        // - At least 16 bytes of entropy
        // - Tied to the specific action (include orderId to prevent replay attacks)
        val nonceData = "$orderId:${System.currentTimeMillis()}:${UUID.randomUUID()}"
        val nonce = Base64.encodeToString(
            nonceData.toByteArray(Charsets.UTF_8),
            Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
        )

        // Step 2: Request integrity token
        val integrityManager = IntegrityManagerFactory.create(application)
        val tokenRequest = IntegrityTokenRequest.builder()
            .setNonce(nonce)
            .build()

        return try {
            val tokenResponse = integrityManager
                .requestIntegrityToken(tokenRequest)
                .await() // kotlin coroutine extension for Tasks

            // Step 3: Send token to your backend for server-side validation
            paymentRepository.processPayment(
                amount = amount,
                orderId = orderId,
                integrityToken = tokenResponse.token()
            )
        } catch (e: IntegrityServiceException) {
            PaymentResult.IntegrityCheckFailed(e.errorCode)
        }
    }
}

Verdict fields and server-side validation

The integrity token is an encrypted JWS (JSON Web Signature) that your backend decrypts and validates using the Google Play Integrity API endpoint. The verdict contains three principal fields:

// Server-side validation (Kotlin/Ktor backend example):
// POST https://playintegrity.googleapis.com/v1/{packageName}:decodeIntegrityToken
// Body: { "integrity_token": "<token from client>" }

// Decoded verdict JSON structure:
{
  "requestDetails": {
    "requestPackageName": "com.example.app",
    "nonce": "<your nonce — verify it matches what you sent>",
    "timestampMillis": "1722470400000"
  },
  "appIntegrity": {
    "appRecognitionVerdict": "PLAY_RECOGNIZED",
    // PLAY_RECOGNIZED: app binary matches a version distributed via Play Store
    // UNRECOGNIZED_VERSION: app is signed with your key but not from Play Store
    // UNEVALUATED: integrity check could not be performed (too many requests, etc.)
    "packageName": "com.example.app",
    "certificateSha256Digest": ["<your signing cert digest>"]
  },
  "deviceIntegrity": {
    "deviceRecognitionVerdict": ["MEETS_DEVICE_INTEGRITY"]
    // MEETS_DEVICE_INTEGRITY: passes basic Android compatibility, not rooted
    // MEETS_STRONG_INTEGRITY: passes CTS, verified boot, hardware attestation
    // MEETS_VIRTUAL_INTEGRITY: runs on an emulator (legitimate use for testing)
  },
  "accountDetails": {
    "appLicensingVerdict": "LICENSED"
    // LICENSED: account has purchased/installed the app via Play Store
    // UNLICENSED: account has not acquired the app legitimately
    // UNEVALUATED: licensing could not be checked
  }
}

Server-side validation decisions for a fintech app: require appRecognitionVerdict == "PLAY_RECOGNIZED" for payment initiation; allow UNRECOGNIZED_VERSION with elevated fraud scoring (may be a legitimate beta build or enterprise sideload); block deviceRecognitionVerdict that does not include MEETS_DEVICE_INTEGRITY for high-value transactions. Always validate the nonce field matches the nonce your server issued for this specific action — this prevents replay attacks where an attacker captures a valid token from one request and replays it for a different one.

Testing: Espresso, Robolectric, and Turbine

Android testing strategy on a retainer covers three layers: Espresso for instrumented UI tests that run on a device or emulator, Robolectric for unit tests of Android components without a device, and Turbine for Flow assertions without boilerplate awaitItem loops.

Espresso UI tests

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class SearchScreenTest {

    @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Inject lateinit var fakeRepository: FakeSearchRepository

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun searchScreen_showsResults_whenQueryEntered() {
        fakeRepository.setResults(listOf(SearchResult("1", "Android Developer")))

        composeTestRule.onNodeWithTag("SearchTextField")
            .performTextInput("Android")

        composeTestRule.waitUntil(timeoutMillis = 3_000L) {
            composeTestRule
                .onAllNodesWithTag("SearchResultItem")
                .fetchSemanticsNodes().isNotEmpty()
        }

        composeTestRule.onNodeWithTag("SearchResultItem")
            .assertIsDisplayed()
            .assertTextContains("Android Developer")
    }
}

Robolectric for ViewModel and component unit tests

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], application = HiltTestApplication::class)
class WorkManagerTest {

    private lateinit var context: Context
    private lateinit var workManager: WorkManager

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        // Initialize WorkManager with test configuration (synchronous executor)
        val config = Configuration.Builder()
            .setMinimumLoggingLevel(Log.DEBUG)
            .setExecutor(SynchronousExecutor())
            .build()
        WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
        workManager = WorkManager.getInstance(context)
    }

    @Test
    fun periodicSyncWork_isEnqueuedWithCorrectConstraints() {
        val request = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS).build()
        workManager.enqueueUniquePeriodicWork(
            "test-sync",
            ExistingPeriodicWorkPolicy.KEEP,
            request
        )

        val workInfo = workManager.getWorkInfosForUniqueWork("test-sync").get()
        assertThat(workInfo).isNotEmpty()
        assertThat(workInfo[0].state).isEqualTo(WorkInfo.State.ENQUEUED)
    }
}

Turbine for Flow testing

Turbine (by Cash App) eliminates the manual coroutine test scaffolding required to assert on Flow emissions:

// build.gradle.kts: testImplementation("app.cash.turbine:turbine:1.1.0")

class SearchViewModelTest {
    private val testDispatcher = UnconfinedTestDispatcher()
    private val fakeRepository = FakeSearchRepository()
    private lateinit var viewModel: SearchViewModel

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        viewModel = SearchViewModel(fakeRepository)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `search emits Loading then Success states`() = runTest {
        fakeRepository.setResults(listOf(SearchResult("1", "Kotlin Engineer")))

        viewModel.uiState.test {
            // First emission: initial Idle state
            assertThat(awaitItem()).isEqualTo(SearchUiState.Idle)

            viewModel.search("Kotlin")

            // Second emission: Loading state
            assertThat(awaitItem()).isEqualTo(SearchUiState.Loading)

            // Third emission: Success state with results
            val successState = awaitItem()
            assertThat(successState).isInstanceOf(SearchUiState.Success::class.java)
            assertThat((successState as SearchUiState.Success).results)
                .hasSize(1)
            assertThat(successState.results[0].title)
                .isEqualTo("Kotlin Engineer")

            cancelAndIgnoreRemainingEvents()
        }
    }
}

The Turbine .test { } block collects from the Flow for the duration of the lambda, with awaitItem() suspending until the next emission arrives (or a timeout). This is far more readable than the equivalent manual approach (launch { collect { ... } }; advanceUntilIdle(); cancel()) and produces clear assertion failures when the expected emission doesn’t arrive within the timeout.

HourTab for Android developer retainers

Android developer retainer work produces stable recomposition counts, a WorkManager background sync that actually runs under Doze mode, an R8 release build that doesn’t crash on Gson deserialization, and a Play Integrity API implementation that passes Google’s annual policy review. The hours behind each outcome — the Compose compiler metrics audit and the six @Immutable annotations that eliminated 200+ recompositions per second on the feed screen, the repeatOnLifecycle refactor across twelve fragment screens that stopped background Flow collection, the ProGuard keep rule investigation after the release build started crashing on Gson null fields, the Play Integrity migration from SafetyNet before the deprecation deadline — are not visible to the product owner or CTO without a work log that connects each hour block to the specific Android platform function performed.

HourTab gives Android architects and Kotlin engineers a retainer dashboard their product leads can bookmark without creating an account: the month’s committed hours, the hours consumed, and the work log entries that connect each block to the Compose recomposition audit, the Flow lifecycle refactor, the WorkManager constraint tuning session, or the Play Store compliance task. When the product owner can see that 6 of the month’s 30 retainer hours went to Compose stability (feed screen: 200+ recompositions/second → 1, frame rate 34fps → 58fps) and 4 went to repeatOnLifecycle refactoring across twelve screens (verified in LeakCanary: background subscriptions eliminated), the retainer renewal conversation is grounded in the actual distribution of Android platform advisory work rather than a vague sense of whether the mobile consulting investment produced value.

The retainer model fits Android architecture consulting because the Android platform is not static. The Compose compiler improves its stability inference with each release; new Kotlin coroutine APIs arrive with each Kotlin version; WorkManager adds constraint types and policy options with each release; and Google’s annual Play Store policy cycle reliably introduces new target SDK requirements, permission changes, and API deprecations (SafetyNet in 2024, the annual targetSdkVersion increment, the 64-bit requirement, the predictive back gesture requirement). A monthly hour commitment provides the Android architect’s sustained availability across the full Android platform maintenance and evolution calendar. Showing clients the remaining hours on a live dashboard removes the overhead of status emails and lets the Android architect focus the retainer hours on platform work rather than reporting.

Frequently asked questions

What does an Android developer on retainer typically do?

An Android developer or Kotlin engineer on monthly retainer provides ongoing mobile platform advisory and development: Kotlin coroutine and Flow architecture (StateFlow for UI state, SharedFlow for one-shot events, viewModelScope and lifecycleScope with repeatOnLifecycle to prevent background collection leaks, collectLatest for search debounce, Dispatchers.IO vs. Dispatchers.Default dispatch discipline, stateIn/shareIn with SharingStarted.WhileSubscribed for configuration-change-resilient hot flows); Jetpack Compose stability and recomposition analysis (Compose compiler metrics audit via assembleRelease -Pcompose.metrics.enabled=true, identifying unstable types in composables.csv and classes.csv, applying @Stable and @Immutable annotations, replacing List with ImmutableList, using derivedStateOf for computed scroll state, snapshotFlow for bridging Compose state to coroutines); Android Gradle build system (libs.versions.toml version catalog maintenance, buildConfigField for environment-specific constants, productFlavors for free/premium variants, R8/ProGuard minification with keep rules for Retrofit/Gson model classes); WorkManager background work architecture (Constraints.Builder for network type and battery requirements, PeriodicWorkRequest vs. OneTimeWorkRequest, work chaining with beginWith().then().enqueue(), expedited work with OutOfQuotaPolicy); and Google Play Integrity API compliance (IntegrityManager.requestIntegrityToken with action-tied nonces, server-side verdict validation for appRecognitionVerdict, deviceRecognitionVerdict, and appLicensingVerdict, Play Store annual target SDK and policy compliance).

What Android development work is most commonly underlogged in a retainer?

The most systematically underlogged categories are Compose recomposition audits (running compiler metrics, identifying skippable=false composables in the hot path, tracing back to unstable types in classes.csv, applying @Immutable or replacing List with ImmutableList, and verifying in Layout Inspector — typically 6 to 16 hours of annotation work invisible in the improved frame rate); Flow lifecycle leak investigation (tracing every Flow collection site across twelve or more fragment screens, adding repeatOnLifecycle wrappers, verifying in LeakCanary that background subscriptions are eliminated — typically 4 to 10 hours of refactoring invisible in the stabilized memory profile); R8/ProGuard debugging after enabling minification (identifying stripped classes via runtime crashes or Gson null field deserialization, writing and validating keep rules, confirming the release APK with apkanalyzer — typically 4 to 12 hours invisible in the three lines added to proguard-rules.pro); and Play Store compliance work (SafetyNet to Play Integrity migration, target SDK upgrade, new permission review, predictive back gesture implementation — typically 8 to 20 hours invisible in the policy compliance status). Detailed work log entries that capture the compiler metrics findings and specific annotations added make this invisible Android platform investment visible.

What should an Android developer retainer agreement include?

Android developer retainer agreements should specify: scope boundary between feature development, architecture advisory, code review, and Play Store compliance (compliance and advisory produce no user-visible feature — define these as in-scope functions with their own hour allocation); repository access required (read access for code review, write access for pull request authorship, Google Play Console access for release management and compliance monitoring); device testing scope (whether the retainer includes testing WorkManager and battery behavior on real OEM devices via Firebase Test Lab or a physical device lab); IP ownership for Kotlin code contributions, Compose component libraries, and ProGuard rule sets; Play Store compliance calendar coverage (target SDK annual deadline, Play Integrity migration status, new permission requirements, predictive back gesture adoption); and a shared work log documenting each Compose recomposition audit, Flow lifecycle investigation, WorkManager constraint tuning session, and Play Store compliance task. Monthly retainer amounts for Android developer advisory and architecture consulting typically range from $6,000 to $14,000 per month for code review and architecture advisory retainers, increasing to $12,000 to $25,000 per month for full-stack Android architecture consulting at scale.

What are typical retainer rates for Android developers and Kotlin engineers?

Entry-level Android developers with 1 to 3 years of experience and standard Jetpack library familiarity typically bill $80 to $140 per hour, with monthly retainers running 10 to 18 hours for code review and advisory work. Mid-level Android engineers with 3 to 8 years of experience, expertise in Kotlin coroutines and Flow, Jetpack Compose, WorkManager, and Play Store release management, typically bill $130 to $230 per hour, with monthly retainers running 15 to 30 hours. Senior Android architects with 8 to 14 years of experience, expertise in Compose stability systems and compiler metrics, Kotlin coroutine internals, Play Integrity API implementation, and R8 build optimization, typically bill $185 to $350 per hour, with monthly retainers running 20 to 40 hours. Android consulting firms and specialized mobile architecture consultancies typically bill $160 to $280 per hour. Monthly retainer amounts for ongoing Android platform advisory and architecture support typically range from $6,000 to $14,000 per month for code review and architecture advisory retainers, increasing to $12,000 to $25,000 per month for full-stack Android architecture consulting.

How should Android developer retainer hours be logged?

Android retainer work log entries should capture the advisory category (coroutine/Flow architecture, Compose stability, Gradle/R8 build, WorkManager, Play Integrity/compliance, Espresso/Robolectric/Turbine testing), the specific screen or module, the task, and the finding or deliverable. Example: “Compose Stability Audit — FeedScreen. Task: investigate battery drain report (OEM battery stats showing unusual wakelock from app). Work: (1) Layout Inspector showed FeedItem composable recomposing 200+ times per second at idle; (2) Compose compiler metrics: FeedItem marked restartable=true, skippable=false; classes.csv: FeedItem inferred Unstable due to List<Tag> field; (3) Replaced List<Tag> with ImmutableList<Tag> (kotlinx-collections-immutable); re-ran metrics: FeedItem now skippable=true; (4) Layout Inspector recomposition count: 200+ → 1 per new feed data. Also found 4 additional composables in the same screen with unstable parameters — annotated with @Immutable. 2 hours. (5) Robolectric + Turbine test added for FeedViewModel StateFlow sequence. 1 hour. Total: 6 hours. Feed frame rate: 34fps → 58fps during scroll. Battery wakelock eliminated.” Entries that document the composables.csv skippability findings and the specific ImmutableList replacement connect the 6 hours of stability work to the measurable frame rate improvement.