Skip to main content

Android Runtime Permissions

The SDK declares the permissions it needs in its own manifest, but Android requires the host app to request the dangerous ones at runtime, from an Activity. The SDK gives you two calls for that, and both take a whitelist of typed DoorstepPermission buckets:

CallWhat it does
DoorstepAI.checkPermissions(context)Observes current state. Never prompts — safe to call at launch, on every screen, from anywhere.
DoorstepAI.requestPermissions(activity, permissions)Asks for exactly the buckets you name — no bundling, no hidden extras.

Omitting the whitelist means all six buckets. Naming one bucket asks for that bucket only.

MainActivity.kt
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.DoorstepPermission
import com.doorstepai.sdks.tracking.DoorstepPermissionState

// Observe (no dialog, ever)
val status = DoorstepAI.checkPermission(this, DoorstepPermission.LOCATION_WHEN_IN_USE)
if (status.state != DoorstepPermissionState.GRANTED) {
// Ask for foreground location only — notifications and Bluetooth stay untouched
DoorstepAI.requestPermissions(this, setOf(DoorstepPermission.LOCATION_WHEN_IN_USE))
}
Runtime vs. install-time

Only the six buckets below are runtime permissions. ACCESS_NETWORK_STATE, FOREGROUND_SERVICE, FOREGROUND_SERVICE_LOCATION, WAKE_LOCK, ACCESS_WIFI_STATE and friends are normal (install-time) permissions: the OS grants them when your app is installed, and they must never appear in a runtime permission request. See Installation → Required Permissions for the manifest side.


Check current state

fun checkPermissions(
context: Context,
permissions: Set<DoorstepPermission> = DoorstepPermission.ALL
): Map<DoorstepPermission, DoorstepPermissionStatus>

Returns one DoorstepPermissionStatus per whitelisted bucket, in DoorstepPermission declaration order.

PermissionGate.kt
import android.util.Log
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.DoorstepPermission
import com.doorstepai.sdks.tracking.DoorstepPermissionState

DoorstepAI.checkPermissions(this).forEach { (bucket, status) ->
Log.i("Perms", "${bucket.wireName}: ${status.state} canRequest=${status.canRequest} os=${status.osStatus}")
}

// Ready to track in the background?
val always = DoorstepAI.checkPermission(this, DoorstepPermission.LOCATION_ALWAYS)
val backgroundReady = always.state == DoorstepPermissionState.GRANTED

DoorstepPermissionStatus

FieldTypeMeaning
permissionDoorstepPermissionWhich bucket this describes.
stateDoorstepPermissionStateThe verdict — see Permission states. Branch on this.
osStatusStringThe raw evidence behind state, in Android's own terms ("ACCESS_FINE_LOCATION=granted,ACCESS_COARSE_LOCATION=denied", "implicit(sdk=28)", "notDeclaredInManifest[ACTIVITY_RECOGNITION]"). Diagnostic only — never branch on it, it is not a stable contract.
canRequestBooleantrue when a requestPermissions call naming this bucket, with default parameters, could actually show a prompt. true for NOT_DETERMINED, DENIED and WHEN_IN_USE_ONLY; false for GRANTED, PERMANENTLY_DENIED, RESTRICTED, UNAVAILABLE and NOT_DECLARED.

Pass an Activity, not a bare Context

checkPermissions accepts any Context, but pass an Activity when you have one. Only an Activity gives the SDK shouldShowRequestPermissionRationale, which is the signal that separates a re-askable DENIED from a PERMANENTLY_DENIED that only Settings can fix. With a plain Context a refusal always reports as the re-askable DENIED — deliberately the conservative direction, since PERMANENTLY_DENIED sends a driver to a Settings screen.

Single bucket, and JSON for bug reports

// One bucket
val status = DoorstepAI.checkPermission(this, DoorstepPermission.NOTIFICATIONS)

// Compact JSON for support logs, bug reports and scripted assertions
val json = DoorstepAI.checkPermissionsJson(this)
// {"locationWhenInUse":{"state":"GRANTED","canRequest":false,
// "os":"ACCESS_FINE_LOCATION=granted,ACCESS_COARSE_LOCATION=granted"}, …}

The JSON keys are each bucket's stable wireName (locationWhenInUse, locationAlways, activityRecognition, bluetoothScan, nearbyWifiDevices, notifications). state is stable; os is free-form.


