
Production-ready permission manager eliminating lifecycle boilerplate and fragments; automatic rationale/settings dialogs, service checks for GPS/Bluetooth, dead-click and gallery permission edge-case fixes, thread-safe API.
Type-safe permission management for Kotlin Multiplatform — Android, iOS & Browser
Documentation · API reference · Quick start · Why Grant? · Demo app
Grant handles the permission edge cases that simple wrappers miss: silent deadlocks, Android process death, iOS Info.plist crashes, partial media/location access, and hardware service state. The API is logic-first — request permissions from a ViewModel or repository with no Activity, Fragment, or lifecycle binding.
class CameraViewModel(grantManager: GrantManager) : ViewModel() {
val cameraGrant = GrantHandler(grantManager, AppGrant.CAMERA, viewModelScope)
fun onCaptureClick() = viewModelScope.launch {
if (cameraGrant.requestSuspend() == GrantStatus.GRANTED) {
cameraEngine.start()
}
}
}@Composable
fun CameraScreen(viewModel: CameraViewModel) {
GrantDialog(handler = viewModel.cameraGrant) // renders rationale / settings-guide dialogs (Material 3)
Button(onClick = viewModel::onCaptureClick) { Text("Start camera") }
}Requirements: Android 8.0+ (API 26) · iOS 13+ · a browser with navigator.permissions
(grant-core only — Compose Multiplatform Web or Kotlin/JS) · Kotlin 2.x · JVM 17
grant-core declares zero <uses-permission> entries, so nothing appears on your Play listing that you did not ask for. The Android counterpart to the iOS framework isolation below.NSUsageDescription keys.Info.plist keys before requesting, turning the classic SIGABRT production crash into a clear error.SavedStateHandle, with no timeouts.withTimeout test policy that converts silent deadlocks into failing tests.RawPermission for anything the library doesn't ship yet.grant-core only) — real navigator.permissions/getUserMedia/Notification/Geolocation checks for Camera, Microphone, Location, and Notification on js and wasmJs, the latter specifically for Compose Multiplatform Web. Every other grant honestly reports unsupported rather than a fabricated GRANTED.GrantGroupHandler requests several permissions in a single batch, drives one StateFlow for the whole group, and fires onAllGranted only when every one is satisfied.GrantEventListener to any handler and observe every stage: requested, granted, denied, rationale shown, settings guide shown, settings opened.grant-compose module renders the rationale and settings-guide flow out of the box.Grant is published to Maven Central. Add the core module, plus only the optional modules you use. A BOM keeps every module's version in sync without retyping it:
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(platform("dev.brewkits:grant-bom:2.5.0")) // pins every dev.brewkits:grant-* line below
implementation("dev.brewkits:grant-core")
// Optional
implementation("dev.brewkits:grant-compose") // Compose dialogs (Material 3)
implementation("dev.brewkits:grant-core-koin") // Koin DI integration
// Optional per-permission modules. Omitting a module means its iOS
// framework is never linked — no phantom NSUsageDescription keys.
implementation("dev.brewkits:grant-contacts") // Contacts (iOS CNContactStore)
implementation("dev.brewkits:grant-calendar") // Calendar (iOS EventKit)
implementation("dev.brewkits:grant-motion") // Motion (iOS CoreMotion)
implementation("dev.brewkits:grant-bluetooth") // Bluetooth (iOS CoreBluetooth)
implementation("dev.brewkits:grant-location-always") // Background "always" location (iOS)
}
commonTest.dependencies {
implementation("dev.brewkits:grant-testing") // FakeGrantManager and friends — see Testing below
}
}
}No BOM? Every artifact above also resolves with an explicit version, e.g.
implementation("dev.brewkits:grant-core:2.5.0").
Android needs no setup: Grant ships a self-contained transparent Activity, so request() opens the system dialog from anywhere. Prefer your own ActivityResultLauncher? Register it once with grantManager.setLauncher(...) and Grant uses it instead.
iOS needs one call per optional module at app startup:
// AppDelegate / @main entry point
GrantContacts.shared.initialize() // if you added grant-contacts
GrantCalendar.shared.initialize() // if you added grant-calendar
GrantMotion.shared.initialize() // if you added grant-motion
GrantBluetooth.shared.initialize() // if you added grant-bluetooth
GrantLocationAlways.shared.initialize() // if you added grant-location-always[!WARNING] 2.3.0 —
grant-composeno longer ships aniosX64target. Compose Multiplatform 1.11 stopped publishing iosX64 artifacts. All other modules keep iosX64; Intel-Mac-simulator consumers ofgrant-composeshould stay on 2.2.3. Details in the Migration Guide.
[!NOTE] Migrating from v1.x? Contacts, Calendar, Motion, Bluetooth, and background location are now opt-in modules: add the artifact and call
initialize()on iOS. Android needs no code changes. Projects that also target Web/Desktop should isolate Grant behind amobileMainsource set — see Dependency Management.
grant-bom is a Maven BOM (a Gradle java-platform, not a code module) that pins every other
dev.brewkits:grant-* artifact to the same version. Add it once with platform(...) and every
other Grant dependency in the same source set can drop its version string — one line to bump
instead of nine, and no risk of accidentally mixing grant-core:2.4.0 with grant-tracking:2.3.0
in the same build (AppGrant's enum ordinals are inlined at compile time, so a version mismatch
across modules is exactly the kind of thing worth ruling out mechanically — see the
Migration Guide).
grant-testing ships the fakes Grant's own test suite uses internally — FakeGrantManager,
FakeServiceManager, MultiGrantFakeManager, and FakeGrantStore — as a testImplementation
artifact, so testing code that depends on GrantManager/ServiceManager doesn't require writing
a hand-rolled fake first:
class CameraViewModelTest {
@Test
fun `starts the camera once granted`() = runTest {
val manager = FakeGrantManager(mockStatus = GrantStatus.NOT_DETERMINED)
manager.configure(AppGrant.CAMERA, status = GrantStatus.GRANTED)
val viewModel = CameraViewModel(manager)
viewModel.onCaptureClick()
assertTrue(manager.requestedGrants.contains(AppGrant.CAMERA))
}
}Every call is recorded (requestedGrants, checkStatusCalls, openSettingsCalled,
capturedLauncher), and shouldThrow/simulatedDelayMs let a test exercise error and latency
paths without touching a real platform API.
GrantHandler runs the full state machine for one permission — request, rationale, permanent denial, settings guide — and exposes it as a StateFlow your UI can render:
class CameraViewModel(grantManager: GrantManager) : ViewModel() {
val cameraGrant = GrantHandler(
grantManager = grantManager,
grant = AppGrant.CAMERA,
scope = viewModelScope,
)
// Suspend until the flow resolves…
suspend fun startCapture() {
if (cameraGrant.requestSuspend() == GrantStatus.GRANTED) cameraEngine.start()
}
// …or consume it reactively.
val captureFlow = cameraGrant.requestFlow()
.filter { it == GrantStatus.GRANTED }
.onEach { cameraEngine.start() }
}A feature that needs more than one permission — a video call needs Camera and Microphone — should not
ask twice and should not half-succeed. GrantGroupHandler batches the system prompts into one pass, then
walks any refusals individually to show the right rationale or settings guide. onAllGranted runs only when
every permission in the group is satisfied:
class CallViewModel(grantManager: GrantManager) : ViewModel() {
val callGrants = GrantGroupHandler(
grantManager = grantManager,
grants = listOf(AppGrant.CAMERA, AppGrant.MICROPHONE),
scope = viewModelScope,
)
fun onJoinCall() {
callGrants.request(
rationaleMessages = mapOf(
AppGrant.CAMERA to "Your camera is needed so others can see you.",
AppGrant.MICROPHONE to "Your microphone is needed so others can hear you.",
)
) {
// Runs only when BOTH are granted.
callEngine.join()
}
}
}callGrants.state is a single StateFlow<GrantGroupUiState> for the whole group, and GrantGroupDialog(callGrants)
renders it. Per-permission results stay available through callGrants.statuses.
Every handler takes an optional GrantEventListener. Each method has a default empty implementation, so
override only the stages you measure — useful for finding where users actually drop off:
val cameraGrant = GrantHandler(
grantManager = grantManager,
grant = AppGrant.CAMERA,
scope = viewModelScope,
eventListener = object : GrantEventListener {
override fun onRationaleShown(grant: GrantPermission) {
analytics.track("permission_rationale_shown", grant.toString())
}
override fun onDenied(grant: GrantPermission, status: GrantStatus) {
// status distinguishes DENIED from DENIED_ALWAYS
analytics.track("permission_denied", grant.toString(), status.toString())
}
override fun onSettingsOpened(grant: GrantPermission) {
analytics.track("permission_settings_opened", grant.toString())
}
},
)val scanFlow = grantFlow {
val btStatus = bluetoothHandler.requestSuspend()
if (btStatus == GrantStatus.GRANTED) {
locationHandler.requestSuspend() // needed for BLE scanning on some Android versions
}
}A granted location permission is useless with GPS turned off. GrantAndServiceChecker answers both questions in one call:
fun startTracking() {
viewModelScope.launch {
when (checker.checkLocationReady()) {
is LocationReadyStatus.Ready -> sensor.start()
is LocationReadyStatus.ServiceDisabled -> uiState.showEnableGps()
is LocationReadyStatus.GrantDenied -> requestPermission()
is LocationReadyStatus.BothRequired -> uiState.showBothPrompts()
}
}
}Most KMP permission libraries are thin wrappers around the native APIs. Grant is built around the failure modes those wrappers hit in production:
| Grant | moko-permissions | accompanist-permissions | |
|---|---|---|---|
| No lifecycle binding | ✅ | ❌ needs BindEffect
|
❌ needs Activity |
| ViewModel support | full | partial | ❌ |
iOS crash prevention (Info.plist) |
✅ | ❌ | — |
| iOS framework isolation | ✅ | ❌ | — |
| Process-death recovery | built-in | ❌ | manual |
| Service checks (GPS/BT/Health) | ✅ | ❌ | ❌ |
| Android 14 partial access | ✅ | partial | ✅ |
| Custom permissions | ✅ | limited | limited |
Every claim below is enforced by CI or was checked by hand on real hardware — nothing here is aspirational.
main — build, full test suite, Android
Lint, and the API-surface check all run before a PR can land.checkKotlinAbi); a PR that changes the surface without
regenerating the dump fails CI. Two breaking changes shipped unnoticed in v2.1.0 before this
gate existed — it hasn't happened since. (grant-bom, a Maven BOM, has no Kotlin surface to
lock — its POM's <dependencyManagement> block is the whole artifact.)ACCESS_LOCAL_NETWORK
mapping and the multi-process advisory in 2.4.0 were both confirmed on a physical device —
the multi-process case specifically because a secondary-process request measured a real
120-second timeout with the permission silently granted underneath it, a class of bug a
simulator-only test suite would never have caught.js and wasmJs targets, including the Firefox permissions.query()
fallback path.| Artifact | Release AAR |
|---|---|
grant-core |
293 KB |
grant-compose |
30 KB |
grant-core-koin |
7 KB |
grant-contacts · grant-calendar · grant-motion · grant-bluetooth · grant-location-always · grant-tracking
|
~2 KB each |
grant-testing (test-only — see Testing below) |
20 KB |
Download size is not app size. In a real R8-minified build (the demo, with
-allowaccessmodification), Grant contributes 83 classes out of 2,686 — the rest is
stripped. Adding an optional module such as grant-bluetooth costs single-digit kilobytes.
Every published module emits a CycloneDX SBOM (./gradlew cyclonedxBom →
<module>/build/reports/bom.json), so you can answer "what is inside this dependency?"
without unpacking it.
No Baseline Profile is shipped, deliberately. Grant's entire startup contribution is one
ContentProvider.onCreate() that registers an activity-lifecycle callback; there is no hot
path for AOT compilation to improve. The permission request path is user-triggered and gated
behind a system dialog, where JIT versus AOT is not measurable. A profile here would be
ceremony, not speed.
23 built-in permissions across Camera, Microphone, Gallery (read and save-only), Storage, Location, Notifications, Bluetooth (combined, or scan-only / connect-only separately), Contacts, Calendar, Motion, Exact Alarms, Nearby Wi-Fi, Local Network, and App Tracking Transparency — anything else via RawPermission.
| Permission | Android | iOS | Notes |
|---|---|---|---|
| Camera | ✅ | ✅ | iOS main-thread safe + deadlock fix |
| Microphone | ✅ | ✅ | Shares the AVFoundation handler with Camera |
| Gallery (full) | ✅ | ✅ | Android 14+ partial access → PARTIAL_GRANTED
|
| Gallery (images only) | ✅ | ✅ | AppGrant.GALLERY_IMAGES_ONLY |
| Gallery (video only) | ✅ | ✅ | AppGrant.GALLERY_VIDEO_ONLY |
| Gallery (save only) | ✅ | ✅ |
AppGrant.GALLERY_ADD_ONLY — no prompt at all on Android 10+; PHAccessLevelAddOnly on iOS |
| Storage (legacy) | ✅ | ✅ | Pre-API 33 fallback |
| Location (when in use) | ✅ | ✅ | GPS service check; "Approximate"-only → PARTIAL_GRANTED
|
| Location (always) | ✅ | ✅ | Android two-step background flow handled |
| Notifications | ✅ | ✅ | Android 13+ and legacy flows |
| Bluetooth | ✅ | ✅ | Service status check + Scan/Connect |
| Bluetooth Advertise | ✅ | ✅ | AppGrant.BLUETOOTH_ADVERTISE |
| Contacts (full) | ✅ | ✅ | Read + write access |
| Contacts (read-only) | ✅ | ✅ | AppGrant.READ_CONTACTS |
| Calendar (full) | ✅ | ✅ | iOS 17+ FullAccess / WriteOnly mapped correctly |
| Calendar (read-only) | ✅ | ✅ | AppGrant.READ_CALENDAR |
| Motion / Activity | ✅ | ✅ | Simulator-aware (safe mock on Simulator) |
| Schedule Exact Alarm | ✅ | ✅ | Android 12+ SCHEDULE_EXACT_ALARM
|
| Nearby Wi-Fi Devices | ✅ | ✅ |
NEARBY_WIFI_DEVICES (API 33+); no-op on iOS |
| Local Network | ✅ | ✅ | Android 17+ ACCESS_LOCAL_NETWORK; no-op below API 37 and on iOS (OS auto-prompts) |
| App Tracking Transparency | ✅ | ✅ |
AppGrant.APP_TRACKING — iOS ATTrackingManager (requires the optional grant-tracking module); Android has no runtime gate for cross-app tracking, so this honestly reports GRANTED rather than prompting |
Service checks (ServiceType)
| Service | Android | iOS |
|---|---|---|
| GPS / Location | ✅ | ✅ |
| Bluetooth | ✅ | ✅ |
| Wi-Fi | ✅ | ✅ |
| NFC | ✅ | — |
| Camera hardware | ✅ | ✅ |
| Health Connect / HealthKit | ✅ | ✅ |
| Guide | Description |
|---|---|
| Quick start | Request your first permission in five minutes |
| Architecture | Concurrency, state machines, and the mutex flow |
| iOS setup |
Info.plist configuration — read before shipping |
| Migration guide | Upgrading to 2.5.0, to 2.4.0 (and from v1.x → 2.x) |
| Service checking | Combining permission and hardware service checks |
| Support policy | Versioning, supported versions, platform support, and what Grant will not do |
| Manual injection | Using Grant without a DI framework |
| Android reliability | How Grant fixes "dead clicks" on Android |
| Best practices | Patterns for production apps |
Contributions are welcome — see CONTRIBUTING.md and the
Code of Conduct. New to the codebase? Start with an issue labeled
good first issue.
Run ./gradlew :grant-core:allTests before submitting a PR.
Apache License 2.0 — see LICENSE.
Type-safe permission management for Kotlin Multiplatform — Android, iOS & Browser
Documentation · API reference · Quick start · Why Grant? · Demo app
Grant handles the permission edge cases that simple wrappers miss: silent deadlocks, Android process death, iOS Info.plist crashes, partial media/location access, and hardware service state. The API is logic-first — request permissions from a ViewModel or repository with no Activity, Fragment, or lifecycle binding.
class CameraViewModel(grantManager: GrantManager) : ViewModel() {
val cameraGrant = GrantHandler(grantManager, AppGrant.CAMERA, viewModelScope)
fun onCaptureClick() = viewModelScope.launch {
if (cameraGrant.requestSuspend() == GrantStatus.GRANTED) {
cameraEngine.start()
}
}
}@Composable
fun CameraScreen(viewModel: CameraViewModel) {
GrantDialog(handler = viewModel.cameraGrant) // renders rationale / settings-guide dialogs (Material 3)
Button(onClick = viewModel::onCaptureClick) { Text("Start camera") }
}Requirements: Android 8.0+ (API 26) · iOS 13+ · a browser with navigator.permissions
(grant-core only — Compose Multiplatform Web or Kotlin/JS) · Kotlin 2.x · JVM 17
grant-core declares zero <uses-permission> entries, so nothing appears on your Play listing that you did not ask for. The Android counterpart to the iOS framework isolation below.NSUsageDescription keys.Info.plist keys before requesting, turning the classic SIGABRT production crash into a clear error.SavedStateHandle, with no timeouts.withTimeout test policy that converts silent deadlocks into failing tests.RawPermission for anything the library doesn't ship yet.grant-core only) — real navigator.permissions/getUserMedia/Notification/Geolocation checks for Camera, Microphone, Location, and Notification on js and wasmJs, the latter specifically for Compose Multiplatform Web. Every other grant honestly reports unsupported rather than a fabricated GRANTED.GrantGroupHandler requests several permissions in a single batch, drives one StateFlow for the whole group, and fires onAllGranted only when every one is satisfied.GrantEventListener to any handler and observe every stage: requested, granted, denied, rationale shown, settings guide shown, settings opened.grant-compose module renders the rationale and settings-guide flow out of the box.Grant is published to Maven Central. Add the core module, plus only the optional modules you use. A BOM keeps every module's version in sync without retyping it:
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(platform("dev.brewkits:grant-bom:2.5.0")) // pins every dev.brewkits:grant-* line below
implementation("dev.brewkits:grant-core")
// Optional
implementation("dev.brewkits:grant-compose") // Compose dialogs (Material 3)
implementation("dev.brewkits:grant-core-koin") // Koin DI integration
// Optional per-permission modules. Omitting a module means its iOS
// framework is never linked — no phantom NSUsageDescription keys.
implementation("dev.brewkits:grant-contacts") // Contacts (iOS CNContactStore)
implementation("dev.brewkits:grant-calendar") // Calendar (iOS EventKit)
implementation("dev.brewkits:grant-motion") // Motion (iOS CoreMotion)
implementation("dev.brewkits:grant-bluetooth") // Bluetooth (iOS CoreBluetooth)
implementation("dev.brewkits:grant-location-always") // Background "always" location (iOS)
}
commonTest.dependencies {
implementation("dev.brewkits:grant-testing") // FakeGrantManager and friends — see Testing below
}
}
}No BOM? Every artifact above also resolves with an explicit version, e.g.
implementation("dev.brewkits:grant-core:2.5.0").
Android needs no setup: Grant ships a self-contained transparent Activity, so request() opens the system dialog from anywhere. Prefer your own ActivityResultLauncher? Register it once with grantManager.setLauncher(...) and Grant uses it instead.
iOS needs one call per optional module at app startup:
// AppDelegate / @main entry point
GrantContacts.shared.initialize() // if you added grant-contacts
GrantCalendar.shared.initialize() // if you added grant-calendar
GrantMotion.shared.initialize() // if you added grant-motion
GrantBluetooth.shared.initialize() // if you added grant-bluetooth
GrantLocationAlways.shared.initialize() // if you added grant-location-always[!WARNING] 2.3.0 —
grant-composeno longer ships aniosX64target. Compose Multiplatform 1.11 stopped publishing iosX64 artifacts. All other modules keep iosX64; Intel-Mac-simulator consumers ofgrant-composeshould stay on 2.2.3. Details in the Migration Guide.
[!NOTE] Migrating from v1.x? Contacts, Calendar, Motion, Bluetooth, and background location are now opt-in modules: add the artifact and call
initialize()on iOS. Android needs no code changes. Projects that also target Web/Desktop should isolate Grant behind amobileMainsource set — see Dependency Management.
grant-bom is a Maven BOM (a Gradle java-platform, not a code module) that pins every other
dev.brewkits:grant-* artifact to the same version. Add it once with platform(...) and every
other Grant dependency in the same source set can drop its version string — one line to bump
instead of nine, and no risk of accidentally mixing grant-core:2.4.0 with grant-tracking:2.3.0
in the same build (AppGrant's enum ordinals are inlined at compile time, so a version mismatch
across modules is exactly the kind of thing worth ruling out mechanically — see the
Migration Guide).
grant-testing ships the fakes Grant's own test suite uses internally — FakeGrantManager,
FakeServiceManager, MultiGrantFakeManager, and FakeGrantStore — as a testImplementation
artifact, so testing code that depends on GrantManager/ServiceManager doesn't require writing
a hand-rolled fake first:
class CameraViewModelTest {
@Test
fun `starts the camera once granted`() = runTest {
val manager = FakeGrantManager(mockStatus = GrantStatus.NOT_DETERMINED)
manager.configure(AppGrant.CAMERA, status = GrantStatus.GRANTED)
val viewModel = CameraViewModel(manager)
viewModel.onCaptureClick()
assertTrue(manager.requestedGrants.contains(AppGrant.CAMERA))
}
}Every call is recorded (requestedGrants, checkStatusCalls, openSettingsCalled,
capturedLauncher), and shouldThrow/simulatedDelayMs let a test exercise error and latency
paths without touching a real platform API.
GrantHandler runs the full state machine for one permission — request, rationale, permanent denial, settings guide — and exposes it as a StateFlow your UI can render:
class CameraViewModel(grantManager: GrantManager) : ViewModel() {
val cameraGrant = GrantHandler(
grantManager = grantManager,
grant = AppGrant.CAMERA,
scope = viewModelScope,
)
// Suspend until the flow resolves…
suspend fun startCapture() {
if (cameraGrant.requestSuspend() == GrantStatus.GRANTED) cameraEngine.start()
}
// …or consume it reactively.
val captureFlow = cameraGrant.requestFlow()
.filter { it == GrantStatus.GRANTED }
.onEach { cameraEngine.start() }
}A feature that needs more than one permission — a video call needs Camera and Microphone — should not
ask twice and should not half-succeed. GrantGroupHandler batches the system prompts into one pass, then
walks any refusals individually to show the right rationale or settings guide. onAllGranted runs only when
every permission in the group is satisfied:
class CallViewModel(grantManager: GrantManager) : ViewModel() {
val callGrants = GrantGroupHandler(
grantManager = grantManager,
grants = listOf(AppGrant.CAMERA, AppGrant.MICROPHONE),
scope = viewModelScope,
)
fun onJoinCall() {
callGrants.request(
rationaleMessages = mapOf(
AppGrant.CAMERA to "Your camera is needed so others can see you.",
AppGrant.MICROPHONE to "Your microphone is needed so others can hear you.",
)
) {
// Runs only when BOTH are granted.
callEngine.join()
}
}
}callGrants.state is a single StateFlow<GrantGroupUiState> for the whole group, and GrantGroupDialog(callGrants)
renders it. Per-permission results stay available through callGrants.statuses.
Every handler takes an optional GrantEventListener. Each method has a default empty implementation, so
override only the stages you measure — useful for finding where users actually drop off:
val cameraGrant = GrantHandler(
grantManager = grantManager,
grant = AppGrant.CAMERA,
scope = viewModelScope,
eventListener = object : GrantEventListener {
override fun onRationaleShown(grant: GrantPermission) {
analytics.track("permission_rationale_shown", grant.toString())
}
override fun onDenied(grant: GrantPermission, status: GrantStatus) {
// status distinguishes DENIED from DENIED_ALWAYS
analytics.track("permission_denied", grant.toString(), status.toString())
}
override fun onSettingsOpened(grant: GrantPermission) {
analytics.track("permission_settings_opened", grant.toString())
}
},
)val scanFlow = grantFlow {
val btStatus = bluetoothHandler.requestSuspend()
if (btStatus == GrantStatus.GRANTED) {
locationHandler.requestSuspend() // needed for BLE scanning on some Android versions
}
}A granted location permission is useless with GPS turned off. GrantAndServiceChecker answers both questions in one call:
fun startTracking() {
viewModelScope.launch {
when (checker.checkLocationReady()) {
is LocationReadyStatus.Ready -> sensor.start()
is LocationReadyStatus.ServiceDisabled -> uiState.showEnableGps()
is LocationReadyStatus.GrantDenied -> requestPermission()
is LocationReadyStatus.BothRequired -> uiState.showBothPrompts()
}
}
}Most KMP permission libraries are thin wrappers around the native APIs. Grant is built around the failure modes those wrappers hit in production:
| Grant | moko-permissions | accompanist-permissions | |
|---|---|---|---|
| No lifecycle binding | ✅ | ❌ needs BindEffect
|
❌ needs Activity |
| ViewModel support | full | partial | ❌ |
iOS crash prevention (Info.plist) |
✅ | ❌ | — |
| iOS framework isolation | ✅ | ❌ | — |
| Process-death recovery | built-in | ❌ | manual |
| Service checks (GPS/BT/Health) | ✅ | ❌ | ❌ |
| Android 14 partial access | ✅ | partial | ✅ |
| Custom permissions | ✅ | limited | limited |
Every claim below is enforced by CI or was checked by hand on real hardware — nothing here is aspirational.
main — build, full test suite, Android
Lint, and the API-surface check all run before a PR can land.checkKotlinAbi); a PR that changes the surface without
regenerating the dump fails CI. Two breaking changes shipped unnoticed in v2.1.0 before this
gate existed — it hasn't happened since. (grant-bom, a Maven BOM, has no Kotlin surface to
lock — its POM's <dependencyManagement> block is the whole artifact.)ACCESS_LOCAL_NETWORK
mapping and the multi-process advisory in 2.4.0 were both confirmed on a physical device —
the multi-process case specifically because a secondary-process request measured a real
120-second timeout with the permission silently granted underneath it, a class of bug a
simulator-only test suite would never have caught.js and wasmJs targets, including the Firefox permissions.query()
fallback path.| Artifact | Release AAR |
|---|---|
grant-core |
293 KB |
grant-compose |
30 KB |
grant-core-koin |
7 KB |
grant-contacts · grant-calendar · grant-motion · grant-bluetooth · grant-location-always · grant-tracking
|
~2 KB each |
grant-testing (test-only — see Testing below) |
20 KB |
Download size is not app size. In a real R8-minified build (the demo, with
-allowaccessmodification), Grant contributes 83 classes out of 2,686 — the rest is
stripped. Adding an optional module such as grant-bluetooth costs single-digit kilobytes.
Every published module emits a CycloneDX SBOM (./gradlew cyclonedxBom →
<module>/build/reports/bom.json), so you can answer "what is inside this dependency?"
without unpacking it.
No Baseline Profile is shipped, deliberately. Grant's entire startup contribution is one
ContentProvider.onCreate() that registers an activity-lifecycle callback; there is no hot
path for AOT compilation to improve. The permission request path is user-triggered and gated
behind a system dialog, where JIT versus AOT is not measurable. A profile here would be
ceremony, not speed.
23 built-in permissions across Camera, Microphone, Gallery (read and save-only), Storage, Location, Notifications, Bluetooth (combined, or scan-only / connect-only separately), Contacts, Calendar, Motion, Exact Alarms, Nearby Wi-Fi, Local Network, and App Tracking Transparency — anything else via RawPermission.
| Permission | Android | iOS | Notes |
|---|---|---|---|
| Camera | ✅ | ✅ | iOS main-thread safe + deadlock fix |
| Microphone | ✅ | ✅ | Shares the AVFoundation handler with Camera |
| Gallery (full) | ✅ | ✅ | Android 14+ partial access → PARTIAL_GRANTED
|
| Gallery (images only) | ✅ | ✅ | AppGrant.GALLERY_IMAGES_ONLY |
| Gallery (video only) | ✅ | ✅ | AppGrant.GALLERY_VIDEO_ONLY |
| Gallery (save only) | ✅ | ✅ |
AppGrant.GALLERY_ADD_ONLY — no prompt at all on Android 10+; PHAccessLevelAddOnly on iOS |
| Storage (legacy) | ✅ | ✅ | Pre-API 33 fallback |
| Location (when in use) | ✅ | ✅ | GPS service check; "Approximate"-only → PARTIAL_GRANTED
|
| Location (always) | ✅ | ✅ | Android two-step background flow handled |
| Notifications | ✅ | ✅ | Android 13+ and legacy flows |
| Bluetooth | ✅ | ✅ | Service status check + Scan/Connect |
| Bluetooth Advertise | ✅ | ✅ | AppGrant.BLUETOOTH_ADVERTISE |
| Contacts (full) | ✅ | ✅ | Read + write access |
| Contacts (read-only) | ✅ | ✅ | AppGrant.READ_CONTACTS |
| Calendar (full) | ✅ | ✅ | iOS 17+ FullAccess / WriteOnly mapped correctly |
| Calendar (read-only) | ✅ | ✅ | AppGrant.READ_CALENDAR |
| Motion / Activity | ✅ | ✅ | Simulator-aware (safe mock on Simulator) |
| Schedule Exact Alarm | ✅ | ✅ | Android 12+ SCHEDULE_EXACT_ALARM
|
| Nearby Wi-Fi Devices | ✅ | ✅ |
NEARBY_WIFI_DEVICES (API 33+); no-op on iOS |
| Local Network | ✅ | ✅ | Android 17+ ACCESS_LOCAL_NETWORK; no-op below API 37 and on iOS (OS auto-prompts) |
| App Tracking Transparency | ✅ | ✅ |
AppGrant.APP_TRACKING — iOS ATTrackingManager (requires the optional grant-tracking module); Android has no runtime gate for cross-app tracking, so this honestly reports GRANTED rather than prompting |
Service checks (ServiceType)
| Service | Android | iOS |
|---|---|---|
| GPS / Location | ✅ | ✅ |
| Bluetooth | ✅ | ✅ |
| Wi-Fi | ✅ | ✅ |
| NFC | ✅ | — |
| Camera hardware | ✅ | ✅ |
| Health Connect / HealthKit | ✅ | ✅ |
| Guide | Description |
|---|---|
| Quick start | Request your first permission in five minutes |
| Architecture | Concurrency, state machines, and the mutex flow |
| iOS setup |
Info.plist configuration — read before shipping |
| Migration guide | Upgrading to 2.5.0, to 2.4.0 (and from v1.x → 2.x) |
| Service checking | Combining permission and hardware service checks |
| Support policy | Versioning, supported versions, platform support, and what Grant will not do |
| Manual injection | Using Grant without a DI framework |
| Android reliability | How Grant fixes "dead clicks" on Android |
| Best practices | Patterns for production apps |
Contributions are welcome — see CONTRIBUTING.md and the
Code of Conduct. New to the codebase? Start with an issue labeled
good first issue.
Run ./gradlew :grant-core:allTests before submitting a PR.
Apache License 2.0 — see LICENSE.