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)