Skip to content

Latest commit

 

History

218 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Grant logo

Grant

Type-safe permission management for Kotlin Multiplatform — Android, iOS & Browser

Maven Central CI CodeQL Kotlin Platforms License

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

Features

  • Logic-first API — works in ViewModels, repositories, or composables. No Activity or Fragment references, no lifecycle binding.
  • Adds no permissions to your appgrant-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.
  • iOS framework isolation — Contacts, Calendar, Motion, Bluetooth, and background location live in opt-in modules. Frameworks you don't use are never linked, so Apple's static scanner never demands phantom NSUsageDescription keys.
  • iOS crash guard — validates Info.plist keys before requesting, turning the classic SIGABRT production crash into a clear error.
  • Android process-death recovery — a request in flight survives system-initiated process death via SavedStateHandle, with no timeouts.
  • Deadlock-free by construction — reentrant locking plus a withTimeout test policy that converts silent deadlocks into failing tests.
  • 24 built-in permissions — Camera, Gallery (incl. Android 14 partial access and a save-only mode that never prompts), Location (incl. "Approximate"-only), Bluetooth, Local Network (Android 17), App Tracking Transparency (iOS), Full-Screen Intent (Android 14+), and more — plus RawPermission for anything the library doesn't ship yet.
  • Browser target (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.
  • Permission groups as one unitGrantGroupHandler requests several permissions in a single batch, drives one StateFlow for the whole group, and fires onAllGranted only when every one is satisfied.
  • Funnel analytics — attach an optional GrantEventListener to any handler and observe every stage: requested, granted, denied, rationale shown, settings guide shown, settings opened.
  • Service-state checks — one call answers both "is the permission granted?" and "is GPS/Bluetooth actually on?".
  • Material 3 dialogs — optional grant-compose module renders the rationale and settings-guide flow out of the box.
  • Heavily tested — 2,100+ test executions across Android and iOS targets on every build, including concurrency-stress, regression, and process-death suites.

Installation

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-compose no longer ships an iosX64 target. Compose Multiplatform 1.11 stopped publishing iosX64 artifacts. All other modules keep iosX64; Intel-Mac-simulator consumers of grant-compose should 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 a mobileMain source set — see Dependency Management.

Versioning with the BOM

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).

Testing

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.

Usage

Request a permission

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() }
}

Request several permissions as one unit

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.

Track the permission funnel

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())
        }
    },
)

Chain sequential permissions

val scanFlow = grantFlow {
    val btStatus = bluetoothHandler.requestSuspend()
    if (btStatus == GrantStatus.GRANTED) {
        locationHandler.requestSuspend()   // needed for BLE scanning on some Android versions
    }
}

Check permission and hardware together

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()
        }
    }
}

Catch grants changed from Settings (iOS)

A user can leave your app running in the background, flip a permission in Settings, and come back — a GrantHandler constructed before that trip goes stale with no signal to catch it. autoRefreshOnForeground() subscribes to the real foreground signal where one exists (iOS's UIApplicationDidBecomeActiveNotification) and calls refreshStatus() for you on every transition:

class CameraViewModel(grantManager: GrantManager, scope: CoroutineScope) {
    val cameraGrant = GrantHandler(grantManager, AppGrant.CAMERA, scope)
    private val foregroundHandle = cameraGrant.autoRefreshOnForeground()

    override fun onCleared() {
        foregroundHandle.close() // also closes automatically when `scope`'s Job completes
    }
}

Android, jvm, and browser have no equivalent OS-wide signal today, so the call is a documented no-op there (logged once, not silently swallowed) — call refreshStatus() or onReturnFromSettings() yourself from whatever lifecycle hook your platform offers instead.

Why Grant?

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

Engineering rigor

Every claim below is enforced by CI or was checked by hand on real hardware — nothing here is aspirational.

  • Every pull request is gated, not just merges to main — build, full test suite, Android Lint, and the API-surface check all run before a PR can land.
  • CodeQL runs on every push and currently reports zero open alerts — every finding was triaged by hand, not just silenced.
  • The public API surface is locked. All 10 Kotlin modules use Kotlin's explicit-API mode plus a committed ABI dump (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.)
  • Every published Kotlin module ships a CycloneDX SBOM — answer "what's inside this dependency" without unpacking it.
  • Verified on real hardware, not just a simulator. The Android 17 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.
  • The browser target runs against real Chrome, not a fake: 358/358 tests pass in headless Chrome for both the js and wasmJs targets, including the Firefox permissions.query() fallback path.

Size and supply chain

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.

Supported permissions

24 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, Full-Screen Intent, Nearby Wi-Fi, Local Network, and App Tracking Transparency — anything else via RawPermission.

Full permission matrix
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) AppGrant.STORAGE — legacy alias for Gallery (full); same permissions, same PARTIAL_GRANTED behavior on both platforms
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
Full-Screen Intent AppGrant.USE_FULL_SCREEN_INTENT — Android 14+ special access, same shape as Exact Alarm; no-op on iOS
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

Documentation

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

Contributing

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.

License

Apache License 2.0 — see LICENSE.

Built by BrewKits

Releases

Used by

Contributors

Languages