B04 — Permissions playground
Build an app that asks for a runtime permission, reports the grant state, and demonstrates the difference between a permission check and an AppOp. Then change the target level and watch the same code behave differently.
Permissions are the part of the platform where the user and the developer negotiate, and the negotiation is easy to model in code. This project makes the negotiation visible: what the app asks for, what the user grants, and what the platform does with the answer.
What you are building#
One screen that shows a permission's current state, a button to request it, and a second button that performs an operation gated by it. A log line records the result of each attempt, so the denial path is as visible as the success path.
Steps#
- Choose a low-risk permission
Use a runtime permission with an obvious effect, such as reading the user's approximate location or posting a notification. Avoid camera and microphone for a first project: their indicators add variables.1
You should seeA permission with a clear operation behind it. - Declare it in the manifest
Add a
uses-permissionelement. Declaring is necessary and not sufficient: the user still has to grant it.2You should seeThe manifest lists the permission. - Show the current state
Check the permission state on each composition or lifecycle resume and render it. Show three states: granted, denied, and "denied permanently" where the system will no longer show a prompt.
You should seeA label that updates after each grant or denial. - Request it
Use the activity result API to launch the permission request. Handle both callbacks: granted and denied. Log both.
You should seeThe system dialog appears the first time. - Perform the gated operation
Call the API behind the permission and catch the failure. Do not assume the grant: check, call, and handle the failure path. The user can revoke at any time.3
You should seeSuccess when granted, a handled failure when not. - Explain the permanent denial
After a permanent denial, the system stops showing the dialog. Detect that case and offer a link to the app's settings page instead of a button that does nothing.
You should seeA rationale and a route to Settings. - Add an AppOps readout
Read the operation's AppOps mode via the shell or the AppOps API, and show it next to the permission state. Then set it to ignore from the shell and observe that the permission still reads as granted while the operation fails.4
You should seeThe operation mode shown alongside the grant. - Change the target level
Pick a change gated on your target level, lower the target below it, and observe the old behaviour. The notification permission at API 33 is a good candidate: an app targeting 32 does not need the runtime grant; one targeting 33 does.5
You should seeA behaviour difference between target levels.
The request uses the activity result API:
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
Log.i("PermDemo", "granted=$granted")
}
Button(onClick = { launcher.launch(Manifest.permission.ACCESS_COARSE_LOCATION) }) {
Text("Request location")
}adb shell dumpsys package com.example.permissionsplayground | grep -i -A 3 "runtime permissions"
adb shell appops get com.example.permissionsplayground
adb shell cmd appops set com.example.permissionsplayground COARSE_LOCATION ignore
adb shell cmd appops set com.example.permissionsplayground COARSE_LOCATION defaultExample output
Illustrative — package and permission differ.Verification#
The app should be able to report all four states without crashing: granted, denied, permanently denied, and granted-but-operation-denied. The fourth is the one most apps forget, and it is the one the AppOps step produces.
What you learned#
- A permission is a grant, not a capability. It can be revoked, and the operation can still fail.
- AppOps is a second gate. A granted permission with an ignored operation fails, and the app cannot tell which gate closed.
- Target level changes the rules. The same code asks for different things depending on what it targets.
- Permanent denial is a state, not an error. The UI must handle it without a dead button.
Read The permissions model for the protection levels, and Lab 6 — Permissions and AppOps for the shell side.
Extend it#
- Add a permission with a one-time grant and observe the revocation when the app leaves the foreground.
- Add the privacy dashboard view using the permission usage APIs, and compare what the system shows with what your app recorded.
- Automate the target-level comparison with a build variant per target, so both behaviours are testable in one project.
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsPermissions on Android | Privacydeveloper.android.comaccessed 2026-10-27↩
- docsDeclare app permissions | Privacydeveloper.android.comaccessed 2026-10-27↩
- docsRequest runtime permissions | Privacydeveloper.android.comaccessed 2026-10-27↩
- docsPermissions best practicesdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 13 or higherdeveloper.android.comaccessed 2026-10-27↩
- docsTarget API level requirements for Google Play appsdeveloper.android.comaccessed 2026-10-27↩