Releases: brewkits/Grant
Release list
v2.3.0 — Kotlin 2.4 toolchain · Android 17 LOCAL_NETWORK · location/gallery status fixes
⚠️ Breaking (grant-compose only)
grant-compose no longer publishes the iosX64 target. Compose Multiplatform 1.11 stopped publishing iosX64 artifacts. Every other module (grant-core, grant-core-koin, grant-contacts, grant-calendar, grant-motion, grant-bluetooth, grant-location-always) keeps iosX64. Apps needing grant-compose on an Intel-Mac simulator should stay on 2.2.3.
Added
AppGrant.LOCAL_NETWORK— Android 17 (API 37) introduced theACCESS_LOCAL_NETWORKruntime permission (NEARBY_DEVICES group; enforcement mandatory for apps targeting 37) for talking to LAN devices. Below API 37 and on iOS the grant is a no-op (GRANTED); on iOS the OS prompts automatically on first LAN access whenNSLocalNetworkUsageDescriptionis present. Device-verified end-to-end on a Pixel 6 Pro running Android 17.- Android 17 Contact Picker guidance — KDoc on
CONTACTS/READ_CONTACTSnow points read-only "pick a few contacts" flows to the permission-free Contact Picker.
Fixed
- Approximate-only location misreported as denied. A user picking "Approximate" in the OS dialog grants only COARSE; that usable state used to fail the all-granted check and escalate to
DENIED_ALWAYS. Coarse-only now reportsPARTIAL_GRANTED. requestSuspend()suspended forever when no dialog host was attached. A headless request parked on rationale/settings dialog state with no collector now clears the unrenderable state and completes with the current status.- Android 14+: fully-granted gallery misreported as
DENIED_ALWAYSwhenREAD_MEDIA_VISUAL_USER_SELECTEDstayed denied (ADB grant, MDM policy, auto-reset edge states). Full access is now judged on the required permissions only. - Demo: Scenario 3 "Test Grant Denial Flow" was mis-wired to the gallery request — the denial → rationale → settings walkthrough never ran.
Toolchain
| Component | From | To |
|---|---|---|
| Kotlin | 2.1.0 | 2.4.0 |
| Compose Multiplatform | 1.9.3 | 1.11.1 |
| kotlinx-coroutines | 1.10.2 | 1.11.0 |
| Kover | 0.7.5 | 0.9.8 |
| atomicfu | 0.27.0 | 0.33.0 |
Verification
./gradlew build(all 9 modules, Android + iOS): 2,174 tests executed, 0 failures- Kover line coverage 86.9% against the enforced 85% floor
- LOCAL_NETWORK + denial flows device-verified on Pixel 6 Pro (Android 17)
Full changelog: CHANGELOG.md · Migration guide
v2.2.3
🐛 Fixed — Settings guide shown as rationale again after a permanent denial, in the same session (#55 follow-up)
On Android, once a permission was permanently denied, dismissing the settings guide and tapping request again re-showed the rationale dialog instead of the settings guide — but only within the same app session. After a restart it behaved correctly, which is what made it confusing to track down.
The cause: checkStatus() short-circuited on the in-memory status cache before consulting the OS-persisted shouldShowRequestPermissionRationale() flag. The first denial cached DENIED, so the second (permanent) denial was masked by that stale value and never became DENIED_ALWAYS until the cache was cleared on process death. The OS rationale flag is now consulted first — matching the RawPermission and LOCATION_ALWAYS branches — so the DENIED → DENIED_ALWAYS transition is detected immediately, no restart needed.
Verified end-to-end on a physical Pixel 6 Pro (Android 16): deny → rationale → deny → settings guide → dismiss → request → settings guide (no rationale loop). iOS (iPhone XS Max, iOS 18.7.9) is unaffected by design — it reads the live OS status and has no rationale step, so denial routes straight to the settings guide on every request.
📝 Notes
- Pure correctness fix — no public API changes, no behavior changes for apps that don't hit this exact deny → deny → dismiss → request sequence.
- Android-only. iOS,
RawPermission,LOCATION_ALWAYS, and the special-access permissions were already correct and are untouched.
Full diff: v2.2.2...v2.2.3
v2.2.2
🐛 Fixed — Android permission request swallowed after app restart (#55)
After denying a permission and restarting the app, the first request tap could silently do nothing instead of showing the rationale (soft denial) or the settings guide (permanent denial). Two root causes were fixed:
checkStatus()now consults the OS-persistedshouldShowRequestPermissionRationale()flag before the in-memory request history, so a soft denial is recovered after a restart.- The request history itself is now persisted on Android via the new
SharedPreferencesGrantStore(filegrant_request_history), so a permanent denial is recovered after a restart and correctly reported asDENIED_ALWAYS(routing to Settings) instead ofNOT_DETERMINED.
Verified on a physical Pixel 6 Pro (Android 16) for both soft and permanent denial after kill + restart.
⚠️ Behavior change (Android)
SharedPreferencesGrantStore is now the default GrantStore on Android (iOS remains InMemoryGrantStore). It persists only the immutable "has been requested" fact — never the permission status, which is always re-read from the OS — so there is no state desync. The backing file is excluded from backup and device-to-device transfer, so fresh installs start clean.
Opt back into the old in-memory behavior with:
GrantFactory.create(context, store = InMemoryGrantStore())📖 Docs
- New recipe: Skip the Prompt When Already Granted — observe
GrantHandler.statusto go straight to the feature without re-prompting.
Full Changelog: v2.2.1...v2.2.2
v2.2.1 — Android Permission Dialog Hotfix (#53)
Grant v2.2.1
Patch release fixing #53 — on Android the system permission dialog never opened, while iOS worked fine. Affected v2.1.0 and v2.2.0.
Root cause
Since v1.4.3, the Android request() path required a GrantLauncher registered via setLauncher(). When no launcher was registered, the request silently returned DENIED without ever showing the system dialog. This also contradicted the library's "No Lifecycle Binding" promise — setLauncher() was never documented in the README.
Fix
- When no launcher is registered,
request()now falls back to the library's self-contained transparentGrantRequestActivity(already shipped ingrant-core's manifest), so the dialog opens from any context (ViewModel, Repository, etc.) with zero lifecycle binding. setLauncher()remains an optional optimization for apps that prefer driving the dialog through their ownActivityResultLauncher.- Aligned the multi-request timeout with the single-request path (5 min, was 60s).
- Documented Android behavior +
setLauncher()in the README. - New regression test:
Issue53NoLauncherFallbackTest.
See #54 for the full fix.
Upgrading
No code changes required — just bump the version:
implementation("dev.brewkits:grant-core:2.2.1")v2.2.0
Grant v2.2.0
Resolves #45 — Apple's App Store static scanner no longer demands NSBluetoothAlwaysUsageDescription / NSLocationAlwaysAndWhenInUseUsageDescription for apps that don't use those capabilities.
New opt-in modules (iOS framework / selector isolation)
dev.brewkits:grant-bluetooth— isolatesCoreBluetooth.framework. Apps that don't use Bluetooth no longer link it.dev.brewkits:grant-location-always— isolates therequestAlwaysAuthorization(background location) path.grant-corenow calls onlyrequestWhenInUseAuthorization; foreground-only apps are no longer flagged for the always-location key.
This extends the v2.1.0 pattern (Contacts/Calendar/Motion). An obfuscation workaround was explicitly rejected as App Store Review Guideline 2.3.1 circumvention — module isolation is the sanctioned fix.
Other
- Maven bundle script is now module-array driven with per-module coverage checks.
- CI builds/tests/lints/covers the two new modules; release attaches all eight module JARs.
- Full test suites (unit/integration/system/performance/stress/security/regression + native
iosTestdeadlock guards) for both new modules. - Verified on real Pixel 6 Pro, iPhone 16 simulator, and iPhone 6s Plus.
Upgrading
Add the new modules only if you need them:
implementation("dev.brewkits:grant-bluetooth:2.2.0") // optional
implementation("dev.brewkits:grant-location-always:2.2.0") // optionalv2.1.0 - Analytics & i18n Support
✨ What's New in v2.1.0
⚠️ Breaking Changes (Compose UI layer only)
-
GrantDialog/GrantGroupDialog/GrantAndServiceDialog— string params replaced byGrantDialogStrings
The individualStringparameters on each dialog composable have been replaced by a singlestrings: GrantDialogStringsparameter. -
IosPermissionHandlerrenamed toPermissionHandler(iOS custom handler authors only)
The interface used to implement customRawPermissionhandlers on iOS has been renamed fromIosPermissionHandlertoPermissionHandler.
✨ New: GrantEventListener — Permission Funnel Analytics
Attach an optional listener to any handler to observe the full permission flow lifecycle (onRequested, onGranted, onDenied, onRationaleShown, onSettingsGuideShown, onSettingsOpened).
✨ New: GrantDialogStrings — i18n via CompositionLocal
Replace per-callsite string overrides with a single app-level provider using GrantDialogStringsProvider.
🐛 Bug Fixes
- Issue #41 — Double-denial dead-end (Android): Escalates to settings guide when the OS returns
DENIEDafter a rationale has already been shown. - Issue #33 —
LOCATION_ALWAYSPARTIAL stuck: Fixed false positiveonGrantedwhen background location was denied but foreground was granted. - iOS — requestWithCustomUi missing events: Custom-UI flow now correctly emits all
GrantEventListenerevents. - iOS — Group Handler Rationale: Fixed inconsistent rationale display for group requests on iOS.
- iOS — Avoid double location dialogs: Removed spurious
requestWhenInUseAuthorization()beforerequestAlwaysAuthorization()to prevent UI flicker.
♻️ Internal Refactoring
- Unified state machine: Merged
handleStatus()andhandleStatusWithCustomUi()into a single logic path. - Test Integrity: Refactored unit tests to remove deceptive/meaningless checks and added robust assertions.
✅ Test Suite
v2.0.0 — iOS Framework Isolation: Contacts, Calendar & Motion
What's New
v2.0.0 is a major architectural release that permanently resolves App Store ITMS-90683 rejections caused by Grant linking Contacts.framework, EventKit.framework, and CoreMotion.framework into every consumer binary — even for apps that never request those permissions.
The Problem This Fixes
Previously, adding grant-core as a dependency caused Apple's static analyzer to require NSContactsUsageDescription, NSCalendarsUsageDescription, and NSMotionUsageDescription in Info.plist — even for apps that only use LOCATION, BLUETOOTH, or NOTIFICATION.
The root cause: Kotlin/Native's ObjC interop toolchain registers framework names as metadata in the .klib at compile time, before DCE or the linker runs. Apple's static analyzer reads this compile-time metadata directly. No amount of -weak_framework flags, by lazy initialization, or handler-pattern refactoring could fix this — the framework name string was baked into grant-core.klib the moment the source file was compiled.
The only complete fix is at the dependency graph level: move the source file into a separate artifact that consumers simply don't add unless they need it.
Breaking Changes — iOS Only
Apps using AppGrant.CONTACTS, AppGrant.READ_CONTACTS, AppGrant.CALENDAR, AppGrant.READ_CALENDAR, or AppGrant.MOTION on iOS must:
1. Add the corresponding optional module:
// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:grant-core:2.0.0")
// Add only what your app uses:
implementation("dev.brewkits:grant-contacts:2.0.0")
implementation("dev.brewkits:grant-calendar:2.0.0")
implementation("dev.brewkits:grant-motion:2.0.0")
}2. Call initialize() once on iOS app start:
// iosMain — call once, e.g. in ApplicationDelegate
GrantContacts.initialize()
GrantCalendar.initialize()
GrantMotion.initialize()Android: zero changes required.
New Modules
| Artifact | Isolates | iOS framework |
|---|---|---|
dev.brewkits:grant-contacts:2.0.0 |
AppGrant.CONTACTS, AppGrant.READ_CONTACTS |
Contacts.framework |
dev.brewkits:grant-calendar:2.0.0 |
AppGrant.CALENDAR, AppGrant.READ_CALENDAR |
EventKit.framework |
dev.brewkits:grant-motion:2.0.0 |
AppGrant.MOTION |
CoreMotion.framework |
Apps that don't add an optional module never link the corresponding framework. Apple's scanner finds no metadata and does not require the NSUsageDescription key. No workaround keys in Info.plist needed.
Additional Fixes
checkStatus()forRawPermissionnow correctly dispatches throughIosPermissionHandlerRegistry(previously onlyrequestInternal()did — custom handlers were invisible to status checks)mainContinuationmade public so optional modules in separate Gradle projects can import the iOS threading utilityMyGrantManagerdeprecation message updated to reflect v2.0.0 rename
Test Suite
1131 tests, 100% pass rate — Android JVM (Robolectric) + iOS Simulator arm64:
| Module | Android | iOS | Total |
|---|---|---|---|
grant-core |
416 | 379 | 795 |
grant-contacts |
54 | 54 | 108 |
grant-calendar |
60 | 60 | 120 |
grant-motion |
52 | 52 | 104 |
grant-compose |
1 | 1 | 2 |
grant-core-koin |
1 | 1 | 2 |
| Total | 584 | 547 | 1131 |
Each new module includes unit, integration, system, performance (1 000 sequential + parallel requests), stress (300 concurrent), regression, and security tests.
Documentation
docs/ios/APPLE_FRAMEWORK_LINKING_ISSUE.md— completely rewritten to explain the three build-stage layers (compile-time ObjC interop metadata, DCE, linker flags) and why only a dependency graph boundary provides a complete fixdocs/MIGRATION_GUIDE.md— v2.0.0 migration sectionROADMAP.md— v2.1.0 planning: opt-in Builder DSL for granular DCE across all permissions (design credit: @RoryKelly, PR #39)
What's Next — v2.1.0
The opt-in GrantFactory.create { location(); bluetooth() } Builder DSL from PR #39 will be incorporated in v2.1.0. This will allow DCE to strip any unused handler (including Camera, Location, Bluetooth) for apps that want to minimize binary size beyond what module isolation alone provides.
Full Changelog: v1.4.2...v2.0.0
v1.4.2 — Issue #33 Root Cause Fixed
What's Fixed
This release resolves the root cause of issue #33 — LOCATION_ALWAYS requests silently timing out or returning DENIED on Android. The v1.4.1 release addressed symptoms but the underlying wiring was still broken in production.
🔴 Critical Bug Fixes
1. AndroidGrantLauncher.launch() — Callback Never Invoked
Impact: Every request() call on Android (Camera, Microphone, Location, etc.) would hang for the full 5-minute timeout and return DENIED.
Root cause: AndroidGrantLauncher.from() registered a fixed callback at Activity creation time. The per-call onResult passed to launch() was discarded. Since PlatformGrantDelegate uses the per-call onResult to call deferred.complete(), the coroutine suspended forever.
// Before (broken):
override fun launch(permissions: List<String>, onResult: (Map<String, Boolean>) -> Unit) {
launcher.launch(permissions.toTypedArray())
// onResult is ignored → deferred.complete() never called → 5-min timeout
}
// After (fixed):
override fun launch(permissions: List<String>, onResult: (Map<String, Boolean>) -> Unit) {
pendingCallback = onResult // stored per-call
launcher.launch(permissions.toTypedArray())
// registered callback invokes pendingCallback → deferred.complete() called correctly
}2. BindGrantsController — No-Op on Android (Launcher Never Wired)
Impact: PlatformGrantDelegate.setLauncher() was never called. The launcher == null guard returned DENIED immediately for every request() call.
Fix: BindGrantsController now uses rememberLauncherForActivityResult + SideEffect to wire the launcher into GrantManager on every composition, including after Activity recreation.
3. ReentrantMutex — Context Key Collision on iOS
Impact: Concurrent batch request() calls on iOS could silently deadlock.
Fix: Each ReentrantMutex instance now creates its own unique context key.
✅ Why Tests Were Passing (But Production Was Broken)
All Android unit tests used mock GrantLauncher implementations that correctly called onResult. The production AndroidGrantLauncher did not. This masked both bugs through v1.4.1 — tests green, production broken.
✅ Verified on Device
Full LOCATION_ALWAYS end-to-end flow on Pixel 6 Pro (Android 16):
| Step | Action | Result |
|---|---|---|
| 1 | Tap Request for LOCATION_ALWAYS (NOT_DETERMINED) |
System foreground dialog appears |
| 2 | Grant "While using the app" | Library detects PARTIAL_GRANTED, auto-triggers step 2 |
| 3 | GrantRequestActivity launches background step |
Android Location Settings opens |
| 4 | Select "Allow all the time" → Back | App receives result |
| 5 | Final status | LOCATION_ALWAYS = GRANTED ✓ |
No timeout. No crash. No DENIED short-circuit.
🏗️ New APIs
GrantLauncher interface
interface GrantLauncher {
fun launch(permissions: List<String>, onResult: (Map<String, Boolean>) -> Unit)
}AndroidGrantLauncher
// In Activity.onCreate() — for non-Compose integration:
val launcher = AndroidGrantLauncher.from(this)
grantManager.setLauncher(launcher)
// In Compose — BindGrantsController() handles this automatically📦 Dependency
// grant-core
implementation("dev.brewkits:grant-core:1.4.2")
// grant-compose (optional)
implementation("dev.brewkits:grant-compose:1.4.2")Upgrading from 1.4.1
Compose users: No changes needed. BindGrantsController() now does real work on Android — make sure it is called in your root composable.
Non-Compose users: Call grantManager.setLauncher(AndroidGrantLauncher.from(this)) in Activity.onCreate().
AndroidGrantLauncher.from() signature change: The onResult parameter has been removed (it was non-functional in 1.4.1). Update call sites:
// Before (1.4.1 — broken, onResult was never called):
AndroidGrantLauncher.from(activity) { results -> ... }
// After (1.4.2):
AndroidGrantLauncher.from(activity)v1.4.1 - Hot fix for LOCATION_ALWAYS
🐛 Bug Fixes
- LOCATION_ALWAYS Flow: Fixed a regression introduced in 1.4.0 where a duplicate background location request caused a 60-second timeout if the app already possessed foreground location permissions (Issue #33).
🧪 Tests
- Added Robolectric regression test
Issue33HotfixDuplicateRequestTest(#36) that pins the v1.4.1 guard. MocksGrantRequestActivity.requestGrantsto count invocations; asserts exactly one call when the request starts fromPARTIAL_GRANTED. Counter-test verifies the legitimate 2-step flow fromNOT_DETERMINEDis not over-restricted. Test was verified locally by temporarily reverting the guard — it correctly fails withGot 2 invocations.
Note: The test was added post-release in #36 (merge commit 8f27527). The v1.4.1 git tag still points to f3a41aa for stability; the published Maven Central artifact is unchanged (tests are not shipped). Future releases will include the regression coverage from
main.
v1.4.0
🚀 Enterprise Hardening & Extensibility
- Process Death Recovery:
GrantRequestActivitynow integratesSavedStateHandleto preserve active permission requests if the Android OS kills the app in the background. - Activity Launch Guard: Prevents multiple overlapping
GrantRequestActivityinstances during rapid concurrent calls. - Robust Locking Strategy: Introduced a custom
ReentrantMutexacross iOS and Android delegates to resolve edge-case deadlocks globally. - IosPermissionHandlerRegistry: Developers can now register custom implementations for
RawPermissionon iOS, eliminating "Not Implemented" limitations.
✨ New APIs & Features
- Core APIs: Introduced
requestSuspend()andrequestFlow()to provide non-callback alternatives perfectly tailored for modern Kotlin Coroutines. - GrantFlow DSL: A new builder DSL (
grantFlow { ... }) allows you to fluently orchestrate complex, multi-step sequential permission flows. - NEARBY_WIFI_DEVICES: Full support for Android 13+ nearby Wi-Fi hardware permissions.
- iOS Location Precision: Added support for
NSLocationTemporaryFullAccuracyUsageDescriptionKeyviaLocationTemporaryFullAccuracyHandler. - Material 3 Dialogs: Upgraded
GrantRationaleDialogandGrantSettingsDialogingrant-composeto use the new Compose MultiplatformBasicAlertDialog, adhering strictly to Material 3 design guidelines.
🛠️ Performance & Quality
- Parallel Status Checks:
GrantAndServiceCheckerand handlers now check OS permissions and hardware services concurrently usingasync/awaitAll, minimizing UI latency. - SPM Support: Added a
Package.swiftconfiguration to support distributing the library via Swift Package Manager. - Expanded Documentation: New recipes added for Android 16 Photo Picker,
UsbManager, and iOS 18 Limited Contacts (CNContactPickerViewController). - Testing: Raised Kover minimum coverage threshold to 85% and added new Robolectric suites for Android 10
LOCATION_ALWAYS2-step flows.