Skip to main content

iOS Permissions

The SDK exposes runtime permissions as a whitelist of typed buckets. There are two calls:

CallWhat 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
Where prompts belong

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.

PermissionDebug.swift
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

FieldTypeMeaning
permissionDoorstepPermissionThe bucket this status describes
stateDoorstepPermissionStateThe verdict. Branch on this — the raw values are stable
osStatusStringRaw evidence in iOS's own words ("authorizedWhenInUse,accuracy=full", "cmMotionActivity=3,cmSensorRecorder=3", "missingInfoPlistKey(NSMotionUsageDescription)"). Diagnostic only — never branch on it
canRequestBoolTrue 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.

canRequest is false for whenInUseOnly

On 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.

All four permission calls are @MainActor

checkPermissions(_:), 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.

ParameterTypeDefaultDescription
permissions[DoorstepPermission]DoorstepPermission.allThe whitelist. Only these buckets are ever requested
onlyIfNotDeterminedBooltrueSkip buckets the user has already answered
timeoutTimeInterval120How long to wait for each individual answer before moving on
completion((DoorstepPermissionRequestResult) -> Void)?nilCalled on the main thread once the sequence settles

DoorstepPermissionRequestResult

FieldTypeMeaning
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
didRequestBoolTrue 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 completion reports settled state rather than a snapshot taken while an alert was still on screen.
  • One location prompt per call. If .locationAlways is whitelisted it is the location ask and .locationWhenInUse folds into it (you'll find .locationWhenInUse in deferred with 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, requested is empty, and every whitelisted bucket appears in deferred with the in-flight reason. Await the first completion.
  • Buckets that are granted, unavailable or notDeclared are never asked.
  • With onlyIfNotDetermined: true (the default), anything already answered is left alone — iOS would not re-prompt anyway. Passing false forces the request for already-answered buckets. Its one genuinely useful case is the whenInUseOnly → Always upgrade; a permanentlyDenied bucket 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 full timeout, 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.
The old requester is superseded

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.

BucketiOS API it triggersRequired Info.plist keyNotes
.locationWhenInUserequestWhenInUseAuthorization()NSLocationWhenInUseUsageDescriptionEnough 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
.locationAlwaysrequestAlwaysAuthorization()NSLocationAlwaysAndWhenInUseUsageDescriptionRequired 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
.motionFitnessMotion & Fitness — one authorization behind CMMotionActivityManager, CMSensorRecorder (accel backfill) and absolute altitudeNSMotionUsageDescriptionosStatus reports both cmMotionActivity and cmSensorRecorder, so an accel-backfill problem is diagnosable from one line. Reports unavailable where CMMotionActivityManager.isActivityAvailable() is false
.bluetoothCBCentralManager instantiationNSBluetoothAlwaysUsageDescriptionShip 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.

StateMeaningWhat to do
grantedHeld right nowNothing
notDeterminedNever asked; a prompt will showAsk, from a user-initiated flow
whenInUseOnlyForeground location held, background is not — the user chose "While Using the App". Only ever reported for .locationAlwaysExplain why background is needed, then deep-link to Settings (or onlyIfNotDetermined: false). Do not treat as granted
deniedRefused, but another prompt is still possibleNever reported on iOS — see below
permanentlyDeniedRefused, and no further prompt is possibleDeep-link to Settings; do not re-prompt
restrictedBlocked by device policy / MDM / Screen TimeSurface as a device-management problem; there is no user prompt that fixes it
unavailableHardware or OS support is absent (e.g. CMMotionActivityManager.isActivityAvailable() == false)Nothing to request; not a failure. Hide the feature
notDeclaredThe host build is missing the Info.plist usage-description key this permission requiresFix the build: add the key. Not a user refusal
whenInUseOnly is not a flavour of granted

One 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 refusal

It 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.

PermissionCoordinator.swift
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.

Run your prompts before the first session

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