7 min read

Android Learning Log: Compose Permissions, SavedStateHandle, and Testing Pitfalls

A developer-friendly learning log on fixing a Jetpack Compose permission flow, understanding SavedStateHandle/nav args, and debugging MockK + Robolectric Compose tests—complete with real-world analogies and code snippets.

#Android#Dev-tools#Learning-log

Table of contents

  1. Context: What I’m Building
  2. What I Learned / Architecture Decisions
  3. 1) Permissions need a state machine—not a “reactive if”
  4. 2) In Compose tests, the SavedStateHandle you mock is often not the one used
  5. 3) MockK errors often point to Kotlin bytecode realities
  6. Key Concepts Explained
  7. Compose recomposition vs LaunchedEffect
  8. Permissions UX as a state machine
  9. SavedStateHandle and nav args
  10. Code Snippets & Scenarios
  11. Scenario A: The problematic permission flow
  12. Scenario B: A permission state machine that matches UX
  13. Scenario C: Why Object cannot be cast to String happens
  14. Scenario D: Compose UI tests and SavedStateHandle mismatch
  15. What Worked, What Didn’t
  16. What worked
  17. What didn’t
  18. What I’d Do Differently
  19. References / Further Reading

Context: What I’m Building

I’m building an Android SDK flow that includes an Address Capture stage and a camera-based capture flow (e.g., document capture/quality checks). This week, the work took a “real-world” turn: a small permissions UX issue and a test setup mismatch spiraled into a deeper lesson about Compose side-effects, permission state machines, and how ViewModels get constructed in Compose tests.

The immediate goal was simple:

  • When a user enters the camera screen, I want to show my own permission UI first.
  • When they tap OK, I want to show the native Android permission dialog.
  • If they deny permission (native dialog) or press No on my dialog, I want to call onPermissionDismissed() and navigate back.
  • If they deny twice, the third attempt should route the user to App Settings (but still via my own dialog first).

At the same time, I was refactoring country selection logic in Address Capture to respect allowedCountries from navigation data—and updating tests accordingly.

This post is my AI-assisted learning log of what I discovered.


What I Learned / Architecture Decisions

1) Permissions need a state machine—not a “reactive if”

My original approach tried to auto-request permissions in a LaunchedEffect when permissions weren’t granted. The UX bug was that it sometimes showed:

  • PermissionNotGrantedContent (my UI)
  • and the native permission dialog

…at the same time.

The key architectural decision I landed on:

Don’t auto-launch native permission dialogs based purely on state. Drive the request from explicit user intent (a tap) and model it as a state machine.

2) In Compose tests, the SavedStateHandle you mock is often not the one used

I initially mocked SavedStateHandle and thought my ViewModel would read nav data from it. But in Compose, viewModel() typically uses a SavedStateViewModelFactory with a SavedStateHandle created by the runtime (Activity/NavBackStackEntry), not my mocked instance.

So my tests failed with:

  • IllegalStateException: Required value was null (nav arg missing)
  • or ClassCastException: Object cannot be cast to String (relaxed mocks + generics)

The decision:

Prefer real SavedStateHandle(mapOf(...)) in unit tests. In Compose UI tests, either inject the ViewModel or mock getData for any handle.

3) MockK errors often point to Kotlin bytecode realities

Two patterns kept showing up:

  • Property-as-usecase: useCases.updateResult(payload) can compile into getUpdateResult() + invoke(payload).
  • Varargs in extension functions: Kotlin vararg becomes an array in bytecode. MockK needs *anyVararg().

Key Concepts Explained

Compose recomposition vs LaunchedEffect

Analogy: Imagine a theater stage. Recomposition is actors returning to stage as props change. LaunchedEffect(key) is a stagehand job that restarts whenever the label on a prop (key) changes.

If the key is something unstable (like a list reference), the stagehand keeps restarting. In my case, that meant re-triggering permission requests (and sometimes navigation).

Rule of thumb:

  • Keep LaunchedEffect keys stable.
  • Avoid triggering navigation or permission requests from “raw state” changes unless you’re very intentional.

Permissions UX as a state machine

Analogy: A club bouncer doesn’t keep asking for ID every time you glance at the door. There’s an explicit flow:

  1. You see the sign (my UI)
  2. You agree to show ID (tap OK)
  3. The bouncer checks (native dialog)
  4. You’re granted entry or turned away

Rule of thumb:

  • Don’t call launchMultiplePermissionRequest() in a LaunchedEffect on screen entry.
  • Call it only after an explicit action.

SavedStateHandle and nav args

Analogy: Navigation args are a shipping label. SavedStateHandle is the warehouse clipboard where the worker (ViewModel) reads the label. If the package never got labeled (no args), the worker has nothing to read.

Rule of thumb:

  • In unit tests: create SavedStateHandle(mapOf(KEY to value)).
  • In Compose UI tests: ensure the runtime’s handle has args, or intercept the accessor function.

Code Snippets & Scenarios

Scenario A: The problematic permission flow

This is the pattern that caused trouble:

val permissionsState = rememberRequiredPermissionsState()
var hasRequestedPermissions by rememberSaveable { mutableStateOf(false) }

