Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Scheduling complication updates

I have a custom complication on Apple Watch that I am trying to get to update once an hour. Each hour it should ping an API endpoint and if the data has changed from the last check, the complication should be updated.

Here is what I currently have that only seems to work once in a blue moon. When it DOES work, it does indeed ping my server and update the complication. It appears WatchOS just isn't calling my scheduled tasked once per hour. Is there a better standard practice that I'm missing?

@implementation ExtensionDelegate

- (void)applicationDidFinishLaunching {
    // Perform any final initialization of your application.
    [SessionManager sharedManager];

    [self scheduleHourlyUpdate];
}

- (void) scheduleHourlyUpdate {
    NSDate *nextHour = [[NSDate date] dateByAddingTimeInterval:(60 * 60)];
    NSDateComponents *dateComponents = [[NSCalendar currentCalendar]
    components: NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour | NSCalendarUnitMinute | NSCalendarUnitSecond fromDate:nextHour];

    [[WKExtension sharedExtension] scheduleBackgroundRefreshWithPreferredDate:nextHour userInfo:nil scheduledCompletion:^(NSError * _Nullable error) {
        // schedule another one in the next hour
        if (error != nil)
            NSLog(@"Error while scheduling background refresh task: %@", error.localizedDescription);
    }];
}

- (void)handleBackgroundTasks:(NSSet<WKRefreshBackgroundTask *> *)backgroundTasks {
    // Sent when the system needs to launch the application in the background to process tasks. Tasks arrive in a set, so loop through and process each one.
    for (WKRefreshBackgroundTask * task in backgroundTasks) {
        // Check the Class of each task to decide how to process it
        if ([task isKindOfClass:[WKApplicationRefreshBackgroundTask class]]) {
            // Be sure to complete the background task once you’re done.
            WKApplicationRefreshBackgroundTask *backgroundTask = (WKApplicationRefreshBackgroundTask*)task;
            [backgroundTask setTaskCompletedWithSnapshot:NO];
            [self updateComplicationServer];
        } else if ([task isKindOfClass:[WKSnapshotRefreshBackgroundTask class]]) {
            // Snapshot tasks have a unique completion call, make sure to set your expiration date
            WKSnapshotRefreshBackgroundTask *snapshotTask = (WKSnapshotRefreshBackgroundTask*)task;
            [snapshotTask setTaskCompletedWithDefaultStateRestored:YES estimatedSnapshotExpiration:[NSDate distantFuture] userInfo:nil];
        } else if ([task isKindOfClass:[WKWatchConnectivityRefreshBackgroundTask class]]) {
            // Be sure to complete the background task once you’re done.
            WKWatchConnectivityRefreshBackgroundTask *backgroundTask = (WKWatchConnectivityRefreshBackgroundTask*)task;
            [backgroundTask setTaskCompletedWithSnapshot:NO];
        } else if ([task isKindOfClass:[WKURLSessionRefreshBackgroundTask class]]) {
            // Be sure to complete the background task once you’re done.
            WKURLSessionRefreshBackgroundTask *backgroundTask = (WKURLSessionRefreshBackgroundTask*)task;
            [backgroundTask setTaskCompletedWithSnapshot:NO];
        } else if ([task isKindOfClass:[WKRelevantShortcutRefreshBackgroundTask class]]) {
            // Be sure to complete the relevant-shortcut task once you’re done.
            WKRelevantShortcutRefreshBackgroundTask *relevantShortcutTask = (WKRelevantShortcutRefreshBackgroundTask*)task;
            [relevantShortcutTask setTaskCompletedWithSnapshot:NO];
        } else if ([task isKindOfClass:[WKIntentDidRunRefreshBackgroundTask class]]) {
            // Be sure to complete the intent-did-run task once you’re done.
            WKIntentDidRunRefreshBackgroundTask *intentDidRunTask = (WKIntentDidRunRefreshBackgroundTask*)task;
            [intentDidRunTask setTaskCompletedWithSnapshot:NO];
        } else {
            // make sure to complete unhandled task types
            [task setTaskCompletedWithSnapshot:NO];
        }
    }
}

