jimbobuk
jimbobuk

Reputation: 1221

Apple Watch ClockKit Complications don't update their timeline entries if the clock face isn't hidden during execution

Has anyone else noticed a problem with complication entries not updating properly. I've just added some initial support to my app, but noticed that they weren't displaying what i expected them to display. For example's sake, and for ease of testing this issue quickly I'd create a timeline

A -> B -> C
0s   10s  20s

Yet all I'd see was complication entry A staying around past the time that B and C should have displayed.

My normal app itself isn't set to create regularly spaced complications like this, it has many aspects of timers that it exposes that can be set by the user, but one such aspect just allows the user to start multiple timers at once, which all will finish after the user defined durations they choose. Unlike the iOS clock app's timer you're able to specify the timer durations in seconds, and so its perfectly possible that 2 timers would finish within seconds of each other, on the whole though its more likely that they'll be minutes apart. Also there shouldn't be too many complication entries being added, though other more complicated aspects of my app could easily add 10s or even ~100 complication entries depending on how complex a task the user has setup within it. For now though, this simpler example is easier to discuss and test.

I updated Xcode to the latest version (7.3.2) with no improvements, and sent a build to my actual phone and watch and again no improvement. Until once it did work. On further debugging I discovered that i could make the timeline behave itself by simply lowering my watch (to turn off the screen) and then wake it up again, all whilst it was mid executing my timeline. Having done this the timeline would then work properly from then on.

I've created a test app to demonstrate the problem, which does reproduce the problem fully, so I'm going to send it to apple on a bug report. Just thought i'd see if anyone else had noticed this issue.

Also when my test app executes i get the following logging output with an error that doesn't make sense

-[ExtensionDelegate session:didReceiveUserInfo:]:67 - complication.family=1 in activeComplications - calling reloadTimelineForComplication
-[ComplicationController getTimelineStartDateForComplication:withHandler:]:43 - calling handler for startDate=2016-06-15 22:08:26 +0000
-[ComplicationController getTimelineEndDateForComplication:withHandler:]:73 - calling handler for endDate=2016-06-15 22:08:46 +0000
-[ComplicationController getCurrentTimelineEntryForComplication:withHandler:]:148 - calling handler for entry at date=2016-06-15 22:08:26 +0000
-[ComplicationController getTimelineEntriesForComplication:afterDate:limit:withHandler:]:202 - adding entry at date=2016-06-15 22:08:36 +0000; with timerEndDate=2016-06-15 22:08:46 +0000 i=1
getTimelineEntriesForComplication:afterDate:limit:withHandler: -- invalid entries returned. (1 entries before start date 2016-06-15 22:08:46 +0000).  Excess entries will be discarded.

The relevant information from this log is as follows

getTimelineStartDateForComplication - calling handler for startDate=22:08:26
getTimelineEndDateForComplication - calling handler for endDate=22:08:46 
getCurrentTimelineEntryForComplication -  calling handler for entry at date=22:08:26
getTimelineEntriesForComplication:afterDate - adding entry at date=22:08:36
getTimelineEntriesForComplication:afterDate:limit:withHandler: -- invalid entries returned. (1 entries before start date 22:08:46).  Excess entries will be discarded.

Which you can see in the error from the system at the end that it is using the start date of 22:08:46, which was actually what I told Clockkit was my timeline's endDate, NOT the startDate. I'm not sure if this is related to the behaviour i'm seeing as I see the same error when it works after I hide/show the screen.

I've put a video of this behaviour in my test app online here. The details of this test app are as follows

Full code that should just run in the relevant simulators is available here, the relevant complication modules are also listed here for reference.

In my extension delegate, i receive userInfo from the iOS app and schedule a reload of my complication timeline

ExtensionDelegate.m

- (void)session:(WCSession *)session didReceiveUserInfo:(NSDictionary<NSString *, id> *)userInfo
{
  DbgLog(@"");

  WKExtension *extension = [WKExtension sharedExtension];
  DbgLog(@"self=%p; wkExtension=%p; userInfo=%@", self, extension, userInfo);

  self.lastReceivedUserInfo = userInfo;

  CLKComplicationServer *complicationServer = [CLKComplicationServer sharedInstance];
  for (CLKComplication *complication in complicationServer.activeComplications)
  {
    DbgLog(@"complication.family=%d in activeComplications - calling reloadTimelineForComplication", complication.family);
    [complicationServer reloadTimelineForComplication:complication];
  }
}

