$permission
Ask for a runtime permission, from a screen or a store.
Dangerous permissions — camera, location, microphone, notifications on
Android 13+ — have to be asked for while the app runs. $permission() returns a live object describing one.
val camera = $permission("camera")
<Column>
@if (camera.granted) {
<CameraPreview />
} @else {
<Button text="Allow camera" onClick={() => camera.request()} />
}
</Column> What it gives you
granted— every permission in the set is grantedshouldShowRationale— denied once, and Android will ask again. Explain why, thenrequest()request()— show the system dialogopenSettings()— open this app's settings pagerefresh()— re-read now (done for you when the app resumes)
granted updates on its own. Reading it in markup subscribes to
it, and every permission is re-read when the app resumes — so coming back
from Settings shows the new answer with no code of yours.Several at once
granted is true only when all of them are:
val nearby = $permission("bluetooth-scan", "nearby-wifi") From a store
Which is usually where the work that needs a permission lives. A store has no Activity of its own; Whitehall attaches to whichever one is on screen.
@store
object MeshStore {
fun announce() {
val notify = $permission("notifications")
if (notify.granted) postNotification() else notify.request()
}
} request() needs an activity on screen — Android blocks the
dialog from the background. Called with none, it does nothing rather than
pretend.Declaring
Nothing to declare. Whitehall adds a <uses-permission> entry
for everything $permission() names.
[app] permissions in whitehall.toml stays for what
cannot be inferred — permissions that follow from runtime behaviour, or that
are granted out of band with pm grant.
Names
| Alias | Permission |
|---|---|
camera | CAMERA |
location, location-coarse, location-background | ACCESS_*_LOCATION |
microphone | RECORD_AUDIO |
notifications | POST_NOTIFICATIONS |
contacts, contacts-write | READ/WRITE_CONTACTS |
calendar, calendar-write | READ/WRITE_CALENDAR |
photos, videos, audio | READ_MEDIA_* |
storage, storage-write | READ/WRITE_EXTERNAL_STORAGE |
bluetooth-scan, bluetooth-connect, bluetooth-advertise | BLUETOOTH_* |
nearby-wifi | NEARBY_WIFI_DEVICES |
phone, call-log, sms | READ_PHONE_STATE / READ_CALL_LOG / READ_SMS |
activity-recognition, body-sensors | as named |
Anything else passes through, so a permission this table has not heard of
still works: $permission("android.permission.SOMETHING_NEW").
There is no blocked
Android cannot tell you that a user denied something permanently. shouldShowRequestPermissionRationale returns false both before the first request and after "don't ask again" — the
two are indistinguishable without separately remembering that you asked.
Whitehall does not keep that record, so it does not offer a blocked that would silently also mean "never asked".
In practice: if request() appears to do nothing and shouldShowRationale is false, it has been denied for good, and openSettings() is the way back.
See it happen
The call changes shape a little as the set grows; the branching in markup is what actually changes.
val camera = $permission("camera")
<Column>
@if (camera.granted) {
<Text>Ready</Text>
} @else {
<Button text="Allow" onClick={() => camera.request()} />
}
</Column>The name resolves to the Android constant at compile time, and granted is backed by mutableStateOf — reading it in markup subscribes to it.