Request permissions

fun requestPermissions(
activity: Activity,
permissions: Set<DoorstepPermission> = DoorstepPermission.ALL,
requestCode: Int = DoorstepAI.PERMISSION_REQUEST_CODE
): PermissionRequestResult

fun requestAllPermissions(
activity: Activity,
requestCode: Int = DoorstepAI.PERMISSION_REQUEST_CODE
): PermissionRequestResult

fun requestBackgroundLocationPermission(
activity: Activity,
requestCode: Int = DoorstepAI.BACKGROUND_LOCATION_REQUEST_CODE
): Boolean

Call on the main thread, from a live Activity: the grant result is delivered to that activity's onRequestPermissionsResult(requestCode, …), matching the requestCode you passed. Prompts are raised in DoorstepPermission declaration order, never in your Set's iteration order, so the dialog sequence is deterministic.

requestBackgroundLocationPermission is exactly requestPermissions(activity, setOf(DoorstepPermission.LOCATION_ALWAYS), requestCode) and returns true only if a request was actually issued. It returns false — asking nothing — below API 29, when background location is already granted, or when foreground location is not granted yet: Android 11+ answers a premature background ask with an instant auto-denial, and burning the user's one real chance on it is strictly worse than telling you to ask for foreground first.

PermissionRequestResult

A whitelist is a statement of intent, not a promise that every bucket produces a dialog. Everything the call declined to ask for is reported back with a reason instead of being silently dropped.

FieldTypeMeaning
requestedList<String>The manifest permissions actually handed to the OS, in order. Empty means no dialog was shown.
requestedBucketsSet<DoorstepPermission>The buckets requested came from.
alreadyGrantedSet<DoorstepPermission>Whitelisted buckets that were already held, so they were skipped.
unavailableSet<DoorstepPermission>Whitelisted buckets absent on this OS version or hardware, or missing from the host manifest.
deferredMap<DoorstepPermission, String>Buckets deliberately not asked in this call, each with a human-readable reason. The common entry is LOCATION_ALWAYS, which needs its own follow-up call; permanently-denied and restricted buckets land here too.
requestCodeIntThe code passed to the OS, echoed back so you can match it in onRequestPermissionsResult.
didRequestBooleanrequested.isNotEmpty() — true when a dialog (or Settings screen) was actually raised.
An empty requested means no callback

If requested comes back empty (didRequest == false), no dialog was shown and onRequestPermissionsResult will never fire. Do not await it, and do not leave a spinner on screen waiting for it — read alreadyGranted / unavailable / deferred to find out why, and drive your UI from that.

import android.util.Log
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.DoorstepPermission

val result = DoorstepAI.requestPermissions(
this,
setOf(DoorstepPermission.LOCATION_WHEN_IN_USE, DoorstepPermission.LOCATION_ALWAYS)
)
if (!result.didRequest) {
// Nothing was asked — inspect why instead of waiting for a callback
result.deferred.forEach { (bucket, reason) -> Log.w("Perms", "${bucket.wireName}: $reason") }
}
// Here: the foreground dialog is on screen and
// result.deferred[DoorstepPermission.LOCATION_ALWAYS] explains why it was not asked.
// Foreground location is not granted yet, so the reason is "…cannot be requested before
// foreground location is granted"; once it is granted, a whitelist that still contains
// another askable bucket defers LOCATION_ALWAYS with "…must be requested in its own call".
Kotlin overload ambiguity

The deprecated requestPermissions(activity, requestCode, includeBluetooth) overload still exists (the Capacitor / React Native / Flutter bridges call it), and because all of its parameters have defaults, a bare DoorstepAI.requestPermissions(activity) does not compile — the Kotlin compiler cannot resolve which overload you mean. Call DoorstepAI.requestAllPermissions(activity) instead, or always pass an explicit whitelist.

Forward results to the SDK

fun notePermissionRequestResult(
context: Context,
permissions: Array<out String>,
grantResults: IntArray
)

The OS delivers permission results to your activity, not to the SDK. One line of forwarding is the only way the SDK can see a user's answer — and without it, checkPermissions can never report PERMANENTLY_DENIED. It reports the re-askable DENIED instead and never guesses permanence.

