███████╗ █████╗ ███████╗██╗ ██╗
██╔════╝██╔══██╗██╔════╝╚██╗ ██╔╝
█████╗ ███████║███████╗ ╚████╔╝
██╔══╝ ██╔══██║╚════██║ ╚██╔╝
███████╗██║ ██║███████║ ██║
╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝
██████╗ ███████╗██████╗ ███╗ ███╗██╗███████╗███████╗██╗ ██████╗ ███╗ ██╗███████╗
██╔══██╗██╔════╝██╔══██╗████╗ ████║██║██╔════╝██╔════╝██║██╔═══██╗████╗ ██║██╔════╝
██████╔╝█████╗ ██████╔╝██╔████╔██║██║███████╗███████╗██║██║ ██║██╔██╗ ██║███████╗
██╔═══╝ ██╔══╝ ██╔══██╗██║╚██╔╝██║██║╚════██║╚════██║██║██║ ██║██║╚██╗██║╚════██║
██║ ███████╗██║ ██║██║ ╚═╝ ██║██║███████║███████║██║╚██████╔╝██║ ╚████║███████║
╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝
K T X
EasyPermissions-KTX is a Kotlin-first Android permission library built on top of AndroidX
ActivityResultContracts. It covers every permission scenario — single, multiple, background
location, photo picker — through four cohesive APIs: Callback DSL, Suspend, Flow, and Compose.
| Feature | Details | |
|---|---|---|
| 🎯 | Callback / DSL API | Fluent .permissions().onGranted{}.check() builder |
| ⚡ | Suspend API | val result = requestPermission(CAMERA) — awaitable in coroutines |
| 🌊 | Flow API | Cold Flow<PermissionResult> per permission |
| 🎨 | Compose API | rememberPermissionState() / rememberMultiplePermissionsState() |
| 📍 | Background Location | Automatic two-step flow (foreground → background) for Android 10+ |
| 🖼️ | Photo Picker | Wraps PickVisualMedia with graceful fallback to ACTION_GET_CONTENT |
| 🔒 | Permanent Denial | Tracks "Don't ask again" via SharedPreferences — not just shouldShowRationale |
| ♻️ | Lifecycle-aware | Compose state auto-refreshes when user returns from system Settings |
| 🏗️ | No boilerplate | Invisible Fragment handles all ActivityResultLauncher registrations |
| 🧪 | Tested | Unit tests (Robolectric) + Instrumented tests for all core logic |
Step 1 — Add to settings.gradle.kts:
include(":easypermissions-ktx")Step 2 — Add to your module's build.gradle.kts:
dependencies {
// EasyPermissions-KTX v1.0.0
implementation(project(":easypermissions-ktx"))
}Step 3 — Your activity must extend FragmentActivity (or its subclass AppCompatActivity):
// ✅ Correct
class MainActivity : FragmentActivity() { ... }
class MainActivity : AppCompatActivity() { ... } // AppCompatActivity extends FragmentActivity
// ❌ Won't work — ComponentActivity is not supported
class MainActivity : ComponentActivity() { ... }JitPack / Maven Central publishing coming soon. Tag
v1.0.0on GitHub to trigger a JitPack build.
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />The library does not declare permissions automatically — you control your manifest.
The classic builder pattern. Works from FragmentActivity or Fragment.
// Single permission
EasyPermissions.with(this)
.permissions(Manifest.permission.CAMERA)
.rationale("Camera is needed to take photos")
.onGranted { startCamera() }
.onDenied { showRationaleDialog() }
.onPermanentlyDenied { EasyPermissions.openAppSettings(this) }
.check()
// Multiple permissions at once
EasyPermissions.with(this)
.permissions(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
.onGranted { startVideoCall() }
.onResult { result ->
if (result.anyPermanentlyDenied) {
EasyPermissions.openAppSettings(this)
}
}
.check()Best for sequential flows inside a coroutineScope or lifecycleScope.
// Single permission
lifecycleScope.launch {
when (val result = requestPermission(Manifest.permission.CAMERA)) {
is PermissionResult.Granted -> startCamera()
is PermissionResult.Denied -> showRationale(result.shouldShowRationale)
is PermissionResult.PermanentlyDenied -> EasyPermissions.openAppSettings(this@Activity)
}
}
// Multiple permissions
lifecycleScope.launch {
val result = requestPermissions(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
when {
result.allGranted -> startRecording()
result.anyPermanentlyDenied -> EasyPermissions.openAppSettings(this@Activity)
result.anyDenied -> showPartialDenialMessage(result.deniedPermissions)
}
}Useful when you want to react to permissions as a stream or chain them with other flows.
lifecycleScope.launch {
// Single
permissionFlow(Manifest.permission.CAMERA)
.collect { result ->
when (result) {
is PermissionResult.Granted -> startCamera()
is PermissionResult.Denied -> showRationale()
is PermissionResult.PermanentlyDenied -> openSettings()
}
}
// Multiple
permissionsFlow(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
).collect { result ->
if (result.allGranted) startRecording()
}
}Reactive permission state that integrates naturally with Compose's recomposition model.
Status is automatically refreshed on every ON_RESUME event (e.g. user returning from Settings).
@Composable
fun CameraScreen() {
val camera = rememberPermissionState(
permission = Manifest.permission.CAMERA,
onPermissionResult = { granted -> analytics.log("camera_result", granted) }
)
when (val status = camera.status) {
is PermissionStatus.Granted -> {
CameraPreview()
}
is PermissionStatus.Denied -> {
if (status.shouldShowRationale) {
RationaleCard(
message = "Camera lets you take photos right in the app.",
onConfirm = { camera.launchPermissionRequest() }
)
} else {
PermissionRequestButton(
text = "Enable Camera",
onClick = { camera.launchPermissionRequest() }
)
}
}
}
}@Composable
fun RecordScreen() {
val state = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
if (state.allGranted) {
RecordingContent()
} else {
Column {
// Show individual status per permission
state.permissionStates.forEach { perm ->
PermissionRow(
name = perm.permission.substringAfterLast('.'),
status = perm.status
)
}
if (state.shouldShowRationale) {
Text("Camera and microphone are required to record video.")
}
Button(onClick = { state.launchMultiplePermissionRequest() }) {
Text("Grant Permissions")
}
}
}
}Android 10+ requires a mandatory two-step flow: foreground location → background location.
BackgroundLocationManager handles the sequencing automatically, including the pre-Q fallback.
BackgroundLocationManager.with(this)
.foregroundPermission(Manifest.permission.ACCESS_FINE_LOCATION) // default
.onForegroundGranted { showStatus("Foreground granted — requesting background…") }
.onFullyGranted { startGeofenceTracking() }
.onForegroundDenied { showError("Foreground location denied") }
.onBackgroundDenied { showError("Background location denied — go to Settings") }
.onPermanentlyDenied { EasyPermissions.openAppSettings(this) }
.onResult { result -> logEvent(result) }
.request()Android 9 and below: background location is not a separate concept.
onFullyGrantedfires as soon as foreground location is granted.
Wraps the modern Android Photo Picker.
Requires no permission on Android 13+; falls back to ACTION_GET_CONTENT automatically.
val picker = PhotoPickerManager.with(this)
// Single picks
picker.pickSingleImage { uri -> uri?.let { display(it) } }
picker.pickSingleVideo { uri -> uri?.let { play(it) } }
picker.pickSingleMedia { uri -> uri?.let { load(it) } }
// Multiple picks
picker.pickMultipleImages { uris -> showGrid(uris) }
picker.pickMultipleVideos { uris -> buildPlaylist(uris) }
picker.pickMultipleMedia { uris -> buildGallery(uris) }
// Custom media type
picker.pickSingle(PickVisualMedia.SingleMimeType("image/gif")) { uri -> ... }
// Check availability (informational — fallback is automatic)
if (picker.isPhotoPickerAvailable()) { /* modern picker will be used */ }// Check status without requesting
EasyPermissions.isGranted(context, Manifest.permission.CAMERA)
EasyPermissions.areAllGranted(context, Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
// Send user to app Settings (for permanently denied permissions)
EasyPermissions.openAppSettings(context)Query the library version at runtime:
import com.majid2851.easypermissions.LibraryVersion
Log.d("App", "EasyPermissions-KTX v${LibraryVersion.NAME} (code ${LibraryVersion.CODE})")
// → "EasyPermissions-KTX v1.0.0 (code 1)"Version metadata lives in a single place — gradle.properties:
# gradle.properties
libraryGroupId=com.majid2851
libraryArtifactId=easypermissions-ktx
libraryVersion=1.0.0
libraryVersionCode=1These values are read by easypermissions-ktx/build.gradle.kts to set the Gradle group, version, and Maven publication coordinates. To cut a new release, update both gradle.properties and LibraryVersion.kt:
# gradle.properties — bump to 1.1.0
libraryVersion=1.1.0
libraryVersionCode=2// LibraryVersion.kt — keep in sync
const val NAME: String = "1.1.0"
const val CODE: Int = 2./gradlew :easypermissions-ktx:publishReleasePublicationToLocalMavenRepositoryOutput: build/maven-repo/com/majid2851/easypermissions-ktx/1.0.0/
git tag v1.0.0
git push origin v1.0.0Consumers can then depend on it as:
implementation("com.github.majid2851:EasyPermissions-KTX:v1.0.0")easypermissions-ktx/
│
├── EasyPermissions.kt ← Public singleton entry point
│
├── model/
│ ├── PermissionResult.kt ← sealed: Granted | Denied | PermanentlyDenied
│ ├── MultiplePermissionsResult.kt ← allGranted / anyDenied / anyPermanentlyDenied
│ ├── PermissionStatus.kt ← Compose: Granted | Denied(shouldShowRationale)
│ └── BackgroundLocationResult.kt ← sealed: Granted | ForegroundDenied | BackgroundDenied
│
├── core/
│ ├── EasyPermissionsFragment.kt ← Invisible Fragment — owns all ActivityResultLaunchers
│ ├── PermissionChecker.kt ← Static helpers: isGranted / areAllGranted
│ └── PermissionPreferences.kt ← SharedPrefs: tracks "asked before" per permission
│
├── builder/
│ └── PermissionRequestBuilder.kt ← Fluent DSL (.permissions().onGranted{}.check())
│
├── extensions/
│ └── PermissionExtensions.kt ← suspend requestPermission() + callbackFlow API
│
├── compose/
│ ├── PermissionState.kt ← @Stable state for single permission
│ ├── MultiplePermissionsState.kt ← @Stable state for multiple permissions
│ └── PermissionComposables.kt ← rememberPermissionState / rememberMultiplePermissionsState
│
├── location/
│ └── BackgroundLocationManager.kt ← Automatic two-step background location flow
│
└── photo/
└── PhotoPickerManager.kt ← PickVisualMedia wrapper with fallback
Your Activity / Fragment / Composable
│
▼
EasyPermissions.with(this) ← public entry point
requestPermission(CAMERA) ← suspend extension
rememberPermissionState(CAMERA) ← Compose composable
│
▼
EasyPermissionsFragment ← invisible Fragment, attached once per Activity
(commitNow, tag: EasyPermissionsFragment)
│
├── ActivityResultLauncher<String> (single permission)
├── ActivityResultLauncher<Array<String>> (multiple permissions)
├── ActivityResultLauncher<PickVisualMediaRequest> (single photo)
└── ActivityResultLauncher<PickVisualMediaRequest> (multiple photos)
│
▼
Result delivered via callback → suspend continuation / Flow / Compose state
The library ships with 4 test suites covering all core logic:
| Suite | Type | Runs on |
|---|---|---|
PermissionResultTest |
Unit | JVM |
MultiplePermissionsResultTest |
Unit | JVM |
PermissionStatusTest |
Unit | JVM |
PermissionCheckerTest |
Unit (Robolectric) | JVM |
PermissionPreferencesTest |
Unit (Robolectric) | JVM |
PermissionCheckerInstrumentedTest |
Instrumented | Device / Emulator |
PermissionPreferencesInstrumentedTest |
Instrumented | Device / Emulator |
./gradlew :easypermissions-ktx:test./gradlew :easypermissions-ktx:connectedAndroidTest✅ PermissionResult — sealed class properties (isGranted, isDenied, isPermanentlyDenied)
✅ MultiplePermissionsResult — computed booleans (allGranted, anyGranted, anyDenied, noneGranted)
✅ PermissionStatus — Compose status sealed class, shouldShowRationale
✅ PermissionChecker — isGranted / areAllGranted / getGranted / getDenied (real PackageManager)
✅ PermissionPreferences — markAsked / hasAsked / isolation across permissions / persistence
sealed class PermissionResult {
data class Granted(val permission: String)
data class Denied(val permission: String, val shouldShowRationale: Boolean)
data class PermanentlyDenied(val permission: String)
val isGranted: Boolean
val isDenied: Boolean
val isPermanentlyDenied: Boolean
}| Property | Type | Description |
|---|---|---|
grantedPermissions |
List<String> |
All permissions that were granted |
deniedPermissions |
List<String> |
Denied but not permanently |
permanentlyDeniedPermissions |
List<String> |
User selected "Don't ask again" |
allGranted |
Boolean |
All permissions granted |
anyGranted |
Boolean |
At least one granted |
anyDenied |
Boolean |
At least one denied |
anyPermanentlyDenied |
Boolean |
At least one permanently denied |
noneGranted |
Boolean |
No permissions granted |
sealed class PermissionStatus {
object Granted : PermissionStatus()
data class Denied(override val shouldShowRationale: Boolean) : PermissionStatus()
abstract val shouldShowRationale: Boolean
val isGranted: Boolean
val isDenied: Boolean
}| Member | Type | Description |
|---|---|---|
permission |
String |
The permission string |
status |
PermissionStatus |
Reactive — triggers recomposition on change |
launchPermissionRequest() |
fun |
Shows the system dialog |
| Member | Type | Description |
|---|---|---|
permissions |
List<String> |
All requested permissions |
permissionStates |
List<PermissionState> |
Individual reactive state per permission |
allGranted |
Boolean |
All granted |
anyGranted |
Boolean |
At least one granted |
revokedPermissions |
List<PermissionState> |
Not-yet-granted states |
shouldShowRationale |
Boolean |
At least one needs rationale |
statusOf(permission) |
PermissionStatus |
Status for a specific permission |
launchMultiplePermissionRequest() |
fun |
Shows all system dialogs |
| Method | Description |
|---|---|
.permissions(vararg) |
Permissions to request |
.rationale(message) |
Informational rationale string |
.onGranted { } |
All granted callback |
.onDenied { } |
At least one denied (not permanently) |
.onPermanentlyDenied { } |
At least one permanently denied |
.onResult { result } |
Full MultiplePermissionsResult |
.onSingleResult { result } |
Single PermissionResult |
.check() |
Execute the request |
suspend fun FragmentActivity.requestPermission(permission: String): PermissionResult
suspend fun FragmentActivity.requestPermissions(vararg permissions: String): MultiplePermissionsResult
// Fragment overloads
suspend fun Fragment.requestPermission(permission: String): PermissionResult
suspend fun Fragment.requestPermissions(vararg permissions: String): MultiplePermissionsResultfun FragmentActivity.permissionFlow(permission: String): Flow<PermissionResult>
fun FragmentActivity.permissionsFlow(vararg permissions: String): Flow<MultiplePermissionsResult>
// Fragment overloads
fun Fragment.permissionFlow(permission: String): Flow<PermissionResult>
fun Fragment.permissionsFlow(vararg permissions: String): Flow<MultiplePermissionsResult>| Member | Type | Value |
|---|---|---|
NAME |
String (const) |
"1.0.0" |
CODE |
Int (const) |
1 |
shouldShowRequestPermissionRationale() alone cannot distinguish between:
| State | shouldShowRationale |
|---|---|
| First-time request | false |
| After first denial | true |
| After "Don't ask again" | false |
EasyPermissions-KTX stores whether each permission has been asked before in SharedPreferences under the key easy_permissions_ktx_prefs. The isPermanentlyDenied() check is:
isPermanentlyDenied = !isGranted && !shouldShowRationale && hasAsked
This means first-time denials are never misclassified as permanent.
| Requirement | Version / Note |
|---|---|
| Min SDK | 24 (Android 7.0 Nougat) |
| Compile / Target SDK | 34 |
| Kotlin | 1.9.0+ |
| Jetpack Compose BOM | 2023.08.00+ |
| AndroidX Activity | 1.8.0+ |
| AndroidX Fragment | 1.6.0+ |
| Kotlin Coroutines | 1.7.0+ |
| Host Activity | Must extend FragmentActivity (or AppCompatActivity) |
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Write tests for any new behaviour
- Commit with a clear message:
git commit -m "feat: add X" - Push and open a Pull Request
Please follow the existing code style (Kotlin official style guide, no wildcard imports).
Copyright 2024 Majid
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Made with ❤️ for the Android community
If this library saved you time, give it a ⭐