- (void)updateComplicationServer {    
    [self scheduleHourlyUpdate];

    NSString *nsLogin = [NSUserDefaults.standardUserDefaults objectForKey:@"loginDTO"];

    if (nsLogin != nil)
    {
        NSDateComponents *dateComponents = [[NSCalendar currentCalendar]
        components: NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay fromDate:[NSDate date]];

        LoginDTO *login = new LoginDTO([nsLogin cStringUsingEncoding:NSUTF8StringEncoding]);

        NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://www.myurl.com/Api/Watch/Complication"]];
        [req setHTTPMethod:@"GET"];

        // Set headers
        [req addValue:[NSString stringWithUTF8String:login->GetApiKey()] forHTTPHeaderField:@"MySessionKey"];
        [req addValue:[NSString stringWithFormat:@"%d,%d,%d", dateComponents.year, dateComponents.month, dateComponents.day] forHTTPHeaderField:@"FetchDate"];

        [req addValue:@"application/json" forHTTPHeaderField:@"Content-Type"];

        NSURLSession *session = [NSURLSession sharedSession];
        NSURLSessionDataTask *task = [session dataTaskWithRequest:req completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error)
        {
            // Call is complete and data has been received
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse*)response;
            if (httpResponse.statusCode == 200)
            {
                NSString* nsJson = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];

                NSString *prevJson = [NSUserDefaults.standardUserDefaults objectForKey:@"previousComplicationJson"];

                if (prevComplicationJson != nil)
                {
                    if ([prevComplicationJson isEqualToString:nsJson])
                        return; // Nothing changed, so don't update the UI.
                }

                // Update the dictionary
                [NSUserDefaults.standardUserDefaults setObject:nsJson forKey:@"previousComplicationJson"];

                CLKComplicationServer *server = [CLKComplicationServer sharedInstance];
                for (int i = 0; i < server.activeComplications.count; i++)
                    [server reloadTimelineForComplication:server.activeComplications[i]];
            }
        }];

        [task resume];

        delete login;
    }
}
like image 267
user1054922 Avatar asked Feb 07 '20 03:02

user1054922


1 Answers

watchOS background tasks are extraordinarily cumbersome to implement and debug, but based on Apple’s documentation and implementations that others have discussed, here‘s what I believe to be the best practice. I see a couple of issues here.

First, from the WKRefreshBackgroundTask docs:

The system suspends the extension as soon as all background tasks are complete.

Calling setTaskCompletedWithSnapshot on the task indicates to the system that you’ve finished performing all the work you need to do, so it will suspend your app. Your updateComplicationServer method is probably never getting a chance to run because the system suspends your extension too early.

More importantly, to make URL requests during a background update, you’ll need to use a background URL session. The example process outlined in the WKRefreshBackgroundTask docs shows the best practice for setting this up. In short:

  1. You schedule a background refresh using WKExtension’s scheduleBackgroundRefresh just like you’re doing.
  2. The system will wake your extension sometime after your preferred date (at the system’s discretion) with a WKRefreshBackgroundTask.
  3. In your extension delegate’s handle method, check for the WKApplicationRefreshBackgroundTask; instead of performing the request with a URLSessionDataTask here, you need to schedule a background URL session so that the system can suspend your extension and perform the request on your behalf. See the WKURLSessionRefreshBackgroundTask docs for details about how background sessions should be set up.
  4. The system will perform your URL request in a separate process, and again wake your extension once it has finished. It will call your extension delegate’s handle method like before, this time with a WKURLSessionRefreshBackgroundTask. Here, you need to do two things:

    • Save the background task in an instance variable on your extension delegate. We don’t want to set it complete just yet, but we need to keep it around to set complete later when the URL request finishes.
    • Create another background URL session using the background task’s sessionIdentifier, and use your extension delegate as the session’s delegate (why it doesn’t work to use another object as the delegate, I can’t say, but it seems to be a crucial detail). Note that using the same identifier to create a second URL session allows the system to connect the session to the download that it performed for you in another process; the purpose of this second background URL session is solely to connect the delegate with the session.
  5. In the session delegate, implement both the urlSession(_ downloadTask: didFinishDownloadingTo:) and urlSession(task: didCompleteWithError:) functions.

    Unlike your block-based NSURLSessionDataTask, background URL requests are always performed as download tasks. The system performs the request and gives you a temporary file with the resulting data. In the urlSession(_ downloadTask: didFinishDownloadingTo:) function, the data in that file and process it as needed to update your UI.

    Finally, in the delegate’s urlSession(task: didCompleteWithError:) function, call setTaskCompletedWithSnapshot to tell the system that you’ve finished your work. Phew.

As I mentioned, this is all really frustrating to debug, mostly because it’s all up to the system when these things actually take place, if they happen at all. Apple’s documentation has this to say about the budget allocated to background refreshes:

In general, the system performs approximately one task per hour for each app in the dock (including the most recently used app). This budget is shared among all apps on the dock. The system performs multiple tasks an hour for each app with a complication on the active watch face. This budget is shared among all complications on the watch face. After you exhaust the budget, the system delays your requests until more time becomes available.

One final note: legend has it that the watchOS simulator doesn’t handle background URL refresh tasks properly, but unfortunately, Apple’s docs have no official word on that. Best to test on Apple Watch hardware if you‘re able.

like image 143
John Wickham Avatar answered Sep 27 '22 21:09

John Wickham