ShiftActivity.kt
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
DoorstepAI.notePermissionRequestResult(this, permissions, grantResults)
}

An empty grantResults is Android's documented signal for a dismissed or interrupted dialog (BACK, or a tap outside it). That is not a denial — the OS still prompts next time — so it is deliberately not recorded. A later grant clears an earlier recorded refusal, so a user who changes their mind is not held to it.

If you drive your own ActivityResultContracts.RequestMultiplePermissions launcher (for example over DoorstepAI.getRequiredPermissions()), forward from the callback instead:

import android.content.pm.PackageManager
import androidx.activity.result.contract.ActivityResultContracts
import com.doorstepai.sdks.tracking.DoorstepAI

private val launcher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants ->
val entries = grants.entries.toList()
DoorstepAI.notePermissionRequestResult(
this,
entries.map { it.key }.toTypedArray(),
entries.map {
if (it.value) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED
}.toIntArray()
)
}
Legacy entry points

requestPermissions(activity, requestCode, includeBluetooth) is deprecated — behavior-identical, kept only so the wrapper bridges keep compiling. getRequiredPermissions(includeBluetooth) remains as a flat List<String> for hosts feeding their own launcher, but it is not the source of truth for what the SDK can request: DoorstepPermission is, and it additionally covers ACTIVITY_RECOGNITION and LOCATION_ALWAYS. New code should use the whitelist API.

Flat compatibility helpers

These public helpers remain available for hosts that own their permission launcher:

DoorstepAI.getRequiredPermissions(includeBluetooth: Boolean = true): List<String>
DoorstepAI.hasRequiredPermissions(
context: Context,
includeBluetooth: Boolean = false
): Boolean
DoorstepAI.hasBackgroundLocationPermission(context: Context): Boolean

The first two cover the legacy foreground list; they do not replace the typed bucket status API. hasBackgroundLocationPermission returns true below API 29, where background location is not a separate operating-system grant.


Permission buckets

One bucket = one user-visible OS decision. That is why buckets are not permission strings: "foreground location" is two manifest permissions behind a single dialog, and BLE scanning maps to a different pair depending on API level.

BucketManifest permissionsNotes
LOCATION_WHEN_IN_USEACCESS_FINE_LOCATION + ACCESS_COARSE_LOCATION (one dialog)startDelivery* refuses without it, and it is the prerequisite for LOCATION_ALWAYS.
LOCATION_ALWAYSACCESS_BACKGROUND_LOCATION (API 29+)Drives background collection and route geofencing. Must be a separate, second request after foreground is granted — see the warning below. Below API 29 there is no separate grant, so this reports GRANTED.
ACTIVITY_RECOGNITIONACTIVITY_RECOGNITION (API 29+)
BLUETOOTH_SCANBLUETOOTH_SCAN (API 31+); BLUETOOTH_ADMIN + ACCESS_FINE_LOCATION (API ≤ 30)
NEARBY_WIFI_DEVICESNEARBY_WIFI_DEVICES (API 33+)
NOTIFICATIONSPOST_NOTIFICATIONS (API 33+)The tracking foreground service posts a notification the driver can see. Implicitly granted below API 33.

Every one of these is already declared in the SDK's library manifest and reaches your app through manifest merge, so you only have to request them at runtime. The one permission the SDK does not declare is INTERNET — see Installation → Required Permissions.

DoorstepPermission.ALL is the set of all six and is the default whitelist for both calls. Each bucket also exposes a stable wireName for logs and scripted assertions, manifestPermissions() for the strings it resolves to on the current device, and DoorstepPermission.fromWireName("locationAlways") to parse one back (returns null on an unknown token rather than guessing).


Permission states

DoorstepPermissionState has eight values, and the same eight names mean the same things on iOS — host code that branches on them reads identically on both platforms.

