iOS Permissions
The SDK exposes runtime permissions as a whitelist of typed buckets. There are two calls:
| Call | What it does |
|---|---|
DoorstepAI.checkPermissions(_:) | Observes state. Never prompts — safe at launch, on every screen, as often as you like |
DoorstepAI.requestPermissions(_:onlyIfNotDetermined:timeout:completion:) | Asks for exactly the buckets you name — nothing else |
A whitelist is an array of DoorstepPermission. Naming one bucket asks for that bucket
only; omitting the whitelist means all four (DoorstepPermission.all).
DoorstepAI.requestPermissions([.locationWhenInUse]) // one prompt, nothing else
DoorstepAI.requestPermissions() // all four buckets — three prompts: location, motion, bluetooth
Call requestPermissions once, from a user-initiated flow — not from app launch. Everything
the SDK declines to ask for comes back in the result with a reason, so you never have to guess
what the user saw.
Check current state
checkPermissions(_:) returns a [DoorstepPermission: DoorstepPermissionStatus]. Reading
authorization is prompt-free: location status comes from a retained CLLocationManager
(constructing one never prompts), and Motion & Fitness / Bluetooth from static
authorizationStatus reads. Only request*Authorization — and instantiating a
CBCentralManager — raises a prompt, and checkPermissions does neither.
import DoorstepDropoffSDK
@MainActor
func logPermissions() {
// Every bucket (default whitelist)
let statuses = DoorstepAI.checkPermissions()
for (permission, status) in statuses {
print(permission.rawValue, status.state.rawValue, status.osStatus, status.canRequest)
}
// A subset
let location = DoorstepAI.checkPermissions([.locationWhenInUse, .locationAlways])
print(location.count, "location buckets checked")
// A single bucket
let always = DoorstepAI.checkPermission(.locationAlways)
if always.state == .whenInUseOnly {
// Foreground only: locked-phone collection and route geofencing will NOT work.
}
// Compact JSON for support logs and bug reports
print(DoorstepAI.checkPermissionsJson())
}
DoorstepPermissionStatus
| Field | Type | Meaning |
|---|---|---|
permission | DoorstepPermission | The bucket this status describes |
state | DoorstepPermissionState | The verdict. Branch on this — the raw values are stable |
osStatus | String | Raw evidence in iOS's own words ("authorizedWhenInUse,accuracy=full", "cmMotionActivity=3,cmSensorRecorder=3", "missingInfoPlistKey(NSMotionUsageDescription)"). Diagnostic only — never branch on it |
canRequest | Bool | True when a request with default parameters would show a prompt for this bucket |
checkPermissionsJson(_:) renders the same data as
{"<permission>":{"state":"…","canRequest":true|false,"os":"…"}} — state is stable, os is
free-form.
whenInUseOnlyOn iOS the Always upgrade is the user's decision, so the SDK will not re-prompt for it by default.
Drive it from your own UI with a Settings deep link, or pass onlyIfNotDetermined: false.
@MainActorcheckPermissions(_:), checkPermission(_:), checkPermissionsJson(_:) and
requestPermissions(_:onlyIfNotDetermined:timeout:completion:) are annotated @MainActor — call
them from the main actor (SwiftUI view code, @MainActor types) or hop with
await MainActor.run { … }.
Request permissions
@MainActor
func askForTracking() {
DoorstepAI.requestPermissions([.locationAlways, .motionFitness]) { result in
print("prompted:", result.requested.map(\.rawValue)) // in prompt order
print("left alone:", result.alreadyDetermined.map(\.rawValue))
print("cannot ask:", result.unavailable.map(\.rawValue))
for (permission, reason) in result.deferred {
print("not asked — \(permission.rawValue): \(reason)")
}
let always = result.statuses[.locationAlways]?.state ?? .notDetermined
print("locationAlways settled at", always.rawValue, "didRequest:", result.didRequest)
}
}
Parameters
requestPermissions is @MainActor — call it from the main actor.
| Parameter | Type | Default | Description |
|---|---|---|---|
permissions | [DoorstepPermission] | DoorstepPermission.all | The whitelist. Only these buckets are ever requested |
onlyIfNotDetermined | Bool | true | Skip buckets the user has already answered |
timeout | TimeInterval | 120 | How long to wait for each individual answer before moving on |
completion | ((DoorstepPermissionRequestResult) -> Void)? | nil | Called on the main thread once the sequence settles |
DoorstepPermissionRequestResult
| Field | Type | Meaning |
|---|---|---|
requested | [DoorstepPermission] | Buckets actually prompted for, in prompt order |
alreadyDetermined | [DoorstepPermission] | Whitelisted buckets already answered, so the default policy left them alone |
unavailable | [DoorstepPermission] | Whitelisted buckets that cannot be requested on this device or build (unavailable or notDeclared) |
deferred | [DoorstepPermission: String] | Every bucket deliberately skipped, with a human-readable reason |
statuses | [DoorstepPermission: DoorstepPermissionStatus] | State of every whitelisted bucket after the sequence settled |
didRequest | Bool | True when at least one prompt was raised (!requested.isEmpty) |
Rules the sequencer follows
- Prompts are raised one at a time, in a fixed order — location → motion → bluetooth — and each
one is awaited before the next. The order is declaration order, never your array's order, so the
sequence a driver sees is deterministic and
completionreports settled state rather than a snapshot taken while an alert was still on screen. - One location prompt per call. If
.locationAlwaysis whitelisted it is the location ask and.locationWhenInUsefolds into it (you'll find.locationWhenInUseindeferredwith that reason). Asking for both separately is the re-escalation antipattern. - iOS answers a first-time Always request with the When-In-Use prompt and offers the Always
upgrade later on its own schedule. A successful call therefore commonly settles on
whenInUseOnly— that is iOS, not a failure of the call. - A second request while one is in flight is refused, not interleaved: nothing is asked,
requestedis empty, and every whitelisted bucket appears indeferredwith the in-flight reason. Await the firstcompletion. - Buckets that are
granted,unavailableornotDeclaredare never asked. - With
onlyIfNotDetermined: true(the default), anything already answered is left alone — iOS would not re-prompt anyway. Passingfalseforces the request for already-answered buckets. Its one genuinely useful case is thewhenInUseOnly→ Always upgrade; apermanentlyDeniedbucket still shows no alert, because Settings is the only route back. Forced re-asks also get a shorter capped wait (25 s) instead of the fulltimeout, because "Keep Only While Using" is a valid answer that leaves nothing to observe. - An empty whitelist asks nothing and completes immediately with empty collections.
requestAllPermissions(requestAlwaysLocation:requestBluetooth:onlyIfNotDetermined:) still exists
as a superseded — still supported, behavior-identical — shim onto this API (it translates the
two booleans into a whitelist). On iOS it is not yet flagged by the compiler, so you will see no
deprecation warning; its Android counterpart is annotated. New integrations should call
requestPermissions(_:) and name their buckets.
Permission buckets
Four cases, each one user-visible iOS decision. rawValue is the case name ("locationAlways",
…), and DoorstepPermission.from(wireName:) parses one back, case-insensitively, returning nil
for an unknown token.
| Bucket | iOS API it triggers | Required Info.plist key | Notes |
|---|---|---|---|
.locationWhenInUse | requestWhenInUseAuthorization() | NSLocationWhenInUseUsageDescription | Enough for a foreground session. Not enough for a locked or backgrounded phone (iOS stops delivering fixes ~70 s after the screen locks) and not enough for region monitoring |
.locationAlways | requestAlwaysAuthorization() | NSLocationAlwaysAndWhenInUseUsageDescription | Required for locked-phone collection and route geofencing. A first-time ask shows the When-In-Use prompt; the Always upgrade comes later on iOS's schedule |
.motionFitness | Motion & Fitness — one authorization behind CMMotionActivityManager, CMSensorRecorder (accel backfill) and absolute altitude | NSMotionUsageDescription | osStatus reports both cmMotionActivity and cmSensorRecorder, so an accel-backfill problem is diagnosable from one line. Reports unavailable where CMMotionActivityManager.isActivityAvailable() is false |
.bluetooth | CBCentralManager instantiation | NSBluetoothAlwaysUsageDescription | Ship the key whether or not you whitelist .bluetooth — BLE collection is switched on by server config. Without the key the requester declines to ask and reports notDeclared |
The Info.plist keys themselves are listed in Installation → Required Permissions.
Permission states
DoorstepPermissionState uses the same eight names as the Android SDK, so host code branches
identically on both platforms. Not every state occurs on both.
| State | Meaning | What to do |
|---|---|---|
granted | Held right now | Nothing |
notDetermined | Never asked; a prompt will show | Ask, from a user-initiated flow |
whenInUseOnly | Foreground location held, background is not — the user chose "While Using the App". Only ever reported for .locationAlways | Explain why background is needed, then deep-link to Settings (or onlyIfNotDetermined: false). Do not treat as granted |
denied | Refused, but another prompt is still possible | Never reported on iOS — see below |
permanentlyDenied | Refused, and no further prompt is possible | Deep-link to Settings; do not re-prompt |
restricted | Blocked by device policy / MDM / Screen Time | Surface as a device-management problem; there is no user prompt that fixes it |
unavailable | Hardware or OS support is absent (e.g. CMMotionActivityManager.isActivityAvailable() == false) | Nothing to request; not a failure. Hide the feature |
notDeclared | The host build is missing the Info.plist usage-description key this permission requires | Fix the build: add the key. Not a user refusal |
whenInUseOnly is not a flavour of grantedOne CLAuthorizationStatus feeds both location buckets: .authorizedWhenInUse is granted for
.locationWhenInUse and whenInUseOnly for .locationAlways. Collapsing that into granted is
how a driver ends up in a locked-phone session that silently collects nothing.
notDeclared is a host build problem, not a refusalIt means iOS would never show the prompt — the usage-description key is missing from your
Info.plist. It is distinct from unavailable precisely because you can fix it: add the key. For
.bluetooth the requester declines to touch Bluetooth at all while the key is absent, and BLE
collection is turned on by server config rather than by your whitelist, so ship
NSBluetoothAlwaysUsageDescription in every build.
denied is never reported on iOS. iOS does not re-prompt for a permission the user has
refused, so every iOS .denied maps to permanentlyDenied and Settings is the only route back.
The re-askable denied state exists only so the vocabulary matches Android, whose first refusal
is re-askable.
Recommended flow
Stage the asks: foreground location at onboarding, Always only after your own explanation, then Motion & Fitness, then Bluetooth. Each stage is one call with one bucket.
import UIKit
import DoorstepDropoffSDK
@MainActor
enum PermissionCoordinator {
/// Stage 1 — onboarding: foreground location only. No Motion & Fitness, no Bluetooth.
static func onboarding() {
DoorstepAI.requestPermissions([.locationWhenInUse]) { result in
let state = result.statuses[.locationWhenInUse]?.state ?? .notDetermined
print("locationWhenInUse:", state.rawValue)
}
}
/// Stage 2 — AFTER your own "why we need background location" screen.
static func upgradeToAlways() {
DoorstepAI.requestPermissions([.locationAlways]) { result in
let state = result.statuses[.locationAlways]?.state ?? .notDetermined
switch state {
case .granted:
break // locked-phone collection and route geofencing now work
case .whenInUseOnly, .permanentlyDenied, .restricted:
// Nothing left to prompt: iOS owns this decision now.
if let reason = result.deferred[.locationAlways] {
print("locationAlways not asked —", reason)
}
openSettings()
case .notDeclared:
break // fix the Info.plist, not the UI
default:
break // still notDetermined: the prompt went unanswered, ask again later
}
}
}
/// Stage 3 — Motion & Fitness, on your own schedule.
static func motionAndFitness() {
DoorstepAI.requestPermissions([.motionFitness])
}
/// Stage 4 — Bluetooth, which drives BLE collection.
static func bluetooth() {
guard DoorstepAI.checkPermission(.bluetooth).state == .notDetermined else { return }
DoorstepAI.requestPermissions([.bluetooth])
}
static func openSettings() {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
deferred is what tells you a Settings deep link is the only move left: a whenInUseOnly or
already-answered bucket comes back with a reason string instead of a prompt, so you know no alert
was shown and re-calling requestPermissions will not show one either.
The location collector asks for Always on its own the first time a startDelivery* builds it.
Route geofencing asks too, but only while location is still notDetermined. Either way the
whitelist governs the SDK's explicit requester only, so a When-In-Use-only whitelist holds only
if your prompts run before the first session starts.
Troubleshooting
The prompt never appears
Check the bucket's state. notDeclared means the host build is missing that permission's
Info.plist usage-description key, so iOS would never show the prompt — the SDK skips the request
and puts the bucket in unavailable and deferred rather than failing silently.
let status = DoorstepAI.checkPermission(.motionFitness)
if status.state == .notDeclared {
print(status.osStatus) // missingInfoPlistKey(NSMotionUsageDescription)
}
Add the key (see Required Permissions) and rebuild. If the
state is granted, permanentlyDenied or restricted instead, there is nothing to prompt for —
that is alreadyDetermined, not a bug.
Tracking dies about 70 seconds after the screen locks
That is whenInUseOnly: foreground location is held, Always is not. iOS stops delivering fixes
shortly after the screen locks, and region monitoring never starts at all.
if DoorstepAI.checkPermission(.locationAlways).state == .whenInUseOnly {
// Show your own "background location" explanation, then deep-link to Settings.
}
Never treat whenInUseOnly as granted — the session will look alive and collect nothing.
The user denied it and iOS will not prompt again
Expected: every iOS refusal maps to permanentlyDenied, canRequest is false, and a further
requestPermissions call comes back with deferred reason "already answered". Settings is the only
route back:
if DoorstepAI.checkPermission(.motionFitness).state == .permanentlyDenied,
let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
I requested Always but the state says whenInUseOnly
Also expected. iOS answers a first-time Always request with the When-In-Use prompt and offers
the Always upgrade later, on its own schedule — the SDK reports the state as it really is instead
of flattening it into granted. Do not loop on requestPermissions; explain the benefit in your
own UI and let the user upgrade from the iOS prompt when it appears, or from Settings.
Nothing was asked and requested is empty
Read deferred — every skipped bucket carries a reason. The common ones: another request was
already in flight (await the first completion), .locationWhenInUse was folded into the
.locationAlways prompt, the bucket was already answered, the Info.plist key is missing, or the
whitelist was empty.
Next Steps
- 📚 Usage & Integration — initialize the SDK, then start and stop deliveries
- ⚙️ Required Permissions — the Info.plist keys and background modes these buckets depend on