Independent watchOS app stuck in Core Location authorization loop?

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.

Answered by bmt22033 in 751458022

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.

I've opened a DTS ticket to get some clarification on this but while I wait to hear from them, I've done some additional investigation and have observed something interesting. Xcode has a watchOS Simulator titled "Apple Watch Ultra (49 mm)". When I deploy the code to this simulator, it behaves as I would expect it to. That is to say, after granting "always" authorization in the second prompt, I get one more callback to locationManagerDidChangeAuthorization(_:) which indicates manager.authorizationStatus == .authorizedAlways and then the authorization prompts cease. I am now able to start location updates and receive them with the app in both the foreground as well as the background. Opening the Settings app on the watch simulator and navigating to Privacy & Security → Location Services, I see the app listed and it indicates that it has "Always" access to location data. Tapping that row shows that the settings ("Never", "Ask or When I Share", "While Using the App" and "Always") are still disabled but again, "Always" is selected and the app appears to function properly.

There is another simulator in Xcode (listed under iOS Simulators) titled "Apple Watch Ultra (49 mm) via iPhone 14 Pro Max". If I deploy the code to that simulator, I experience the behavior I described above in my original post. This is the same behavior I encounter if I deploy the code to a real Apple Watch Ultra that's paired with my iPhone. So now I'm wondering if, despite being an independent watchOS app, this behavior is somehow related to the watch trying to use location data from the paired iPhone? As I said above, the location authorization prompts only appear on the watch (I've tried keeping the iPhone nearby and unlocked when running the watch app but still don't see any location authorization prompts on the phone). Could the prompts on the watch (after the first series of prompts, I mean), actually be asking for location authorization on the paired iPhone? If that's the case, I guess I'm still not sure why my selection(s) in the authorization prompts aren't being applied to the phone but at least it would give me something to go on.

Accepted Answer

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.

Independent watchOS app stuck in Core Location authorization loop?
 
 
Q