
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.
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+ · Kotlin 2.x · JVM 17
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-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:
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.brewkits:grant-core:2.3.0")
// Optional
implementation("dev.brewkits:grant-compose:2.3.0") // Compose dialogs (Material 3)
implementation("dev.brewkits:grant-core-koin:2.3.0") // 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:2.3.0") // Contacts (iOS CNContactStore)
implementation("dev.brewkits:grant-calendar:2.3.0") // Calendar (iOS EventKit)
implementation("dev.brewkits:grant-motion:2.3.0") // Motion (iOS CoreMotion)
implementation("dev.brewkits:grant-bluetooth:2.3.0") // Bluetooth (iOS CoreBluetooth)
implementation("dev.brewkits:grant-location-always:2.3.0") // Background "always" location (iOS)
}
}
}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.
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,
eventListener = object : GrantEventListener {
override fun onGranted(grant: GrantPermission, status: GrantStatus) {
analytics.track("camera_permission_granted")
}
}
)
// 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() }
}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 |
19 built-in permissions across Camera, Microphone, Gallery, Storage, Location, Notifications, Bluetooth, Contacts, Calendar, Motion, Exact Alarms, Nearby Wi-Fi, and Local Network — 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 |
| 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) |
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.3.0 (and from v1.x → 2.x) |
| Service checking | Combining permission and hardware service checks |
| 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. Run ./gradlew :grant-core:allTests before submitting a PR.
Apache License 2.0 — see LICENSE.
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+ · Kotlin 2.x · JVM 17
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-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:
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.brewkits:grant-core:2.3.0")
// Optional
implementation("dev.brewkits:grant-compose:2.3.0") // Compose dialogs (Material 3)
implementation("dev.brewkits:grant-core-koin:2.3.0") // 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:2.3.0") // Contacts (iOS CNContactStore)
implementation("dev.brewkits:grant-calendar:2.3.0") // Calendar (iOS EventKit)
implementation("dev.brewkits:grant-motion:2.3.0") // Motion (iOS CoreMotion)
implementation("dev.brewkits:grant-bluetooth:2.3.0") // Bluetooth (iOS CoreBluetooth)
implementation("dev.brewkits:grant-location-always:2.3.0") // Background "always" location (iOS)
}
}
}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.
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,
eventListener = object : GrantEventListener {
override fun onGranted(grant: GrantPermission, status: GrantStatus) {
analytics.track("camera_permission_granted")
}
}
)
// 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() }
}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 |
19 built-in permissions across Camera, Microphone, Gallery, Storage, Location, Notifications, Bluetooth, Contacts, Calendar, Motion, Exact Alarms, Nearby Wi-Fi, and Local Network — 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 |
| 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) |
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.3.0 (and from v1.x → 2.x) |
| Service checking | Combining permission and hardware service checks |
| 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. Run ./gradlew :grant-core:allTests before submitting a PR.
Apache License 2.0 — see LICENSE.