LaunchedEffect(permissionsState.permissions) {
    if (!permissionsState.allPermissionsGranted && !hasRequestedPermissions) {
        hasRequestedPermissions = true
        permissionsState.launchMultiplePermissionRequest()
    }
}

when {
    permissionsState.allPermissionsGranted -> CameraPreview(...)
    hasRequestedPermissions -> PermissionNotGrantedContent(...)
}

Why it failed:

  • It requests permissions as soon as the screen composes.
  • UI simultaneously renders the “not granted” UI while the OS dialog appears.
  • If the state toggles (or recomposes) unexpectedly, it can re-trigger side effects.

Scenario B: A permission state machine that matches UX

A more deterministic model:

enum class PermissionStep {
    ShowAppDialog,
    RequestingNative,
    Granted
}

@Composable
fun CameraPermissionGate(
    permissionsState: MultiplePermissionsState,
    denyCount: Int,
    onPermissionDismissed: () -> Unit,
    openAppSettings: () -> Unit,
) {
    var step by rememberSaveable { mutableStateOf(PermissionStep.ShowAppDialog) }

    // When permissions become granted, transition to Granted.
    LaunchedEffect(permissionsState.allPermissionsGranted) {
        if (permissionsState.allPermissionsGranted) step = PermissionStep.Granted
    }

    val isPermanentlyDenied = permissionsState.permissions.any { p ->
        !p.status.shouldShowRationale && !p.status.isGranted
    }

    when (step) {
        PermissionStep.Granted -> {
            // render camera
            CameraPreview(...)
        }

        PermissionStep.ShowAppDialog,
        PermissionStep.RequestingNative -> {
            PermissionNotGrantedContent(
                multiplePermissionsState = permissionsState,
                onNegativeButtonClick = onPermissionDismissed,
                onPositiveButtonClick = {
                    // Third attempt -> Settings
                    if (denyCount >= 2 || isPermanentlyDenied) {
                        openAppSettings()
                    } else {
                        step = PermissionStep.RequestingNative
                        permissionsState.launchMultiplePermissionRequest()
                    }
                }
            )
        }
    }

    // Key: handle native denial explicitly by observing permission results.
    LaunchedEffect(step, permissionsState.allPermissionsGranted) {
        if (step == PermissionStep.RequestingNative && !permissionsState.allPermissionsGranted) {
            // If native dialog returns and still not granted, treat as denial.
            // Navigate back only after the user actually responded.
            onPermissionDismissed()
        }
    }
}

This matches the desired UX:

  • Enter screen → show app dialog
  • Tap OK → show native dialog
  • Deny → call onPermissionDismissed() and go back
  • Deny twice → third attempt routes to Settings

Note: In production, I’d store denyCount in a ViewModel (or persisted storage) to survive process recreation.

Scenario C: Why Object cannot be cast to String happens

In tests, I used:

savedStateHandle = mockk(relaxed = true)
coEvery { savedStateHandle.get<String>(SDK_NAV_ARG_DEFAULT_NAME) } returns json

Two issues:

  1. SavedStateHandle.get() is not suspend, so coEvery can fail to match.
  2. If the stub doesn’t match, relaxed mocks return a generic Object, and Kotlin tries to cast to String.

Better:

val handle = SavedStateHandle(mapOf(SDK_NAV_ARG_DEFAULT_NAME to json))
val vm = AddressCaptureManualEntryViewModel(handle)

Scenario D: Compose UI tests and SavedStateHandle mismatch

In UI tests, AddressCaptureView() calls viewModel()—so my mocked handle was never used. A pragmatic fix is to mock the extension function for any handle:

mockkStatic("com.complycube.sdk.navigation.SdkNavDataKt")

every {
  any<SavedStateHandle>().getData<AddressCaptureNavigationData>(*anyVararg())
} returns navData

What Worked, What Didn’t

What worked

  • Modeling permissions as explicit transitions reduced flaky behavior.
  • Replacing mocked SavedStateHandle with a real one made tests stable.
  • Understanding property-getter + invoke patterns made MockK verification sane.

What didn’t

  • Auto-requesting permissions inside LaunchedEffect caused UX overlap.
  • Relying on relaxed mocks for generic APIs (get<String>) caused casting crashes.
  • Assuming Compose would use my mocked SavedStateHandle in UI tests was wrong.

What I’d Do Differently

  1. Design permission flows as state machines from day one.
  2. Inject ViewModels into Composables (or allow passing them) to test UI deterministically.
  3. Prefer real objects for SavedStateHandle rather than mocking.
  4. Avoid confirmVerified on Kotlin singleton objects (hashCode calls and internal machinery make it brittle).

References / Further Reading

  • Android Docs: Jetpack Compose side-effects (LaunchedEffect, remember)
  • Android Docs: Runtime permissions + best practices
  • AndroidX: SavedStateHandle and ViewModel saved state
  • MockK documentation: every vs coEvery, varargs (anyVararg), static mocking
  • Robolectric docs: main looper scheduling and Compose test integration

Share

More to explore

Keep exploring

Previous

Building a Frictionless Android Sample App: README Funnel, Doctor Script, and a CI Compatibility Matrix

Next

German eID (Personalausweis) + Criipto on Android: What I Broke, Fixed, and Finally Understood