Search code examples
swiftcore-locationwatchosprivacyios-permissions

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 extremely 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 install and launch the app, the view creates an instance of the view model which then creates the singleton instance of my LocationUpdateManager class. When that class is initialized, it configures the location manager and sets that class as the delegate which 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 is 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! 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:

"Allow Location Access"

  • 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. Initially, I thought I had a bug in my logic inside locationManagerDidChangeAuthorization(_:) but after having researched that further, I'm not sure that's the case.

Now 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. When I open Settings -> Privacy and Security -> Location Services -> MyApp, I see the same settings as above but here, the settings are enabled and I can change them. I changed "Ask Next Time Or When I Share" to "Always" on the iPhone, waited a second and then killed/relaunched the app on the watch and I no longer get the authorization prompts.

So while I'm thankful to no longer get stuck in this "authorization dialog loop" on the watch, I'm very confused as to why this was necessary? If I remove the app from the watch (via the watch app on the iPhone) and then re-install the app to the watch again via Xcode, I'm right back where I started. In researching this problem, I've run across some posts from 5 or 6 years ago saying that it wasn't possible to authorize location access directly from the watch and that the dialogs would only appear on the phone when it's unlocked. But I gather that that was prior to the introduction of independent/standalone watch apps.

I tried keeping the iPhone nearby and unlocked when I reinstalled and ran the app on the watch again but even then, I only get the authorization prompts on the watch itself. But for some reason, it seems like I am unable to get watchOS to remember/honor the authorizations that I select through those dialogs on the watch. The only way I've been able to break out of this loop is to manually change the location authorization level on the iPhone. Is that really how this is supposed to work for an independent watchOS app? I'm really 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.

--- UPDATE ---

I've opened a DTS ticket with Apple 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.


Solution

  • I finally heard back from Developer Technical Support 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 went on to say that since none of the monitoring APIs in Core Location that actually require 'always' authorization (like the significant change location service, region monitoring, etc.) 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 was experiencing.

    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 to indicate this.