Then in my ComplicationController are the following methods to handle the complication side of things

ComplicationController.m

#define DbgLog(fmt, ...)    NSLog((@"%s:%d - " fmt), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__)

@interface ComplicationController ()

@end

@implementation ComplicationController

#pragma mark - Timeline Configuration

- (void)getSupportedTimeTravelDirectionsForComplication:(CLKComplication *)complication withHandler:(void(^)(CLKComplicationTimeTravelDirections directions))handler
{
  handler(CLKComplicationTimeTravelDirectionForward|CLKComplicationTimeTravelDirectionBackward);
}

- (void)getTimelineStartDateForComplication:(CLKComplication *)complication withHandler:(void(^)(NSDate * __nullable date))handler
{
  NSDate *startDate;

  WKExtension *extension = [WKExtension sharedExtension];
  assert(extension.delegate);
  assert([extension.delegate isKindOfClass:[ExtensionDelegate class]]);
  ExtensionDelegate *extensionDelegate = (ExtensionDelegate *)extension.delegate;
  if (extensionDelegate.lastReceivedUserInfo)
  {
    NSDictionary *userInfo = extensionDelegate.lastReceivedUserInfo;
    startDate = [userInfo objectForKey:@"date"];
  }

  DbgLog(@"calling handler for startDate=%@", startDate);
  handler(startDate);
}

- (NSDate*)getTimelineEndDate
{
  NSDate *endDate;

  WKExtension *extension = [WKExtension sharedExtension];
  assert(extension.delegate);
  assert([extension.delegate isKindOfClass:[ExtensionDelegate class]]);
  ExtensionDelegate *extensionDelegate = (ExtensionDelegate *)extension.delegate;
  if (extensionDelegate.lastReceivedUserInfo)
  {
    NSDictionary *userInfo = extensionDelegate.lastReceivedUserInfo;
    NSDate *startDate = [userInfo objectForKey:@"date"];
    NSNumber *duration = [userInfo objectForKey:@"duration"];
    NSNumber *count = [userInfo objectForKey:@"count"];

    NSTimeInterval totalDuration = duration.floatValue * count.floatValue;
    endDate = [startDate dateByAddingTimeInterval:totalDuration];
  }

  return endDate;
}

- (void)getTimelineEndDateForComplication:(CLKComplication *)complication withHandler:(void(^)(NSDate * __nullable date))handler
{
  NSDate *endDate=[self getTimelineEndDate];

  DbgLog(@"calling handler for endDate=%@", endDate);
  handler(endDate);
}

- (void)getPrivacyBehaviorForComplication:(CLKComplication *)complication withHandler:(void(^)(CLKComplicationPrivacyBehavior privacyBehavior))handler {
    handler(CLKComplicationPrivacyBehaviorShowOnLockScreen);
}

#pragma mark - Timeline Population

- (CLKComplicationTemplate *)getComplicationTemplateForComplication:(CLKComplication *)complication
                             forEndDate:(NSDate *)endDate
                             orBodyText:(NSString *)bodyText
                             withHeaderText:(NSString *)headerText
{
  assert(complication.family == CLKComplicationFamilyModularLarge);

  CLKComplicationTemplateModularLargeStandardBody *template = [[CLKComplicationTemplateModularLargeStandardBody alloc] init];

  template.headerTextProvider = [CLKSimpleTextProvider textProviderWithText:headerText];
  if (endDate)
  {
    template.body1TextProvider = [CLKRelativeDateTextProvider textProviderWithDate:endDate style:CLKRelativeDateStyleTimer units:NSCalendarUnitHour|NSCalendarUnitMinute|NSCalendarUnitSecond];
  }
  else
  {
    assert(bodyText);
    template.body1TextProvider = [CLKSimpleTextProvider textProviderWithText:bodyText];
  }

  return template;
}

- (CLKComplicationTimelineEntry *)getComplicationTimelineEntryForComplication:(CLKComplication *)complication
                                 forStartDate:(NSDate *)startDate
                                      endDate:(NSDate *)endDate
                                   orBodyText:(NSString *)bodyText
                                   withHeaderText:(NSString *)headerText
{
  CLKComplicationTimelineEntry *entry = [[CLKComplicationTimelineEntry alloc] init];
  entry.date = startDate;
  entry.complicationTemplate = [self getComplicationTemplateForComplication:complication forEndDate:endDate orBodyText:bodyText withHeaderText:headerText];

  return entry;
}