StateMeaningWhat to do
GRANTEDHeld right now.Nothing.
NOT_DETERMINEDNever asked, as far as the SDK can tell — a request will show a dialog.Request it, at the moment its purpose is on screen.
WHEN_IN_USE_ONLYForeground location is held, background is not — the user chose "While using the app". Only ever reported for LOCATION_ALWAYS.Show your rationale, then request LOCATION_ALWAYS alone (the OS opens the "Allow all the time" screen). Do not treat this as granted.
DENIEDAsked and refused, but another prompt is still possible.Show a rationale, then re-ask.
PERMANENTLY_DENIEDRefused with no further prompt possible — only the OS Settings app can change it. A request would return an instant denial with no dialog.Deep-link the user to your app's Settings page.
RESTRICTEDBlocked by device policy / MDM / parental controls.Not reported on Android today (iOS-only). Treat like PERMANENTLY_DENIED if you see it.
UNAVAILABLEDoes not exist on this OS version, or the hardware is absent (NEARBY_WIFI_DEVICES below API 33, BLUETOOTH_SCAN with no BLE radio).Nothing — it is not a failure and there is nothing to request. Hide the related UI.
NOT_DECLAREDThe permission exists on this OS version, but the host app's merged manifest does not declare it, so the OS would deny the request instantly with no dialog.Fix your build: add the <uses-permission> entry.
WHEN_IN_USE_ONLY is not granted

LOCATION_WHEN_IN_USE = GRANTED together with LOCATION_ALWAYS = WHEN_IN_USE_ONLY is one single OS status read two ways, and that is the whole reason the two buckets exist. Background collection and route geofencing do not work in this state. Collapsing it into GRANTED is how a driver ends up in a locked-phone session that silently collects nothing.

LOCATION_ALWAYS = GRANTED is necessary but not sufficient: background collection also needs a running foreground service to keep the process alive. See Using the SDK → Foreground Service.

NOT_DECLARED is a build error, not a refusal

NOT_DECLARED means your build is missing a <uses-permission> line (the iOS counterpart is a missing Info.plist usage-description key). The user never saw a prompt and never refused anything. The SDK's library manifest declares all six buckets and manifest merge carries them into your app, so this state normally means something in your build actively removed the entry — a tools:node="remove" rule, or a manifest-merger conflict.

Telling DENIED from PERMANENTLY_DENIED

Android reports a permanently-denied permission with the same signals as a never-asked one, and shouldShowRequestPermissionRationale == false is also what a cancelled dialog leaves behind. So the SDK requires two pieces of evidence, one of which only you can supply: forward results via DoorstepAI.notePermissionRequestResult(...). Without it the SDK reports the re-askable DENIED and never claims permanence.


Recommended flow

Ask for each bucket at the moment its purpose is visible to the driver, one stage at a time — never all six on first launch.

Background location is always a separate, second request

ACCESS_BACKGROUND_LOCATION can never be bundled with the foreground request: Android 11+ auto-denies a bundled ask. Request it in its own call, only after foreground location is granted, and only after your own rationale screen. Whitelist both buckets in one call and the SDK does the safe thing for you — foreground is asked now, LOCATION_ALWAYS comes back in deferred for a follow-up call.

ShiftActivity.kt
import android.content.pm.PackageManager
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.DoorstepPermission
import com.doorstepai.sdks.tracking.DoorstepPermissionState

class ShiftActivity : AppCompatActivity() {

// Stage 1 — onboarding: the tracking notification the driver will see.
fun onOnboardingFinished() {
DoorstepAI.requestPermissions(this, setOf(DoorstepPermission.NOTIFICATIONS))
}

// Stage 2 — "Start shift": foreground location. Required; the SDK refuses to
// start a delivery without it.
fun onStartShiftTapped() {
DoorstepAI.requestPermissions(this, setOf(DoorstepPermission.LOCATION_WHEN_IN_USE))
}

// Stage 3 — a SEPARATE, LATER call, after your own "why we need background
// location" screen, and only once foreground location is granted.
fun onBackgroundRationaleAccepted() {
val foreground = DoorstepAI.checkPermission(this, DoorstepPermission.LOCATION_WHEN_IN_USE)
if (foreground.state != DoorstepPermissionState.GRANTED) {
onStartShiftTapped() // foreground first, or the OS auto-denies the ask
return
}
DoorstepAI.requestPermissions(this, setOf(DoorstepPermission.LOCATION_ALWAYS))
// Equivalent: DoorstepAI.requestBackgroundLocationPermission(this)
}

// Stage 4 — last: the Nearby-devices group, which drives BLE and WiFi-RTT collection.
fun onNearbyDevicesStage() {
DoorstepAI.requestPermissions(
this,
setOf(DoorstepPermission.BLUETOOTH_SCAN, DoorstepPermission.NEARBY_WIFI_DEVICES)
)
}

// Forward every answer — without this, DENIED can never be told from
// PERMANENTLY_DENIED and you will never know to offer a Settings deep link.
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
DoorstepAI.notePermissionRequestResult(this, permissions, grantResults)

val granted = grantResults.isNotEmpty() &&
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (requestCode == DoorstepAI.PERMISSION_REQUEST_CODE && granted) {
// Refresh your UI from the SDK's own verdicts
DoorstepAI.checkPermissions(this).forEach { (bucket, status) ->
Log.i("Perms", "${bucket.wireName}=${status.state}")
}
}
}
}
Everything at once

