I'm working on an independent (standalone) watchOS app which requires access to precise location data in both the foreground and the background (so it needs "Always" access). I created the app as a brand new independent app using Xcode 14.3. The app target has the "location updates" background mode enabled and both the NSLocationWhenInUseUsageDescription
and NSLocationAlwaysAndWhenInUseUsageDescription
keys have been declared in Info.plist.
The view is quite simple:
import SwiftUI
struct ContentView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
VStack {
Button(action: {
viewModel.startLocationUpdates()
}) {
Text("Start location updates")
}
Button(action: {
viewModel.stopLocationUpdates()
}) {
Text("Stop location updates")
}
}
.disabled(viewModel.locationUpdateManager.locationAccessGranted == false)
.padding()
}
}
The view model is also very simple:
import Foundation
extension ContentView {
@MainActor final class ViewModel: ObservableObject {
let locationUpdateManager = LocationUpdateManager.shared
func startLocationUpdates() { locationUpdateManager.startLocationUpdates() }
func stopLocationUpdates() { locationUpdateManager.stopLocationUpdates() }
}
}
And finally, I have a simple implementation of a location update manager singleton:
import CoreLocation
class LocationUpdateManager: NSObject, CLLocationManagerDelegate, ObservableObject {
public static let shared = LocationUpdateManager()
@Published var locationAccessGranted: Bool = false
private var locationManager = CLLocationManager()
private override init() {
super.init()
locationManager.allowsBackgroundLocationUpdates = true
locationManager.delegate = self // triggers callback to locationManagerDidChangeAuthorization(_:)
}
func startLocationUpdates() {
locationManager.startUpdatingLocation()
}
func stopLocationUpdates() {
locationManager.stopUpdatingLocation()
}
// MARK: - CLLocationManagerDelegate methods
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
locationAccessGranted = false
switch manager.authorizationStatus {
case .notDetermined:
print("User has not chosen whether the app can use location services or not; requesting \"when in use\" authorization now")
manager.requestWhenInUseAuthorization()
case .authorizedWhenInUse:
print("User granted \"when in use\" authorization; requesting \"always\" authorization now")
manager.requestAlwaysAuthorization()
case .authorizedAlways:
print("User granted \"always\" authorization")
switch manager.accuracyAuthorization {
case .fullAccuracy:
print("User granted access to location data with full accuracy")
locationAccessGranted = true
case .reducedAccuracy:
print("User granted access to location data with reduced accuracy")
default:
print("Warning: unhandled CLAccuracyAuthorization value!")
}
case .denied:
print("User denied access to location services or they are disabled globally in Settings")
case .restricted:
print("App is not authorized to use location services and the user is restricted from changing this for some reason")
default:
print("Warning: unhandled CLAuthorizationStatus value!")
}
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Location manager reported an error: \(error.localizedDescription)")
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
print(location.debugDescription)
}
}
I'm testing the app on an Apple Watch Ultra running watchOS 9.4 as well as the Apple Watch Series 8 simulator (also running watchOS 9.4). When I assign the delegate to the CLLocationManager instance, it obviously triggers a callback to locationManagerDidChangeAuthorization(_:).
The first time locationManagerDidChangeAuthorization(_:)
is called, the authorization status is .notDetermined
(as expected) and my code requests "when in use" authorization. The authorization prompt appears on the watch with the following options:
- Precise location toggle (enabled by default)
- Allow Once
- Allow While Using App
- Don't Allow
I select "Allow While Using App" and then the second callback to locationManagerDidChangeAuthorization(_:)
occurs and the authorization status is now .authorizedWhenInUse
(again, as expected). So my code now requests "always" authorization and now the second authorization prompt appears on the watch with the following options:
- Keep Only While Using
- Change to Always Allow
I select "Change to Always Allow" and then the third callback to locationManagerDidChangeAuthorization(_:)
occurs and the authorization status is now .authorizedAlways
(which is what I'd expect).
At this point, my app should have the appropriate location authorizations. But here's where something unexpected happens. I'm getting another callback to locationManagerDidChangeAuthorization(_:)
which indicates an authorization status of .notDetermined
again! And then this entire process continues as I described above. Over and over and over as if it's stuck in a loop. I've been through the same authorization prompts at least a dozen times so it seems like it never ends.
I opened the Settings app on the watch and navigated to Privacy & Security → Location Services → MyApp and there, I see the following:
- Never
- Ask or When I Share ← this is selected
- While Using the App
- Always
I have no idea why or how "Ask or When I Share" was selected but it is. Furthermore, all of these settings appear to be disabled and can't be changed. Even though this is an independent watchOS app and the authorization prompts are appearing (only) on the watch, I decided to look in the Settings app on the iPhone that's paired to this watch. There, I see the same settings but they're enabled here and selecting "Always" here causes the dialogs on the watch to cease.
Is that really how this is supposed to work for an independent watchOS app? I'm hoping that I'm just missing something here because prompting the user to provide authorization on the watch but then getting stuck in a loop and having to go to the iPhone to "fix" it seems like a pretty confusing UX.
I heard back from DTS on this issue and they told me that what I'm seeing is due to "undefined behavior" after requesting 'always' authorization for location data. They said that since none of the monitoring APIs in Core Location (like the significant change location service, region monitoring, etc.) that actually require 'always' authorization are supported on watchOS, the authorization prompt that appears after I request 'always' authorization is "mostly a place holder and ends up causing the undefined behavior" that I observed.
They further stated that if my goal is simply to continue receiving location updates when the app is in the background, that 'when in use' authorization is sufficient for that purpose as long as the app still declares the background location update mode and the location manager instance has its .allowsBackgroundLocationUpdates property set to true. However, the call to .startUpdatingLocation() must occur while the app is in the foreground. Finally, they added that it is not possible to stop and restart location updates while an app is in the background on watchOS.
It's still not clear to me why this behavior differs when the code is run on the "Apple Watch Ultra (49 mm)" simulator vs the "Apple Watch Ultra (49 mm) via iPhone 14 Pro Max" simulator or on an actual Apple Watch Ultra. But regardless, it sounds like developers should not be requesting 'always' authorization for location data on watchOS and, that being the case, Apple should probably update their docs accordingly.