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.
Table of contents
- Context: What I’m Building
- What I Learned / Architecture Decisions
- 1) Permissions need a state machine—not a “reactive if”
- 2) In Compose tests, the SavedStateHandle you mock is often not the one used
- 3) MockK errors often point to Kotlin bytecode realities
- Key Concepts Explained
- Compose recomposition vs LaunchedEffect
- Permissions UX as a state machine
- SavedStateHandle and nav args
- Code Snippets & Scenarios
- Scenario A: The problematic permission flow
- Scenario B: A permission state machine that matches UX
- Scenario C: Why Object cannot be cast to String happens
- Scenario D: Compose UI tests and SavedStateHandle mismatch
- What Worked, What Didn’t
- What worked
- What didn’t
- What I’d Do Differently
- 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 mockgetDatafor any handle.
3) MockK errors often point to Kotlin bytecode realities
Two patterns kept showing up:
- Property-as-usecase:
useCases.updateResult(payload)can compile intogetUpdateResult()+invoke(payload). - Varargs in extension functions: Kotlin
varargbecomes 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
LaunchedEffectkeys 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:
- You see the sign (my UI)
- You agree to show ID (tap OK)
- The bouncer checks (native dialog)
- You’re granted entry or turned away
Rule of thumb:
- Don’t call
launchMultiplePermissionRequest()in aLaunchedEffecton 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
denyCountin 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:
SavedStateHandle.get()is not suspend, socoEverycan fail to match.- If the stub doesn’t match, relaxed mocks return a generic
Object, and Kotlin tries to cast toString.
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
SavedStateHandlewith a real one made tests stable. - Understanding property-getter +
invokepatterns made MockK verification sane.
What didn’t
- Auto-requesting permissions inside
LaunchedEffectcaused UX overlap. - Relying on relaxed mocks for generic APIs (
get<String>) caused casting crashes. - Assuming Compose would use my mocked
SavedStateHandlein UI tests was wrong.
What I’d Do Differently
- Design permission flows as state machines from day one.
- Inject ViewModels into Composables (or allow passing them) to test UI deterministically.
- Prefer real objects for
SavedStateHandlerather than mocking. - Avoid
confirmVerifiedon 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:
SavedStateHandleand ViewModel saved state - MockK documentation:
everyvscoEvery, varargs (anyVararg), static mocking - Robolectric docs: main looper scheduling and Compose test integration
Share
More to explore
Keep exploring
1/11/2026
Kotlin to C/C++ Transition Guide: A Systems Programmer's Cheat Sheet
Preparing for a systems programming interview but haven't touched C/C++ since university? This guide bridges your Kotlin knowledge to C/C++ with side-by-side syntax comparisons, memory management deep dives, and critical undefined behaviors you need to know.
1/10/2026
The SDK Mindset: Why Your Code Isn't Your Own Anymore
A deep dive into the paradigm shift from application development to SDK design, and why building libraries requires a fundamentally different mental model
12/16/2025
Building a Frictionless Android Sample App: README Funnel, Doctor Script, and a CI Compatibility Matrix
My AI-assisted learning log on turning an Android SDK demo into a low-friction client experience: a decision-tree README, environment doctor scripts, and a GitHub Actions build matrix that generates a compatibility matrix.
Previous
Building a Frictionless Android Sample App: README Funnel, Doctor Script, and a CI Compatibility Matrix
Next