For an internal or single-purpose driver app, DoorstepAI.requestAllPermissions(activity) asks for every askable bucket in one batch. LOCATION_ALWAYS will still come back in deferred — anything else askable in the same call means background location has to wait for its own request.


Troubleshooting

The prompt never appears

Read the returned PermissionRequestResult — it always says why.

  • requested is empty → no dialog was shown and onRequestPermissionsResult will never fire.
  • The bucket is in unavailable with checkPermission(...).state == NOT_DECLARED → your merged manifest is missing the <uses-permission> line. The SDK declares all six, so check for a tools:node="remove" rule or a merger conflict in your build; osStatus names the exact permission (notDeclaredInManifest[…]).
  • The bucket is in unavailable with UNAVAILABLE → the permission does not exist on this OS version, or the radio is absent. Nothing to fix.
  • The bucket is in alreadyGranted → it is already held.
  • The bucket is in deferred → the reason string names the rule (background location needs foreground first, or must be asked alone; permanently denied; restricted).

The user denied twice and the state is still DENIED

You are not forwarding results. Add DoorstepAI.notePermissionRequestResult(this, permissions, grantResults) to onRequestPermissionsResult, and pass an Activity (not an application Context) to checkPermissions. Without both, the SDK cannot establish permanence and reports the re-askable DENIED on purpose.

Once the state really is PERMANENTLY_DENIED, stop re-asking — the OS will not show a dialog again — and send the user to Settings:

import android.content.Intent
import android.net.Uri
import android.provider.Settings

startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
)
)

Collection stops when the phone locks or the app is backgrounded

There are two independent causes; check both, because a permission fix will not help if the service is the problem.

1. The permission. Check LOCATION_ALWAYS. If it reports WHEN_IN_USE_ONLY, the driver chose "While using the app" and background collection genuinely does not work. Show your rationale and request LOCATION_ALWAYS alone, or deep-link to Settings → Location → "Allow all the time".

2. The foreground service. Background location permission does not keep your process running — a foreground service does. Android freezes a backgrounded process without one, and collection stops even with LOCATION_ALWAYS == GRANTED. Confirm the SDK's service actually came up:

adb shell dumpsys activity services com.doorstepai.sdks.tracking.internal.TrackingService \
| grep -i isForeground

If it is not foreground, look for Foreground start rejected by Android policy (Android 12+ will not let a backgrounded app start one — start the delivery while your app is in the foreground) or manualForeground=true but no host foreground service is currently running (you opted out of the SDK's service and yours is not up). Full walkthrough: Using the SDK → Foreground Service.

Relatedly, startDelivery* refuses to start from the background without background location, with "Cannot start tracking while app is backgrounded without background location permission."

Route geofencing never fires

Route geofencing needs ACCESS_FINE_LOCATION and ACCESS_BACKGROUND_LOCATION — i.e. LOCATION_WHEN_IN_USE == GRANTED and LOCATION_ALWAYS == GRANTED. WHEN_IN_USE_ONLY is not enough, and neither is coarse-only location.

val perms = DoorstepAI.checkPermissions(
this,
setOf(DoorstepPermission.LOCATION_WHEN_IN_USE, DoorstepPermission.LOCATION_ALWAYS)
)
val geofencingReady = perms.values.all { it.state == DoorstepPermissionState.GRANTED }

The background-location request is denied instantly

It was bundled, or asked too early. Android 11+ auto-denies a background-location request that is bundled with the foreground one, and auto-denies it before foreground location is granted. Request LOCATION_ALWAYS in its own call, after foreground is granted — the SDK enforces this and will defer rather than burn the user's one real chance.


Next steps