
PermPilot is a Kotlin Multiplatform (Android + iOS) permissions library for Compose Multiplatform apps. It collapses both platforms’ permission systems – runtime dialogs, Settings-gated special access, staged two-step requests, partial grants, MDM restrictions, and every SDK-version quirk in between – into one ergonomic, exhaustively-typed API:
PermPilot is a Kotlin Multiplatform (Android + iOS) permissions library for Compose Multiplatform apps. It collapses both platforms' permission systems -- runtime dialogs, Settings-gated special access, staged two-step requests, partial grants, MDM restrictions, and every SDK-version quirk in between -- into one ergonomic, exhaustively-typed API:
PermissionGate(permission = Permission.Camera) { state ->
when (state) {
PermissionState.Granted -> CameraPreview()
else -> Text("Camera access needed to continue")
}
}You call request(); PermPilot decides whether that means one system dialog, two sequenced ones, a Settings redirect, or nothing at all -- and always answers with a PermissionState you can when over exhaustively.
Permission handling is where cross-platform apps quietly accumulate platform-specific bugs:
shouldShowRequestPermissionRationale() returns false both before the first request ever and after a permanent denial -- conflating them misreports first-time users as permanently denied (a bug Accompanist famously shipped). PermPilot cross-checks a persisted has-requested flag to tell them apart.requestAlwaysAuthorization() is likewise a silent no-op unless when-in-use is already granted. PermPilot sequences both internally.Limited(reason).AndroidManifest.xml doesn't fail loudly -- Android silently auto-denies it, which looks exactly like the user said no. PermPilot detects it and reports ConfigurationError instead.Info.plist usage-description key terminates the process. PermPilot checks first and reports ConfigurationError instead of ever making that call.None of this leaks into your app code. There are no exceptions to catch, no bind(lifecycle) ceremony, and no SDK-version checks on the consumer side.
request() returns a sealed PermissionState covering every outcome either OS can produce: NotDetermined, Granted, Denied(canRequestAgain), PermanentlyDenied, Restricted, Limited(reason), ConfigurationError(reason).Runtime permissions (camera, microphone, location tiers, photos/media, Bluetooth, Health (Health Connect/HealthKit), telephony/SMS/call log, sensors, activity recognition, ATT, speech, reminders, ...) and 9 Android Special permissions (overlay, exact alarms, full-screen intent, battery optimizations, write settings, all-files access, DND access, usage access, notification-listener access). See the permission matrix.request() call, correct two-step choreography inside.Limited(ApproximateLocationOnly), Limited(PartialMediaAccess), or Limited(SelectedContactsOnly).PermissionGate & PermissionsGate -- Compose wrappers that always render your content first, then layer the right prompt on top of live state. PermissionsGate handles multiple permissions together with a single rationale/settings flow.PermissionDashboard -- A ready-to-use library component to build a "Privacy Settings" screen listing every permission, its state, and settings links.controller.events provides a structured stream of PermissionEvents (StateChanged, RequestStarted, RequestResult) to wire into your analytics funnel without scattering logging.state(permission) is a StateFlow<PermissionState>; grants made in the OS Settings app are picked up automatically on ON_RESUME and republished to every observer.Activity, missing Info.plist key, an undeclared <uses-permission> for a runtime permission, or a missing Special-permission prerequisite (SYSTEM_ALERT_WINDOW, SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, WRITE_SETTINGS, MANAGE_EXTERNAL_STORAGE, PACKAGE_USAGE_STATS, ACCESS_NOTIFICATION_POLICY, or a declared NotificationListenerService) all report as ConfigurationError through the same exhaustive when -- never a crash, hang, or fake denial. Legitimate partial declarations (coarse-only location, photos without the API-34 partial-selection tier) are recognized and not flagged.StateFlow from the OS callback itself, so even if the coroutine that launched the request is cancelled mid-dialog (e.g. its composable left composition), the UI still receives the real result.FakePermissionController -- an in-memory controller for your own tests: script Denied → Granted or any other flow with no Activity, no simulator, no OS dialog.permpilot-history decorates any controller and records every request/resolution/settings-open event into a queryable SQLDelight store.All artifacts are on Maven Central under io.github.alirezajavan:
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
// Compose Multiplatform UI layer (PermissionGate + default dialogs).
// Brings permpilot-core in transitively via api().
implementation("io.github.alirezajavan:permpilot-compose:1.1.0")
// Or, without Compose (e.g. shared ViewModels only):
// implementation("io.github.alirezajavan:permpilot-core:1.1.0")
// Optional: SQLDelight-backed audit log (independently versioned)
// implementation("io.github.alirezajavan:permpilot-history:1.1.0")
}
}
}Then declare what your app actually uses:
<uses-permission> entries in AndroidManifest.xml (see the permission matrix for the exact, API-level-gated set per catalog entry).NS*UsageDescription keys in Info.plist (see the Info.plist checklist).PermPilot verifies both at runtime and reports ConfigurationError for anything missing, so integration mistakes surface on the first request instead of in production as phantom denials.
Declare your permissions in the platform-specific manifests. PermPilot reports ConfigurationError at runtime if these are missing.
<uses-permission> to AndroidManifest.xml.NS*UsageDescription keys to Info.plist.See the Permission Matrix for the exact keys needed for each permission.
The easiest way to request a permission is using the PermissionGate in your Compose UI.
@Composable
fun CameraScreen() {
// 1. Initialize the controller
val controller = rememberPermissionController()
// 2. Wrap your content in a Gate
PermissionGate(
permission = Permission.Camera,
controller = controller
) { state ->
// 3. Handle the state (Gate handles Rationale/Settings dialogs automatically)
when (state) {
PermissionState.Granted -> CameraPreview()
is PermissionState.Limited -> CameraPreview(isLimited = true)
else -> Box(Modifier.fillMaxSize()) { Text("Camera access required") }
}
}
}For non-UI logic or custom flows, use the controller directly:
val scope = rememberCoroutineScope()
val controller = rememberPermissionController()
Button(onClick = {
scope.launch {
val result = controller.request(Permission.Camera)
if (result == PermissionState.Granted) {
// Start camera
}
}
}) {
Text("Scan Document")
}@Composable
fun CameraScreen() {
PermissionGate(permission = Permission.Camera) { state ->
when (state) {
PermissionState.Granted -> CameraPreview()
else -> Text("Camera access needed to continue")
}
}
}
// Or for multiple permissions together:
@Composable
fun VideoCallScreen() {
PermissionsGate(permissions = listOf(Permission.Camera, Permission.Microphone)) { states ->
if (states.values.all { it == PermissionState.Granted }) {
VideoCallUI()
} else {
Text("Camera and Microphone access needed")
}
}
}PermissionGate always calls your content(state) first -- your UI is the source of truth -- then decides which prompt, if any, to layer on top:
| Live state | Gate shows |
|---|---|
NotDetermined |
rationale dialog (priming before the one-shot native prompt) |
Denied(canRequestAgain = true) |
rationale dialog again |
Denied(canRequestAgain = false) / PermanentlyDenied
|
"open Settings" dialog |
Granted / Limited
|
nothing -- these are working grants |
Restricted |
terminal notice (MDM/parental controls; Settings can't fix it) |
ConfigurationError |
developer-facing error notice |
A dismissed prompt stays dismissed for that exact state only -- the moment the underlying state changes (a request resolves, the user returns from Settings), the gate re-evaluates from scratch. It never goes silent forever, and it never nags on an unchanged state.
Two levels, use whichever fits:
Text-level -- keep the default Material3 dialogs, override any copy. Every default dialog (PermissionRationaleDialog, PermissionSettingsDialog, PermissionRestrictedNotice, PermissionConfigurationErrorNotice) takes title / text / button-label parameters:
PermissionGate(
permission = Permission.Camera,
rationale = { onRequest, onDismiss ->
PermissionRationaleDialog(
permission = Permission.Camera,
onConfirm = onRequest,
onDismiss = onDismiss,
title = "Camera needed",
text = "Scanning a document requires the camera.",
confirmLabel = "Continue",
dismissLabel = "Not now",
)
},
) { state -> /* ... */ }Slot-level -- replace any prompt wholesale. Each of the gate's four prompts (rationale, settingsPrompt, restrictedContent, configurationErrorContent) is a plain composable lambda, so a bottom sheet, inline banner, or your own design-system component drops in without touching the gate's state machine:
PermissionGate(
permission = Permission.LocationWhileInUse,
rationale = { onRequest, onDismiss ->
MyBottomSheet(onConfirm = onRequest, onDismiss = onDismiss)
},
settingsPrompt = { onOpenSettings, onDismiss ->
MyBanner(onClick = onOpenSettings, onClose = onDismiss)
},
) { state ->
when (state) {
PermissionState.Granted -> ShowMap()
is PermissionState.Limited -> ShowMap(approximate = true)
else -> Text("Location needed")
}
}Upgrading a Limited grant: Limited (partial photos, approximate location) is a working grant, so the gate deliberately shows no prompt for it. Offer your own upgrade affordance that calls controller.request(permission) directly -- the OS shows its own picker/upgrade dialog, no rationale needed:
if (state is PermissionState.Limited) {
Button(onClick = { scope.launch { controller.request(Permission.PhotoLibrary) } }) {
Text("Allow access to all photos")
}
}Monitor permission funnels without scattering logs in your UI:
val controller = rememberPermissionController()
LaunchedEffect(controller) {
controller.events.collect { event ->
when (event) {
is PermissionEvent.RequestResult -> {
analytics.logEvent("permission_result", mapOf(
"permission" to event.permission.toString(),
"result" to event.result.toString()
))
}
is PermissionEvent.StateChanged -> {
println("${event.permission} changed to ${event.newState}")
}
is PermissionEvent.RequestStarted -> {
analytics.logEvent("permission_dialog_shown", mapOf(
"permission" to event.permission.toString()
))
}
}
}
}Create a "Privacy Settings" or "Permissions" screen in one line:
PermissionDashboard(
controller = controller,
permissions = listOf(Permission.Camera, Permission.Microphone, Permission.LocationWhileInUse),
modifier = Modifier.fillMaxSize()
)PermissionController lives in permpilot-core, which has zero Compose dependency:
val controller: PermissionController = rememberPermissionController() // hand it to your ViewModel
scope.launch {
when (val state = controller.request(Permission.Notifications)) {
PermissionState.Granted -> scheduleReminder()
is PermissionState.Denied -> showRationale()
PermissionState.PermanentlyDenied -> controller.openAppSettings()
else -> Unit
}
}request() / requestAll() never throw -- every platform failure mode (missing Activity, missing manifest declaration, missing Info.plist key, staged-request sequencing) is resolved internally and reported as data. requestAll() batches what the OS allows into one dialog and automatically pulls staged permissions out into their own sequenced flows.
Android's Permission.Special entries (SystemAlertWindow, ExactAlarm, IgnoreBatteryOptimizations, WriteSettings, ManageExternalStorage, DoNotDisturbAccess, UsageAccess, NotificationListenerAccess) have no request dialog -- only a live state() query and a per-permission deep-linked Settings redirect:
val state by controller.state(Permission.IgnoreBatteryOptimizations).collectAsState()
Button(onClick = { controller.openAppSettings(Permission.IgnoreBatteryOptimizations) }) {
Text("Open Settings")
}Each redirect carries the correct package: data URI where the platform supports one, so the user lands on your app's toggle, not a generic list. Two entries have manifest prerequisites without which the app never appears in the target Settings list -- PermPilot reports ConfigurationError(MissingManifestDeclaration) instead of letting the redirect dead-end:
DoNotDisturbAccess requires <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />.NotificationListenerAccess requires a declared NotificationListenerService (bound with BIND_NOTIFICATION_LISTENER_SERVICE).Changes made in Settings are picked up automatically: rememberPermissionController() re-checks every observed permission on every ON_RESUME.
Wrap any PermissionController to record every request / resolution / settings-open event into a queryable SQLDelight-backed log -- useful for support debugging or a privacy-dashboard screen:
val driver = PermissionHistoryDriverFactory(context).createDriver() // Android needs a Context; iOS needs none
val store = SqlDelightPermissionHistoryStore(driver)
val controller = HistoryPermissionController(rememberPermissionController(), store, scope)
// Drop-in replacement for the wrapped controller. Meanwhile:
store.events() // Flow<List<PermissionHistoryEntry>> -- everything
store.events(Permission.Camera) // ...or filtered per permissionRecording is cancellation-safe: once a request resolves, its Resolved event is written even if the caller was cancelled at that exact moment -- the log never ends up with a dangling Requested. permpilot-history is versioned independently and ships on its own release cadence.
val controller = FakePermissionController()
controller.setState(Permission.Camera, PermissionState.Denied(canRequestAgain = true))
// Drive your screen/ViewModel against `controller` exactly like a real one --
// no Activity, no iOS runtime, no OS permission dialog required.Customize how the "has requested" flags are stored on Android (e.g. for encrypted storage):
val persistence = SharedPreferencesPermissionPersistence(context, prefsName = "my_custom_prefs")
val controller = PermissionController.create(context, persistence = persistence)sealed interface Permission {
sealed interface Runtime : Permission // has a real request on ≥1 platform
sealed interface Special : Permission // Android Settings redirect, no dialog
sealed interface PlatformLimited : Permission // no request API on either platform
data object Camera : Runtime
data object LocationWhileInUse : Runtime
data object LocationAlways : Runtime // staged: foreground first, then background
data object PhotoLibrary : Runtime // supports Limited(PartialMediaAccess)
/* ...27 Runtime + 8 Special + LocalNetwork -- full list in docs/permission-matrix.md */
}
sealed interface PermissionState {
data object NotDetermined : PermissionState
data object Granted : PermissionState
data class Denied(val canRequestAgain: Boolean) : PermissionState
data object PermanentlyDenied : PermissionState
data object Restricted : PermissionState
data class Limited(val reason: LimitedReason) : PermissionState
data class ConfigurationError(val reason: ConfigurationErrorReason) : PermissionState
}
interface PermissionController {
val events: SharedFlow<PermissionEvent>
fun state(permission: Permission): StateFlow<PermissionState>
suspend fun request(permission: Permission.Runtime): PermissionState
suspend fun requestAll(vararg permissions: Permission.Runtime): Map<Permission, PermissionState>
fun openAppSettings()
fun openAppSettings(special: Permission.Special)
fun refreshAll()
}permpilot-core Permission catalog + PermissionState model + expect/actual PermissionController.
Pure Kotlin in commonMain; zero Compose dependency. Usable from ViewModels.
▲
│ api()
permpilot-compose Compose Multiplatform layer: PermissionGate, default Material3 dialogs,
rememberPermissionController(), lifecycle re-check on resume.
│
permpilot-history Optional decorator: HistoryPermissionController + SQLDelight event store.
Depends only on permpilot-core; independently versioned.
sample/ QA harness exercising all 35 catalog entries against a real device
(OS permission dialogs can't be driven by instrumented tests).
PermissionController is the only expect/actual surface. Everything else in commonMain -- the catalog, the state model, the gate's decision logic -- is platform-agnostic pure Kotlin.actual, coroutine bridging, staged sequencing, and persistence live in one file (*PermissionController.kt), while pure Permission ↔ platform type translation lives in another (AndroidPermissionMapping.kt / IosPermissionStatusMapping.kt). Adding a permission touches the mapping, not the orchestration -- and the mapping functions are directly unit-testable with no device.PermissionState, so a single exhaustive when in consumer code covers everything and the compiler flags anything new.PermissionGate delegates which prompt to show to a pure resolver (state × dismissed-for → prompt), unit-tested across the full matrix without a Compose runtime. The gate itself only wires slots.StateFlow from inside the OS callback, and requests are serialized through a Mutex -- a cancelled caller or two overlapping requests can't strand the observable state or hang a coroutine.Denied vs PermanentlyDenied is resolved by cross-checking shouldShowRequestPermissionRationale against a persisted has-requested flag; grouped requests check for legitimate partial-grant tiers before falling through to the all-or-nothing resolver; manifest declarations are verified before the OS gets a chance to silently auto-deny.PermissionGate ──▶ controller.request(permission)
│ Mutex: one system dialog at a time
│ 1. already granted? ──▶ Granted (no launcher round-trip)
│ 2. declared in manifest? ──▶ ConfigurationError if not
│ 3. host Activity attached? ──▶ ConfigurationError if not
│ 4. staged permission? ──▶ foreground request first, then a
│ separate background request
▼
MultiRequest ──▶ SharedFlow ──▶ ActivityResultContracts.RequestMultiplePermissions
│
state published from the launcher callback (survives caller cancellation),
resolved through pure functions: partial-grant tiers ▶ rationale flag ▶
persisted has-requested flag ▶ Denied / PermanentlyDenied / Limited / Granted
On iOS the same shape maps to per-framework authorization calls (AVCaptureDevice, CLLocationManager, PHPhotoLibrary, ATTrackingManager, ...), with every delegate/completion callback marshalled onto the main queue and Info.plist keys verified before any native call.
| Concern | Technology |
|---|---|
| Language / targets | Kotlin Multiplatform 2.4 -- android, iosArm64, iosSimulatorArm64
|
| UI layer | Compose Multiplatform 1.11 (Material3) |
| Concurrency & reactivity | kotlinx-coroutines -- suspend requests, StateFlow observation |
| Lifecycle | JetBrains AndroidX Lifecycle (KMP) -- shared ON_RESUME re-check on both platforms |
| Persistence (core) |
multiplatform-settings -- the persisted has-requested flag behind Denied-vs-PermanentlyDenied |
| Persistence (history) | SQLDelight 2 -- typed, queryable event log with platform drivers |
| Android build | AGP com.android.kotlin.multiplatform.library, convention plugins in build-logic/
|
| API stability | kotlinx binary-compatibility-validator -- klib ABI baselines checked on every build |
| Publishing | vanniktech maven-publish -- Maven Central, Dokka javadoc, GPG-signed |
Three test source sets in permpilot-core, each targeting what's actually testable without a device:
commonTest -- pure model logic and FakePermissionController, kotlin.test.androidHostTest -- JVM-only, no emulator: pure resolve-state functions tested directly, plus the real AndroidPermissionController driven through Robolectric shadows, not mocks -- including regression tests for caller-cancellation mid-dialog, check-before-request, partial grants, and manifest-declaration detection.iosTest -- pure iOS status-mapping functions; compiles for both iOS targets on any host (execution needs a Mac/simulator).permpilot-compose unit-tests the gate's full prompt matrix as a pure function; the sample app carries an end-to-end Robolectric test proving Settings-made grants are picked up on resume through real composition and a real Activity lifecycle.
Info.plist key(s), and platform notes.Health/HealthDataType/HealthAccess model and the Health Connect / HealthKit platform mapping, including the Android manifest entries Health Connect needs beyond <uses-permission> to list your app at all.Enable the pre-commit hook once per clone so local mistakes get caught before CI does:
git config core.hooksPath .githooks
Run the full gate the same way CI does before opening a PR:
./gradlew build
This runs all tests on every module, compiles both iOS targets, runs Android Lint, and verifies the public API against the committed binary-compatibility baselines (<module>/api/*.klib.api -- regenerate with ./gradlew apiDump when you intentionally change the API surface).
Copyright 2026 Alireza Javan
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
PermPilot is a Kotlin Multiplatform (Android + iOS) permissions library for Compose Multiplatform apps. It collapses both platforms' permission systems -- runtime dialogs, Settings-gated special access, staged two-step requests, partial grants, MDM restrictions, and every SDK-version quirk in between -- into one ergonomic, exhaustively-typed API:
PermissionGate(permission = Permission.Camera) { state ->
when (state) {
PermissionState.Granted -> CameraPreview()
else -> Text("Camera access needed to continue")
}
}You call request(); PermPilot decides whether that means one system dialog, two sequenced ones, a Settings redirect, or nothing at all -- and always answers with a PermissionState you can when over exhaustively.
Permission handling is where cross-platform apps quietly accumulate platform-specific bugs:
shouldShowRequestPermissionRationale() returns false both before the first request ever and after a permanent denial -- conflating them misreports first-time users as permanently denied (a bug Accompanist famously shipped). PermPilot cross-checks a persisted has-requested flag to tell them apart.requestAlwaysAuthorization() is likewise a silent no-op unless when-in-use is already granted. PermPilot sequences both internally.Limited(reason).AndroidManifest.xml doesn't fail loudly -- Android silently auto-denies it, which looks exactly like the user said no. PermPilot detects it and reports ConfigurationError instead.Info.plist usage-description key terminates the process. PermPilot checks first and reports ConfigurationError instead of ever making that call.None of this leaks into your app code. There are no exceptions to catch, no bind(lifecycle) ceremony, and no SDK-version checks on the consumer side.
request() returns a sealed PermissionState covering every outcome either OS can produce: NotDetermined, Granted, Denied(canRequestAgain), PermanentlyDenied, Restricted, Limited(reason), ConfigurationError(reason).Runtime permissions (camera, microphone, location tiers, photos/media, Bluetooth, Health (Health Connect/HealthKit), telephony/SMS/call log, sensors, activity recognition, ATT, speech, reminders, ...) and 9 Android Special permissions (overlay, exact alarms, full-screen intent, battery optimizations, write settings, all-files access, DND access, usage access, notification-listener access). See the permission matrix.request() call, correct two-step choreography inside.Limited(ApproximateLocationOnly), Limited(PartialMediaAccess), or Limited(SelectedContactsOnly).PermissionGate & PermissionsGate -- Compose wrappers that always render your content first, then layer the right prompt on top of live state. PermissionsGate handles multiple permissions together with a single rationale/settings flow.PermissionDashboard -- A ready-to-use library component to build a "Privacy Settings" screen listing every permission, its state, and settings links.controller.events provides a structured stream of PermissionEvents (StateChanged, RequestStarted, RequestResult) to wire into your analytics funnel without scattering logging.state(permission) is a StateFlow<PermissionState>; grants made in the OS Settings app are picked up automatically on ON_RESUME and republished to every observer.Activity, missing Info.plist key, an undeclared <uses-permission> for a runtime permission, or a missing Special-permission prerequisite (SYSTEM_ALERT_WINDOW, SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, WRITE_SETTINGS, MANAGE_EXTERNAL_STORAGE, PACKAGE_USAGE_STATS, ACCESS_NOTIFICATION_POLICY, or a declared NotificationListenerService) all report as ConfigurationError through the same exhaustive when -- never a crash, hang, or fake denial. Legitimate partial declarations (coarse-only location, photos without the API-34 partial-selection tier) are recognized and not flagged.StateFlow from the OS callback itself, so even if the coroutine that launched the request is cancelled mid-dialog (e.g. its composable left composition), the UI still receives the real result.FakePermissionController -- an in-memory controller for your own tests: script Denied → Granted or any other flow with no Activity, no simulator, no OS dialog.permpilot-history decorates any controller and records every request/resolution/settings-open event into a queryable SQLDelight store.All artifacts are on Maven Central under io.github.alirezajavan:
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
// Compose Multiplatform UI layer (PermissionGate + default dialogs).
// Brings permpilot-core in transitively via api().
implementation("io.github.alirezajavan:permpilot-compose:1.1.0")
// Or, without Compose (e.g. shared ViewModels only):
// implementation("io.github.alirezajavan:permpilot-core:1.1.0")
// Optional: SQLDelight-backed audit log (independently versioned)
// implementation("io.github.alirezajavan:permpilot-history:1.1.0")
}
}
}Then declare what your app actually uses:
<uses-permission> entries in AndroidManifest.xml (see the permission matrix for the exact, API-level-gated set per catalog entry).NS*UsageDescription keys in Info.plist (see the Info.plist checklist).PermPilot verifies both at runtime and reports ConfigurationError for anything missing, so integration mistakes surface on the first request instead of in production as phantom denials.
Declare your permissions in the platform-specific manifests. PermPilot reports ConfigurationError at runtime if these are missing.
<uses-permission> to AndroidManifest.xml.NS*UsageDescription keys to Info.plist.See the Permission Matrix for the exact keys needed for each permission.
The easiest way to request a permission is using the PermissionGate in your Compose UI.
@Composable
fun CameraScreen() {
// 1. Initialize the controller
val controller = rememberPermissionController()
// 2. Wrap your content in a Gate
PermissionGate(
permission = Permission.Camera,
controller = controller
) { state ->
// 3. Handle the state (Gate handles Rationale/Settings dialogs automatically)
when (state) {
PermissionState.Granted -> CameraPreview()
is PermissionState.Limited -> CameraPreview(isLimited = true)
else -> Box(Modifier.fillMaxSize()) { Text("Camera access required") }
}
}
}For non-UI logic or custom flows, use the controller directly:
val scope = rememberCoroutineScope()
val controller = rememberPermissionController()
Button(onClick = {
scope.launch {
val result = controller.request(Permission.Camera)
if (result == PermissionState.Granted) {
// Start camera
}
}
}) {
Text("Scan Document")
}@Composable
fun CameraScreen() {
PermissionGate(permission = Permission.Camera) { state ->
when (state) {
PermissionState.Granted -> CameraPreview()
else -> Text("Camera access needed to continue")
}
}
}
// Or for multiple permissions together:
@Composable
fun VideoCallScreen() {
PermissionsGate(permissions = listOf(Permission.Camera, Permission.Microphone)) { states ->
if (states.values.all { it == PermissionState.Granted }) {
VideoCallUI()
} else {
Text("Camera and Microphone access needed")
}
}
}PermissionGate always calls your content(state) first -- your UI is the source of truth -- then decides which prompt, if any, to layer on top:
| Live state | Gate shows |
|---|---|
NotDetermined |
rationale dialog (priming before the one-shot native prompt) |
Denied(canRequestAgain = true) |
rationale dialog again |
Denied(canRequestAgain = false) / PermanentlyDenied
|
"open Settings" dialog |
Granted / Limited
|
nothing -- these are working grants |
Restricted |
terminal notice (MDM/parental controls; Settings can't fix it) |
ConfigurationError |
developer-facing error notice |
A dismissed prompt stays dismissed for that exact state only -- the moment the underlying state changes (a request resolves, the user returns from Settings), the gate re-evaluates from scratch. It never goes silent forever, and it never nags on an unchanged state.
Two levels, use whichever fits:
Text-level -- keep the default Material3 dialogs, override any copy. Every default dialog (PermissionRationaleDialog, PermissionSettingsDialog, PermissionRestrictedNotice, PermissionConfigurationErrorNotice) takes title / text / button-label parameters:
PermissionGate(
permission = Permission.Camera,
rationale = { onRequest, onDismiss ->
PermissionRationaleDialog(
permission = Permission.Camera,
onConfirm = onRequest,
onDismiss = onDismiss,
title = "Camera needed",
text = "Scanning a document requires the camera.",
confirmLabel = "Continue",
dismissLabel = "Not now",
)
},
) { state -> /* ... */ }Slot-level -- replace any prompt wholesale. Each of the gate's four prompts (rationale, settingsPrompt, restrictedContent, configurationErrorContent) is a plain composable lambda, so a bottom sheet, inline banner, or your own design-system component drops in without touching the gate's state machine:
PermissionGate(
permission = Permission.LocationWhileInUse,
rationale = { onRequest, onDismiss ->
MyBottomSheet(onConfirm = onRequest, onDismiss = onDismiss)
},
settingsPrompt = { onOpenSettings, onDismiss ->
MyBanner(onClick = onOpenSettings, onClose = onDismiss)
},
) { state ->
when (state) {
PermissionState.Granted -> ShowMap()
is PermissionState.Limited -> ShowMap(approximate = true)
else -> Text("Location needed")
}
}Upgrading a Limited grant: Limited (partial photos, approximate location) is a working grant, so the gate deliberately shows no prompt for it. Offer your own upgrade affordance that calls controller.request(permission) directly -- the OS shows its own picker/upgrade dialog, no rationale needed:
if (state is PermissionState.Limited) {
Button(onClick = { scope.launch { controller.request(Permission.PhotoLibrary) } }) {
Text("Allow access to all photos")
}
}Monitor permission funnels without scattering logs in your UI:
val controller = rememberPermissionController()
LaunchedEffect(controller) {
controller.events.collect { event ->
when (event) {
is PermissionEvent.RequestResult -> {
analytics.logEvent("permission_result", mapOf(
"permission" to event.permission.toString(),
"result" to event.result.toString()
))
}
is PermissionEvent.StateChanged -> {
println("${event.permission} changed to ${event.newState}")
}
is PermissionEvent.RequestStarted -> {
analytics.logEvent("permission_dialog_shown", mapOf(
"permission" to event.permission.toString()
))
}
}
}
}Create a "Privacy Settings" or "Permissions" screen in one line:
PermissionDashboard(
controller = controller,
permissions = listOf(Permission.Camera, Permission.Microphone, Permission.LocationWhileInUse),
modifier = Modifier.fillMaxSize()
)PermissionController lives in permpilot-core, which has zero Compose dependency:
val controller: PermissionController = rememberPermissionController() // hand it to your ViewModel
scope.launch {
when (val state = controller.request(Permission.Notifications)) {
PermissionState.Granted -> scheduleReminder()
is PermissionState.Denied -> showRationale()
PermissionState.PermanentlyDenied -> controller.openAppSettings()
else -> Unit
}
}request() / requestAll() never throw -- every platform failure mode (missing Activity, missing manifest declaration, missing Info.plist key, staged-request sequencing) is resolved internally and reported as data. requestAll() batches what the OS allows into one dialog and automatically pulls staged permissions out into their own sequenced flows.
Android's Permission.Special entries (SystemAlertWindow, ExactAlarm, IgnoreBatteryOptimizations, WriteSettings, ManageExternalStorage, DoNotDisturbAccess, UsageAccess, NotificationListenerAccess) have no request dialog -- only a live state() query and a per-permission deep-linked Settings redirect:
val state by controller.state(Permission.IgnoreBatteryOptimizations).collectAsState()
Button(onClick = { controller.openAppSettings(Permission.IgnoreBatteryOptimizations) }) {
Text("Open Settings")
}Each redirect carries the correct package: data URI where the platform supports one, so the user lands on your app's toggle, not a generic list. Two entries have manifest prerequisites without which the app never appears in the target Settings list -- PermPilot reports ConfigurationError(MissingManifestDeclaration) instead of letting the redirect dead-end:
DoNotDisturbAccess requires <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />.NotificationListenerAccess requires a declared NotificationListenerService (bound with BIND_NOTIFICATION_LISTENER_SERVICE).Changes made in Settings are picked up automatically: rememberPermissionController() re-checks every observed permission on every ON_RESUME.
Wrap any PermissionController to record every request / resolution / settings-open event into a queryable SQLDelight-backed log -- useful for support debugging or a privacy-dashboard screen:
val driver = PermissionHistoryDriverFactory(context).createDriver() // Android needs a Context; iOS needs none
val store = SqlDelightPermissionHistoryStore(driver)
val controller = HistoryPermissionController(rememberPermissionController(), store, scope)
// Drop-in replacement for the wrapped controller. Meanwhile:
store.events() // Flow<List<PermissionHistoryEntry>> -- everything
store.events(Permission.Camera) // ...or filtered per permissionRecording is cancellation-safe: once a request resolves, its Resolved event is written even if the caller was cancelled at that exact moment -- the log never ends up with a dangling Requested. permpilot-history is versioned independently and ships on its own release cadence.
val controller = FakePermissionController()
controller.setState(Permission.Camera, PermissionState.Denied(canRequestAgain = true))
// Drive your screen/ViewModel against `controller` exactly like a real one --
// no Activity, no iOS runtime, no OS permission dialog required.Customize how the "has requested" flags are stored on Android (e.g. for encrypted storage):
val persistence = SharedPreferencesPermissionPersistence(context, prefsName = "my_custom_prefs")
val controller = PermissionController.create(context, persistence = persistence)sealed interface Permission {
sealed interface Runtime : Permission // has a real request on ≥1 platform
sealed interface Special : Permission // Android Settings redirect, no dialog
sealed interface PlatformLimited : Permission // no request API on either platform
data object Camera : Runtime
data object LocationWhileInUse : Runtime
data object LocationAlways : Runtime // staged: foreground first, then background
data object PhotoLibrary : Runtime // supports Limited(PartialMediaAccess)
/* ...27 Runtime + 8 Special + LocalNetwork -- full list in docs/permission-matrix.md */
}
sealed interface PermissionState {
data object NotDetermined : PermissionState
data object Granted : PermissionState
data class Denied(val canRequestAgain: Boolean) : PermissionState
data object PermanentlyDenied : PermissionState
data object Restricted : PermissionState
data class Limited(val reason: LimitedReason) : PermissionState
data class ConfigurationError(val reason: ConfigurationErrorReason) : PermissionState
}
interface PermissionController {
val events: SharedFlow<PermissionEvent>
fun state(permission: Permission): StateFlow<PermissionState>
suspend fun request(permission: Permission.Runtime): PermissionState
suspend fun requestAll(vararg permissions: Permission.Runtime): Map<Permission, PermissionState>
fun openAppSettings()
fun openAppSettings(special: Permission.Special)
fun refreshAll()
}permpilot-core Permission catalog + PermissionState model + expect/actual PermissionController.
Pure Kotlin in commonMain; zero Compose dependency. Usable from ViewModels.
▲
│ api()
permpilot-compose Compose Multiplatform layer: PermissionGate, default Material3 dialogs,
rememberPermissionController(), lifecycle re-check on resume.
│
permpilot-history Optional decorator: HistoryPermissionController + SQLDelight event store.
Depends only on permpilot-core; independently versioned.
sample/ QA harness exercising all 35 catalog entries against a real device
(OS permission dialogs can't be driven by instrumented tests).
PermissionController is the only expect/actual surface. Everything else in commonMain -- the catalog, the state model, the gate's decision logic -- is platform-agnostic pure Kotlin.actual, coroutine bridging, staged sequencing, and persistence live in one file (*PermissionController.kt), while pure Permission ↔ platform type translation lives in another (AndroidPermissionMapping.kt / IosPermissionStatusMapping.kt). Adding a permission touches the mapping, not the orchestration -- and the mapping functions are directly unit-testable with no device.PermissionState, so a single exhaustive when in consumer code covers everything and the compiler flags anything new.PermissionGate delegates which prompt to show to a pure resolver (state × dismissed-for → prompt), unit-tested across the full matrix without a Compose runtime. The gate itself only wires slots.StateFlow from inside the OS callback, and requests are serialized through a Mutex -- a cancelled caller or two overlapping requests can't strand the observable state or hang a coroutine.Denied vs PermanentlyDenied is resolved by cross-checking shouldShowRequestPermissionRationale against a persisted has-requested flag; grouped requests check for legitimate partial-grant tiers before falling through to the all-or-nothing resolver; manifest declarations are verified before the OS gets a chance to silently auto-deny.PermissionGate ──▶ controller.request(permission)
│ Mutex: one system dialog at a time
│ 1. already granted? ──▶ Granted (no launcher round-trip)
│ 2. declared in manifest? ──▶ ConfigurationError if not
│ 3. host Activity attached? ──▶ ConfigurationError if not
│ 4. staged permission? ──▶ foreground request first, then a
│ separate background request
▼
MultiRequest ──▶ SharedFlow ──▶ ActivityResultContracts.RequestMultiplePermissions
│
state published from the launcher callback (survives caller cancellation),
resolved through pure functions: partial-grant tiers ▶ rationale flag ▶
persisted has-requested flag ▶ Denied / PermanentlyDenied / Limited / Granted
On iOS the same shape maps to per-framework authorization calls (AVCaptureDevice, CLLocationManager, PHPhotoLibrary, ATTrackingManager, ...), with every delegate/completion callback marshalled onto the main queue and Info.plist keys verified before any native call.
| Concern | Technology |
|---|---|
| Language / targets | Kotlin Multiplatform 2.4 -- android, iosArm64, iosSimulatorArm64
|
| UI layer | Compose Multiplatform 1.11 (Material3) |
| Concurrency & reactivity | kotlinx-coroutines -- suspend requests, StateFlow observation |
| Lifecycle | JetBrains AndroidX Lifecycle (KMP) -- shared ON_RESUME re-check on both platforms |
| Persistence (core) |
multiplatform-settings -- the persisted has-requested flag behind Denied-vs-PermanentlyDenied |
| Persistence (history) | SQLDelight 2 -- typed, queryable event log with platform drivers |
| Android build | AGP com.android.kotlin.multiplatform.library, convention plugins in build-logic/
|
| API stability | kotlinx binary-compatibility-validator -- klib ABI baselines checked on every build |
| Publishing | vanniktech maven-publish -- Maven Central, Dokka javadoc, GPG-signed |
Three test source sets in permpilot-core, each targeting what's actually testable without a device:
commonTest -- pure model logic and FakePermissionController, kotlin.test.androidHostTest -- JVM-only, no emulator: pure resolve-state functions tested directly, plus the real AndroidPermissionController driven through Robolectric shadows, not mocks -- including regression tests for caller-cancellation mid-dialog, check-before-request, partial grants, and manifest-declaration detection.iosTest -- pure iOS status-mapping functions; compiles for both iOS targets on any host (execution needs a Mac/simulator).permpilot-compose unit-tests the gate's full prompt matrix as a pure function; the sample app carries an end-to-end Robolectric test proving Settings-made grants are picked up on resume through real composition and a real Activity lifecycle.
Info.plist key(s), and platform notes.Health/HealthDataType/HealthAccess model and the Health Connect / HealthKit platform mapping, including the Android manifest entries Health Connect needs beyond <uses-permission> to list your app at all.Enable the pre-commit hook once per clone so local mistakes get caught before CI does:
git config core.hooksPath .githooks
Run the full gate the same way CI does before opening a PR:
./gradlew build
This runs all tests on every module, compiles both iOS targets, runs Android Lint, and verifies the public API against the committed binary-compatibility baselines (<module>/api/*.klib.api -- regenerate with ./gradlew apiDump when you intentionally change the API surface).
Copyright 2026 Alireza Javan
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.