Documentation

$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 granted
  • shouldShowRationale — denied once, and Android will ask again. Explain why, then request()
  • request() — show the system dialog
  • openSettings() — open this app's settings page
  • refresh() — 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

AliasPermission
cameraCAMERA
location, location-coarse, location-backgroundACCESS_*_LOCATION
microphoneRECORD_AUDIO
notificationsPOST_NOTIFICATIONS
contacts, contacts-writeREAD/WRITE_CONTACTS
calendar, calendar-writeREAD/WRITE_CALENDAR
photos, videos, audioREAD_MEDIA_*
storage, storage-writeREAD/WRITE_EXTERNAL_STORAGE
bluetooth-scan, bluetooth-connect, bluetooth-advertiseBLUETOOTH_*
nearby-wifiNEARBY_WIFI_DEVICES
phone, call-log, smsREAD_PHONE_STATE / READ_CALL_LOG / READ_SMS
activity-recognition, body-sensorsas 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.

Camera.wh
val camera = $permission("camera")

<Column>
  @if (camera.granted) {
    <Text>Ready</Text>
  } @else {
    <Button text="Allow" onClick={() => camera.request()} />
  }
</Column>
Kotlin waiting
Scroll into view to compile.

The name resolves to the Android constant at compile time, and granted is backed by mutableStateOf — reading it in markup subscribes to it.