Skip to content

Releases: brewkits/Grant

v2.3.0 — Kotlin 2.4 toolchain · Android 17 LOCAL_NETWORK · location/gallery status fixes

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 10 Jul 04:49
78fe9fc

⚠️ 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 the ACCESS_LOCAL_NETWORK runtime 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 when NSLocalNetworkUsageDescription is present. Device-verified end-to-end on a Pixel 6 Pro running Android 17.
  • Android 17 Contact Picker guidance — KDoc on CONTACTS/READ_CONTACTS now 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 reports PARTIAL_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_ALWAYS when READ_MEDIA_VISUAL_USER_SELECTED stayed 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

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 30 Jun 10:14
369ac92

🐛 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

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 26 Jun 18:38

🐛 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-persisted shouldShowRequestPermissionRationale() 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 (file grant_request_history), so a permanent denial is recovered after a restart and correctly reported as DENIED_ALWAYS (routing to Settings) instead of NOT_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())

See GrantStore Architecture.

📖 Docs


Full Changelog: v2.2.1...v2.2.2

v2.2.1 — Android Permission Dialog Hotfix (#53)

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 23 Jun 09:44

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 transparent GrantRequestActivity (already shipped in grant-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 own ActivityResultLauncher.
  • 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

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 08 Jun 03:33
9e23f34

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 — isolates CoreBluetooth.framework. Apps that don't use Bluetooth no longer link it.
  • dev.brewkits:grant-location-always — isolates the requestAlwaysAuthorization (background location) path. grant-core now calls only requestWhenInUseAuthorization; 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 iosTest deadlock 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")  // optional

v2.1.0 - Analytics & i18n Support

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 03 Jun 18:42

✨ What's New in v2.1.0

⚠️ Breaking Changes (Compose UI layer only)

  • GrantDialog / GrantGroupDialog / GrantAndServiceDialog — string params replaced by GrantDialogStrings
    The individual String parameters on each dialog composable have been replaced by a single strings: GrantDialogStrings parameter.

  • IosPermissionHandler renamed to PermissionHandler (iOS custom handler authors only)
    The interface used to implement custom RawPermission handlers on iOS has been renamed from IosPermissionHandler to PermissionHandler.

✨ 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 DENIED after a rationale has already been shown.
  • Issue #33LOCATION_ALWAYS PARTIAL stuck: Fixed false positive onGranted when background location was denied but foreground was granted.
  • iOS — requestWithCustomUi missing events: Custom-UI flow now correctly emits all GrantEventListener events.
  • iOS — Group Handler Rationale: Fixed inconsistent rationale display for group requests on iOS.
  • iOS — Avoid double location dialogs: Removed spurious requestWhenInUseAuthorization() before requestAlwaysAuthorization() to prevent UI flicker.

♻️ Internal Refactoring

  • Unified state machine: Merged handleStatus() and handleStatusWithCustomUi() into a single logic path.
  • Test Integrity: Refactored unit tests to remove deceptive/meaningless checks and added robust assertions.

✅ Test Suite

  • Reached 610 Android / 578 iOS tests, 0 failures across all 6 modules.
  • New regression tests for Issue #41 and #33.

v2.0.0 — iOS Framework Isolation: Contacts, Calendar & Motion

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 14 May 23:36
e3dbdbc

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() for RawPermission now correctly dispatches through IosPermissionHandlerRegistry (previously only requestInternal() did — custom handlers were invisible to status checks)
  • mainContinuation made public so optional modules in separate Gradle projects can import the iOS threading utility
  • MyGrantManager deprecation 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


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

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 13 May 18:30

What's Fixed

This release resolves the root cause of issue #33LOCATION_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

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 12 May 02:40

🐛 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. Mocks GrantRequestActivity.requestGrants to count invocations; asserts exactly one call when the request starts from PARTIAL_GRANTED. Counter-test verifies the legitimate 2-step flow from NOT_DETERMINED is not over-restricted. Test was verified locally by temporarily reverting the guard — it correctly fails with Got 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

Choose a tag to compare

@vietnguyentuan2019 vietnguyentuan2019 released this 11 May 16:55
8f07ef3

🚀 Enterprise Hardening & Extensibility

  • Process Death Recovery: GrantRequestActivity now integrates SavedStateHandle to preserve active permission requests if the Android OS kills the app in the background.
  • Activity Launch Guard: Prevents multiple overlapping GrantRequestActivity instances during rapid concurrent calls.
  • Robust Locking Strategy: Introduced a custom ReentrantMutex across iOS and Android delegates to resolve edge-case deadlocks globally.
  • IosPermissionHandlerRegistry: Developers can now register custom implementations for RawPermission on iOS, eliminating "Not Implemented" limitations.

✨ New APIs & Features

  • Core APIs: Introduced requestSuspend() and requestFlow() 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 NSLocationTemporaryFullAccuracyUsageDescriptionKey via LocationTemporaryFullAccuracyHandler.
  • Material 3 Dialogs: Upgraded GrantRationaleDialog and GrantSettingsDialog in grant-compose to use the new Compose Multiplatform BasicAlertDialog, adhering strictly to Material 3 design guidelines.

🛠️ Performance & Quality

  • Parallel Status Checks: GrantAndServiceChecker and handlers now check OS permissions and hardware services concurrently using async/awaitAll, minimizing UI latency.
  • SPM Support: Added a Package.swift configuration 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_ALWAYS 2-step flows.