- (void)getCurrentTimelineEntryForComplication:(CLKComplication *)complication withHandler:(void(^)(CLKComplicationTimelineEntry * __nullable))handler
{
  // Call the handler with the current timeline entry
  CLKComplicationTimelineEntry *entry;
  assert(complication.family == CLKComplicationFamilyModularLarge);

  WKExtension *extension = [WKExtension sharedExtension];
  assert(extension.delegate);
  assert([extension.delegate isKindOfClass:[ExtensionDelegate class]]);
  ExtensionDelegate *extensionDelegate = (ExtensionDelegate *)extension.delegate;
  if (extensionDelegate.lastReceivedUserInfo)
  {
    NSDictionary *userInfo = extensionDelegate.lastReceivedUserInfo;
    NSDate *startDate = [userInfo objectForKey:@"date"];
    NSNumber *duration = [userInfo objectForKey:@"duration"];
    //NSNumber *count = [userInfo objectForKey:@"count"];

    NSTimeInterval totalDuration = duration.floatValue;
    NSDate *endDate = [startDate dateByAddingTimeInterval:totalDuration];

    entry = [self getComplicationTimelineEntryForComplication:complication forStartDate:startDate endDate:endDate orBodyText:nil withHeaderText:@"current"];
  }

  if (!entry)
  {
    NSDate *currentDate = [NSDate date];
    entry = [self getComplicationTimelineEntryForComplication:complication forStartDate:currentDate endDate:nil orBodyText:@"no user info" withHeaderText:@"current"];
  }

  DbgLog(@"calling handler for entry at date=%@", entry.date);
  handler(entry);
}

- (void)getTimelineEntriesForComplication:(CLKComplication *)complication beforeDate:(NSDate *)date limit:(NSUInteger)limit withHandler:(void(^)(NSArray<CLKComplicationTimelineEntry *> * __nullable entries))handler
{
  NSArray *retArray;
  assert(complication.family == CLKComplicationFamilyModularLarge);

  WKExtension *extension = [WKExtension sharedExtension];
  assert(extension.delegate);
  assert([extension.delegate isKindOfClass:[ExtensionDelegate class]]);
  ExtensionDelegate *extensionDelegate = (ExtensionDelegate *)extension.delegate;
  if (extensionDelegate.lastReceivedUserInfo)
  {
    NSDictionary *userInfo = extensionDelegate.lastReceivedUserInfo;
    NSDate *startDate = [userInfo objectForKey:@"date"];
    if ([startDate timeIntervalSinceDate:date] < 0.f)
    {
      assert(0);
      // not expected to be asked about any date earlier than our startDate
    }
  }

  // Call the handler with the timeline entries prior to the given date
  handler(retArray);
}

- (void)getTimelineEntriesForComplication:(CLKComplication *)complication afterDate:(NSDate *)date limit:(NSUInteger)limit withHandler:(void(^)(NSArray<CLKComplicationTimelineEntry *> * __nullable entries))handler
{
  NSMutableArray *timelineEntries = [[NSMutableArray alloc] init];
  assert(complication.family == CLKComplicationFamilyModularLarge);

  WKExtension *extension = [WKExtension sharedExtension];
  assert(extension.delegate);
  assert([extension.delegate isKindOfClass:[ExtensionDelegate class]]);
  ExtensionDelegate *extensionDelegate = (ExtensionDelegate *)extension.delegate;
  if (extensionDelegate.lastReceivedUserInfo)
  {
    NSDictionary *userInfo = extensionDelegate.lastReceivedUserInfo;
    NSDate *startDate = [userInfo objectForKey:@"date"];
    NSNumber *duration = [userInfo objectForKey:@"duration"];
    NSNumber *count = [userInfo objectForKey:@"count"];

    NSInteger i;
    for (i=0; i<count.integerValue && timelineEntries.count < limit; ++i)
    {
      NSTimeInterval entryDateOffset = duration.floatValue * i;
      NSDate *entryDate = [startDate dateByAddingTimeInterval:entryDateOffset];

      if ([entryDate timeIntervalSinceDate:date] > 0)
      {
    NSDate *timerEndDate = [entryDate dateByAddingTimeInterval:duration.floatValue];

    DbgLog(@"adding entry at date=%@; with timerEndDate=%@ i=%d", entryDate, timerEndDate, i);

    CLKComplicationTimelineEntry *entry = [self getComplicationTimelineEntryForComplication:complication forStartDate:entryDate endDate:timerEndDate orBodyText:nil withHeaderText:[NSString stringWithFormat:@"After %d", i]];
    [timelineEntries addObject:entry];
      }
    }

    if (i==count.integerValue && timelineEntries.count < limit)
    {
      NSDate *timelineEndDate = [self getTimelineEndDate];
      CLKComplicationTimelineEntry *entry = [self getComplicationTimelineEntryForComplication:complication forStartDate:timelineEndDate endDate:nil orBodyText:@"Finished" withHeaderText:@"Test"];
      [timelineEntries addObject:entry];
    }
  }

  NSArray *retArray;

  if (timelineEntries.count > 0)
  {
    retArray = timelineEntries;
  }

  // Call the handler with the timeline entries after to the given date
  handler(retArray);
}

