We have an existing iOS app (developed for a client) that uses Core Location to report location updates to their servers (so they can keep track of their executives). When the app determines that the phone has remained in the same location for several minutes, it stops the location updates to conserve battery life and then uses region monitoring to determine when the phone has left that location (at which point, location updates are started again).
This client recently started issuing Apple Watches to their executives and they’ve asked if we could implement similar functionality in a watchOS app. Based on my initial research into watchOS, it seems that a lot of the mechanisms available in iOS that allow us to do this sort of thing are not supported in watchOS. Region monitoring isn't. Neither are significant-change location updates or visit monitoring. The location manager instance property pausesLocationUpdatesAutomatically doesn’t exist on watchOS, either. I thought we might be able to use the Core Motion activity manager to detect when the device is no longer stationary but it looks like updates from Core Motion aren’t delivered to the app while its suspended.
So the only other possibility that I can think of would be to use HealthKit to start a workout to track if the user is moving. But I’m not sure that would address the situation where the user gets into a vehicle, for example. We'd really like to avoid having to have location updates turned on all the time because of the impact on battery life. Is there another mechanism that I’m not aware of in watchOS that could be leveraged to detect when the watch has transitioned from being stationary to moving again while 1) the app is suspended and 2) doesn’t rely on location updates to be turned on all the time?
Post
Replies
Boosts
Views
Activity
I’m working on an independent watchOS app which is primarily designed to to collect and periodically send location updates to a server. The UI features a toggle that allows the user to turn this capability on or off at their discretion. The typical use case scenario would be for the user to turn the toggle on in the morning, put the app in the background and then go about their day.
Given the limitations and restrictions regarding background execution on watchOS, in an ideal situation, I would be able to upload the stored location updates about every 15-20 minutes. With an active complication on the watch face, it’s my understanding that this should be possible. I’ve implemented background app refresh and indeed, I do see this reliably being triggered every 15-20 minutes or so.
In my handle(_:) method, I process the WKApplicationRefreshBackgroundTask like this:
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
backgroundTasks.forEach { task in
switch task {
case let appRefreshBackgroundTask as WKApplicationRefreshBackgroundTask:
// start background URL session to upload data; watchOS will perform the request in a separate process so that it will continue to run even if our app gets
// terminated; when the system is done transferring data, it will call this method again and backgroundTasks will contain an instance of
// WKURLSessionRefreshBackgroundTask which will be processed below
startBackgroundURLSessionUploadTask()
scheduleNextBackgroundAppRefresh()
appRefreshBackgroundTask.setTaskCompletedWithSnapshot(false)
case let urlSessionTask as WKURLSessionRefreshBackgroundTask:
// add urlSessionTask to the pendingURLSessionRefreshBackgroundTasks array so we keep a reference to it; when the system completes the upload and
// informs us via a URL session delegate method callback, then we will retrieve urlSessionTask from the pendingURLSessionRefreshBackgroundTasks array
// and call .setTaskCompletedWithSnapshot(_:) on it
pendingURLSessionRefreshBackgroundTasks.append(urlSessionTask)
// create another background URL session using the background task’s sessionIdentifier and specify our extension as the session’s delegate; using the same
// identifier to create a second URL session allows the system to connect the session to the upload that it performed for us in another process
let configuration = URLSessionConfiguration.background(withIdentifier: urlSessionTask.sessionIdentifier)
let _ = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}
And here is how I'm creating and starting the background URL session upload task:
func startBackgroundURLSessionUploadTask() {
// 1. check to see that we have locations to report; otherwise, just return
// 2. serialize the locations into a temporary file
// 3. create the background upload task
let configuration = URLSessionConfiguration.background(withIdentifier: Constants.backgroundUploadIdentifier)
configuration.isDiscretionary = false
configuration.sessionSendsLaunchEvents = true
let backgroundUrlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
let request: URLRequest = createURLRequest() // this is a POST request
let backgroundUrlSessionUploadTask = backgroundUrlSession.uploadTask(with: request, fromFile: tempFileUrl)
backgroundUrlSessionUploadTask.countOfBytesClientExpectsToSend = Int64(serializedData.count) // on average, this is ~1.5 KB
backgroundUrlSessionUploadTask.countOfBytesClientExpectsToReceive = Int64(50) // approximate size of server response
backgroundUrlSessionUploadTask.resume()
}
Note that I'm not setting the .earliestBeginDate property on the backgroundUrlSessionUploadTask because I'd like the upload to start as soon as possible without any delay. Also, this same class (my WatchKit application delegate) conforms to URLSessionTaskDelegate and I have implemented urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:) and urlSession(_:task:didCompleteWithError:).
In my testing (on an actual Apple Watch Ultra running watchOS 9.3.1), I've observed that when the system performs the background app refresh, I always receive a callback to myhandle(_:) method. But when I start the background URL session upload task (in startBackgroundURLSessionUploadTask()), I was expecting that when the upload completes, I'd receive another call to myhandle(_:) method with an instance of WKURLSessionRefreshBackgroundTask but this doesn't seem to happen consistently. Sometimes I do see it but other times, I don't and when I don't, the data doesn't seem to be getting uploaded.
On a side note, most of the time, startBackgroundURLSessionUploadTask() gets called as a result of my code handling a background app refresh task. But when the user turns off the toggle in the UI and I stop the location updates, I need to report any stored locations at that time and so I call startBackgroundURLSessionUploadTask() to do that. In that specific case, the upload seems to work 100% of the time but I definitely don't see a callback to my handle(_:) method when this occurs.
Am I wrong in expecting that I should always be getting a callback to handle(_:) when a background URL session upload task completes? If so, under what circumstances should this occur? Thanks very much!
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'm working on an independent watchOS app and I'm testing on the Apple Watch Ultra simulator as well as a couple of real Apple Watch Ultra devices (both have active cellular subscriptions on AT&T, are within 3 feet of their paired iPhones and connected to WiFi, as well).
My app has an application delegate which implements the applicationDidFinishLaunching() method and in that method, I register with APNs for remote notifications. When I receive the token in didRegisterForRemoteNotifications(withDeviceToken:), I send the token on to the server that is going to send notifications to the app.
When I test this code in the Apple Watch Ultra simulator, it works 100% of the time. When I test the same code on a real Apple Watch Ultra, about 70% of the time, I get the following error message in the Xcode debug console:
2023-05-12 08:32:30.779560-0400 Watch App Prototype[569:586139] PDTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2> finished with error [9] Error Domain=NSPOSIXErrorDomain Code=9 "Bad file descriptor" UserInfo={_kCFStreamErrorCodeKey=9, _kCFStreamErrorDomainKey=1, _NSURLErrorRelatedURLSessionTaskErrorKey=(
"LocalDataPDTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2>",
"LocalDataTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataPDTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2>}
2023-05-12 08:32:30.780401-0400 Watch App Prototype[569:586139] Task <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2> finished with error [9] Error Domain=NSPOSIXErrorDomain Code=9 "Bad file descriptor" UserInfo={_kCFStreamErrorCodeKey=9, _kCFStreamErrorDomainKey=1, _NSURLErrorRelatedURLSessionTaskErrorKey=(
"LocalDataTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2>",
"LocalDataPDTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2>",
"LocalDataTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2>"
), _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <5110B87C-28D7-48C9-9C68-121C7728FF68>.<2>}
The operation couldn’t be completed. Bad file descriptor
I ran across a post from someone else about the same problem on StackOverflow.
I'm not sure what to make of this error but here's the code that I'm using to send the URL request:
func perform(_ urlRequest: URLRequest) async throws -> Data {
let (data, response) = try await urlSession.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.serverSideError(response: nil)
}
guard (200...299).contains(httpResponse.statusCode) else {
throw NetworkError.serverSideError(response: httpResponse)
}
return data
}
For what it's worth, urlSession is the shared URLSession instance. Again, this error never occurs in the simulator but it happens on both of the Apple Watch Ultra devices and I would say it occurs about 70% of the time. Can anyone help me understand this error message?
I updated my Apple Watch Ultra from watchOS 9.5 to 9.5.1 and now when I connect it to Xcode (14.3 from the App Store), it says that "the device is not available because there was an error downloading its symbols". I've rebooted the watch, the iPhone and the MacBook but still no luck. As soon as I connect the phone to the Mac, I see Xcode making the watch available for development but then it fails with this error. Everything was working fine with watchOS 9.5.
Updated my Apple Watch Ultra to watchOS 9.5.2 last night and this morning, Xcode is now unable to download the symbols for it. Same issue happened when watchOS 9.5.1. was released (see https://developer.apple.com/forums/thread/730663). The error message, as before, is "Failed with HTTP status 403: Forbidden".