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:
- Android's
shouldShowRequestPermissionRationale()returnsfalseboth 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. - Background location and background body sensors cannot be bundled with other permissions: the OS requires their foreground counterpart granted first, then a separate second request. iOS's
requestAlwaysAuthorization()is likewise a silent no-op unless when-in-use is already granted. PermPilot sequences both internally. - Grouped requests can partially succeed: the user can grant approximate-only location or selected-photos-only access. Treating "not everything granted" as a denial permanently misreports a working grant -- PermPilot models these as
Limited(reason). - Requesting a permission missing from
AndroidManifest.xmldoesn't fail loudly -- Android silently auto-denies it, which looks exactly like the user said no. PermPilot detects it and reportsConfigurationErrorinstead. - On iOS, calling an authorization API without its
Info.plistusage-description key terminates the process. PermPilot checks first and reportsConfigurationErrorinstead 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.
- One API, both platforms --
request()returns a sealedPermissionStatecovering every outcome either OS can produce:NotDetermined,Granted,Denied(canRequestAgain),PermanentlyDenied,Restricted,Limited(reason),ConfigurationError(reason). - 41-entry permission catalog -- 31
Runtimepermissions (camera, microphone, location tiers, photos/media, Bluetooth, Health (Health Connect/HealthKit), telephony/SMS/call log, sensors, activity recognition, ATT, speech, reminders, ...) and 9 AndroidSpecialpermissions (overlay, exact alarms, full-screen intent, battery optimizations, write settings, all-files access, DND access, usage access, notification-listener access). See the permission matrix. - Staged requests sequenced for you -- background location / background body sensors on Android, when-in-use → always upgrade on iOS: one
request()call, correct two-step choreography inside. - Partial grants modeled, not mangled -- approximate-only location, selected-photos, and selected-contacts (iOS 18) access surface as
Limited(ApproximateLocationOnly),Limited(PartialMediaAccess), orLimited(SelectedContactsOnly). PermissionGate&PermissionsGate-- Compose wrappers that always render your content first, then layer the right prompt on top of live state.PermissionsGatehandles multiple permissions together with a single rationale/settings flow.- Built-in Localization -- Default dialogs are localized to English, Spanish, French, and German out of the box. Every prompt remains replaceable or overridable.
PermissionDashboard-- A ready-to-use library component to build a "Privacy Settings" screen listing every permission, its state, and settings links.- Observability (Analytics) --
controller.eventsprovides a structured stream ofPermissionEvents (StateChanged,RequestStarted,RequestResult) to wire into your analytics funnel without scattering logging. - Reactive state --
state(permission)is aStateFlow<PermissionState>; grants made in the OS Settings app are picked up automatically onON_RESUMEand republished to every observer. - Fail-fast configuration errors -- missing host
Activity, missingInfo.plistkey, 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 declaredNotificationListenerService) all report asConfigurationErrorthrough the same exhaustivewhen-- 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. - Crash-safe result delivery -- request outcomes are published to the
StateFlowfrom 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: scriptDenied → Grantedor any other flow with no Activity, no simulator, no OS dialog.- Optional audit log --
permpilot-historydecorates 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:
- Android: the relevant
<uses-permission>entries inAndroidManifest.xml(see the permission matrix for the exact, API-level-gated set per catalog entry). - iOS: the relevant
NS*UsageDescriptionkeys inInfo.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.
- Android: Add
<uses-permission>toAndroidManifest.xml. - iOS: Add
NS*UsageDescriptionkeys toInfo.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:
DoNotDisturbAccessrequires<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />.NotificationListenerAccessrequires a declaredNotificationListenerService(bound withBIND_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).
- One narrow platform boundary.
PermissionControlleris the onlyexpect/actualsurface. Everything else incommonMain-- the catalog, the state model, the gate's decision logic -- is platform-agnostic pure Kotlin. - Orchestration / mapping split. Inside each platform
actual, coroutine bridging, staged sequencing, and persistence live in one file (*PermissionController.kt), while purePermission ↔ platform typetranslation 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. - States, not exceptions. Every outcome -- including integration mistakes -- flows through the same sealed
PermissionState, so a single exhaustivewhenin consumer code covers everything and the compiler flags anything new. - UI decisions are pure functions.
PermissionGatedelegates 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. - Results outlive their callers. Grant/deny outcomes are published to the
StateFlowfrom inside the OS callback, and requests are serialized through aMutex-- a cancelled caller or two overlapping requests can't strand the observable state or hang a coroutine. - Trust nothing implicit about Android.
DeniedvsPermanentlyDeniedis resolved by cross-checkingshouldShowRequestPermissionRationaleagainst 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 andFakePermissionController,kotlin.test.androidHostTest-- JVM-only, no emulator: pure resolve-state functions tested directly, plus the realAndroidPermissionControllerdriven 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.
- Permission matrix -- every catalog entry, its Android manifest permission(s) and API-level gating, iOS
Info.plistkey(s), and platform notes. - iOS Info.plist checklist -- the usage-description keys each permission requires.
- Health & Fitness design -- the
Health/HealthDataType/HealthAccessmodel and the Health Connect / HealthKit platform mapping, including the Android manifest entries Health Connect needs beyond<uses-permission>to list your app at all. - Roadmap -- planned permissions and features to add next, as step-by-step tasks with per-step done markers (built to be picked up and continued by any contributor or AI coding agent).
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.