#pragma mark Update Scheduling

/*
 // don't want any updates other than the ones we request directly
- (void)getNextRequestedUpdateDateWithHandler:(void(^)(NSDate * __nullable updateDate))handler
{
  // Call the handler with the date when you would next like to be given the opportunity to update your complication content
  handler(nil);
}
*/

- (void)requestedUpdateBudgetExhausted
{
  DbgLog(@"");

}

#pragma mark - Placeholder Templates

- (void)getPlaceholderTemplateForComplication:(CLKComplication *)complication withHandler:(void(^)(CLKComplicationTemplate * __nullable complicationTemplate))handler
{
  CLKComplicationTemplate *template = [self getComplicationTemplateForComplication:complication forEndDate:nil orBodyText:@"doing nothing" withHeaderText:@"placeholder"];
  // This method will be called once per supported complication, and the results will be cached
  handler(template);
}

@end

Perhaps you can see if you have the same problems with your own complications in your own apps.

I don't think I'm doing anything wrong, nothing that should cause this odd behaviour, just feels like a bug to me. Unfortunately its one that undermines my app which can work with quite small scale timeline entries in some cases and I'd rather not have them just not work if a user pays attention and keeps the watch screen on whilst testing it out.

Thanks for your time,

Cheers

Upvotes: 2

Views: 1216

Answers (1)

user4151918
user4151918

Reputation:

The error about an entry before the start date is accurate, and the entry is correctly discarded.

The reason why is that getTimelineEntriesForComplication:afterDate: is meant to return future entries after the specified date. What you did was return an entry before the specified date.

The starting date for providing future entries. The dates for your timeline entries should occur after this date and be as close to the date as possible.

Without any posted code to examine, I'd guess that your beforeDate:/afterDate: conditional code is reversed.

Other issues regarding multiple timeline entries per minute:

Regarding the ten second time interval, I can point out several issues for you to consider:

  1. The complication server will request a limited number of entries.

    Providing your entries will always be ten seconds apart, and that the server requested 100 entries (either in the past or the future direction), that amounts to 1000 seconds worth (under 17 minutes). This introduces two problems in general:

    • Given the short span of the timeline, the timeline would need to be updated several times an hour. This is not energy efficient, and you may exhaust your daily complication budget if you extend or reload it too often.

    • With such a short duration, time travel is effectively useless, as rotating the digital crown could quickly travel more than 16 minutes into the (past or) future, where there would not (initially) be any further entries to display.

  2. The complication server caches a limited number of entries.

    Even if you extend your timeline, the server will eventually prune (discard) entries from an extended timeline. Again assuming a 10-second interval, the complication server would only be able to keep about 2 hours worth of entries cached. Furthermore, you have no control over which entries would be discarded.

  3. From a time travel perspective, 5 of every 6 timeline entries would never be displayed while time traveling (since time travel changes by minutes).

    This would certainly present some confusion to the user, as they would not be able to view every entry.

A note about update limits:

While you could add multiple entries per minute, you may want to rethink whether that is practical. Considering that most users won't observe most of the frequent changes, it's likely a lot of wasted effort for little gain.

Regarding energy efficiency, you should also consider the hard limits that Apple imposes for updating the complication. Even in watchOS 3 (where you're encouraged to keep the complication and dock snapshot regularly updated in the background), you'll run up against limits of 4 background updates per hour, and 50 complication updates per day. See watchOS - Show realtime departure data on complication for specifics.

Upvotes: 1

Related Questions