Apple Watch Complication Truncation

Tappd That ships an Apple Watch complication showing how many distinct beers you’ve consumed, along with a ring indicating how close you are to receiving your next distinct beer badge from Untappd. I recently ran into an appearance bug showing these values with the ring where they would be truncated with an ellipse. Originally, I had code to show an image if the value was four digits or larger, but that wasn’t enough. It looked pretty awful.

Truncated Apple Watch utility ring complication
Truncated Apple Watch utility ring complications.

Unfortunately, Apple doesn’t publish the font size of the text in complications, and the font cannot be adjusted by the developer. Fortunately for you, I’m pretty good at trial and error, and I employed the (otherwise useless IMHO) time-travel feature to do some testing. You can measure the width of your font with boundingRectWithSize:options:attributes: and branch to display an image in your ring complication instead of text.

The good news is that the four-digits-or-larger trick seems to work for CLKComplicationTemplateModularSmallRingText and CLKComplicationTemplateCircularSmallRingText, as these rings are a bit larger. On CLKComplicationTemplateUtilitarianSmallRingText the maximum width before truncation appears to be 23.6 when measured with [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]. This was tested with watchOS 2 only.

Desired behavior when the the text would be truncated.
Desired behavior when the text would be truncated.

As for the larger complications, I may update this blog post at some point, but I will leave those widths as an exercise to the reader. In my opinion, if you have something long displayed in these fairly wide complications, it’s likely something that was user-generated (e.g., an event name), and in that case, you do want the system provided truncation.

Desired behavior when the text will fit.
Desired behavior when the text will fit.

Shared Library Debugging under El Capitan

In my day job, I do a lot of debugging of apps that use libraries in non-standard locations. This has traditionally been performed by exporting DYLD_LIBRARY_PATH in your environment and then debugging your app as normal.

$ export DYLD_LIBRARY_PATH=/path/to/lib/dir
$ lldb ./myapp
(lldb) r myapp_arg1 myapp_arg2

Under El Capitan, dyld environment variables are purged when spawning processes, thanks to System Integrity Protection. This means that you’ll have to use lldb to set the variable at debug-time.

$ lldb ./myapp
# 'r' and 'run' are aliases to 'process launch -X true --'
(lldb) process launch -v DYLD_LIBRARY_PATH=/path/to/lib/dir -X true -- myapp_arg1 myapp_arg2

If you always use the same shared library path, you might be able to make new aliases in your .lldbinit.

Cargo Cult App Store

Several months ago, Apple announced the new Apple TV, a long-awaited update for their TV puck. The new Apple TV finally allowed anyone to write apps for the device, which could be distributed through the App Store. Apple practically gave away prototype devices, and so I was quick to request one. I decided this would be a good opportunity to play around with Swift as well as to learn a new Apple SDK. After overcoming issues with the beta API, I had a finished a bare-bones hangman game. A couple days before the device was available to the public, I uploaded the code to GitHub and announced it on Twitter.

I didn’t submit the game to the App Store – I made it as a learning experience and didn’t put much effort into it. App review guidelines specifically say:

If your App looks like it was cobbled together in a few days…please brace yourself for rejection.

Imagine my surprise when on Apple TV launch day, I see it in the App Store. That surprise turned to anger when I noticed I wasn’t mentioned. I released this app’s source code publicly, but under the BSD license, letting you do almost anything you want with the code, so long as you retain the original copyright notice. I didn’t want to be responsible for another app. I was practically begging someone to take this minimum viable product and run with it. Let someone else make millions hundreds while I focus on apps that I want to make, just follow the terms of the license. Not only that, but I didn’t create the word lists that power the game, and those authors deserve credit as well.

Within a few hours of e-mailing the developer of the offending app, the app had been removed from the App Store. This wasn’t the response I was expecting, but it’s certainly a valid solution to the problem. I took some time off from my day job the next morning, whipped up some awful graphics, and shipped my own version of the app.

The cargo cult just didn’t seem to stop. Within a few weeks, still more versions of my app were popping up on the App Store without attribution, even going so far as to use derivatives of those novice graphics I made. More e-mails were sent to developers, and those apps were removed from the store as a result.

Yet another Hangman TV cargo cult app
Nice hills.

How does Apple continue to let this happen? What has happened to App Store review? As developers, we are forced to wait for a week or more while some invisible Apple judge reviews our software, to deem it worthy (or not) for public consumption. This is for every update we do, from a brand new app, to a quick bug fix update. And yet, it takes me all of a second to type the word “hangman” into the App Store search box and see multiple apps with identical screenshots. What oversight at Apple is allowing this to happen?

There’s also the open source license. It would take no effort for the developer of an offending app to include a mention of my name where required, or even be so bold as to write an e-mail to me. Not a single developer has. In some cases, I don’t even hear back from the offender after the initial contact. In my opinion, it is incredibly slimy to just download the code to an app, change the name, and re-upload it for sale, but the software license allows that so long as it has been attributed. And yet, no one does – they either take full credit or they don’t ship an app. Why? When did collaboration become a bad thing?


There’s also the app template marketplace that I’ve recently learned about. For just $18, you too can have an attribution-free license to the source code of an Apple TV hangman app. From a quick search of the App Store, many people are scooping up this code, and in some cases, not even bothering to change the app icon artwork. I suppose we saw this before with Flappy Bird clones, but at least the sprites were changed before they shipped.

Hangman app marketplace

While I’m not against people selling feature-complete app source code to customers, I am against Apple letting identical apps live in the the App Store. This creates nothing but confusion for a potential customer. If you want to play a simple game of hangman with your child, which of the following near-identical apps do you pick?

Apple TV search results for "hangman."
So many options…

Programmers all start learning somewhere, which is why open source is such a great thing. There’s more to programming than code, and young developers need to learn about licenses as well. Get excited about the code you’re copying and pasting. Learn what it does. Make some changes. Open source those changes yourself (if allowed) or push them back to the original developer. Have fun and be an entrepreneur, but more importantly, learn.

Identification Please — Introducing Twenty One

Two weeks ago, I went over to Dogfish Head’s website to check the availability of their 20th anniversary beer. Before I could do anything, a familiar dialog appeared over the content of the website, forcing me to input my date of birth. I’ve seen these age gates (in the parlance of web developers) hundreds of times before, but it really bothered me this time. I was on my iPhone in an area with really bad data service, and having to type into the birthday field made the page zoom into crazy dimensions. Not to mention, I knew that I’d have to submit this form that would execute a JavaScript and re-download the entire page again. Now my birthday is sitting on my phone somewhere in a cookie unsecured.

This is not good UX, and it’s not legally required in the USA.

I drafted a tweet saying I needed Crystal for brewery websites, and then thought to myself I can make Crystal for brewery websites pretty quickly…so I never posted the tweet, drove home empty handed, and wrote Twenty One.

Twenty One makes use of WebKit content blockers to block JavaScript and hide parts of websites in an efficient manner. The app downloads a constantly-updated list in the background and applies the rules to Safari on iOS. The same rules can be used in Safari on OS X via an extension, but currently the entire extension must be updated. Because it only uses content blockers, not all websites can have their age gates blocked, such as those that make use of cookies and redirects. I’ve publicly shamed documented those breweries.

Twenty One is free for iOS and OS X, and is additionally open-source. If you’d like to support continued development, head on over to GitHub and request your favorite brewery, submit a pull request with your blocking rules, or check out my iOS Untappd client, Tappd That.

Untappd Knows Where You Live

Yesterday, Untappd rolled out version 2.3.7 of their app for iOS. There were a couple neat features added like “Check-in Alerts” and (unfortunately) a quarter-star rating system. One new feature caught my eye, the ability to go back to old check-ins and add a venue. But wait…how does Untappd know what venues you were near when you checked in?

As it says on the knowledge base page, “When you check-in a beer and have enabled geolocation, your checkins are geotagged, just like your photos are Geotagged with Instagram.” The difference between Instagram geotagging and Untappd geotagging is that Instagram gives you an option to toggle location services and notifies you quite visibly when location tracking has been enabled. With Untappd, so long as location services are enabled, your exact GPS coordinates are transmitted.

On a fresh install of Untappd, you are immediately presented with the location services dialog. At this point, you haven’t even typed in your username or password for Untappd. Notice the lack of a reason string, which is now required as of iOS 8. While this omission was hopefully an honest mistake, developers making use of iOS 8 deployment targets would not be so lucky — location services do not work without it.

Untappd Location Services Dialog
Why do you want my location? Can’t I sign in first?

If you were naïve enough to accept, and you go about your business, your GPS coordinates will be transferred to Untappd whenever you check in. Here’s the data that gets POSTed. There’s no indication that my location was sent, and no venue (foursquare_id) was tagged. As a user, I have no reason to expect that my location (in this case, my home address) has been sent to a server somewhere.

Untappd Check-In Parameters
Data that gets sent when checking in a beer to Untappd.

Interestingly enough, as soon as I checked in, I was presented a promotion for $5 off tickets to the DC Craft Beer Festival. Relevant to my interests, sure, but a naïve user is probably wondering how Untappd knew that they live anywhere near this venue. Creepy.

Untappd Promotion for DC Craft Beer Festival
Hey Untappd, how do you know where I live?

Displaying this advertisement promotion shows the clear reason why check-ins are now being geotagged — target advertising. We all knew this day was coming. I’m not upset about it, but it sure would be nice to be warned and have the option to disable GPS tracking, especially as a paying supporter. There is no indication that location is being tracked in Untappd’s Terms of Use (last updated 2010!) and their Privacy Policy says that location is only used to “statistical purposes,” whatever that means. Sounds like something needs to change.

Shameless plug: if you definitely don’t want to be tracked but don’t want to have to play the Settings > Privacy > Location game every time you want to tag a venue, download my app Tappd That and use it to check in beers to Untappd.

As for me…

Untappd Location Settings
My new location settings for Untappd.

Don’t Block the (Text)Box

Sometimes the location where the user is trying to type on the screen is directly underneath where the on-screen keyboard will appear. Apple has provided a solution to this, but it doesn’t work out-of-the-box these days. With iOS 8 and custom keyboard extensions just around the corner, it’d be a good idea to take a look at how you prevent view obstruction in your apps.Don't Block the Box

First things first, your text field should be within a UIScrollView. There are other ways to fix the problem, but a UIScrollView is the easiest. Then, simply check that the bottom of the view you are typing into is visible in the non-obstructed section of the view.

Start by having your UIViewController listen for the NSNotification that the keyboard has been displayed and will be hidden. Some properties we will need include:

@property (nonatomic, assign) BOOL scrollViewInitialValuesSaved;
@property (nonatomic, assign) UIEdgeInsets scrollViewInsets;
@property (nonatomic, assign) UIEdgeInsets scrollViewIndicatorInsets;
@property (nonatomic, assign) CGPoint scrollViewOffset;
 
@property (nonatomic, weak) IBOutlet UIScrollView *scrollView
- (void)viewDidLoad
{
	[super viewDidLoad];
	self.scrollViewInitialValuesSaved = NO;
}
 
- (void)viewWillAppear:(BOOL)animated
{
	[super viewWillAppear:animated];
 
	[[NSNotificationCenter defaultCenter] addObserver:self
						 selector:@selector(keyboardWasShown:)
						     name:UIKeyboardDidShowNotification
						   object:nil];
	[[NSNotificationCenter defaultCenter] addObserver:self
						 selector:@selector(keyboardWillBeHidden:)
						     name:UIKeyboardWillHideNotification
						   object:nil];
}
 
- (void)viewWillDisappear:(BOOL)animated
{
	[super viewWillDisappear:animated];
 
	[[NSNotificationCenter defaultCenter] removeObserver:self
						        name:UIKeyboardDidShowNotification
						      object:nil];
	[[NSNotificationCenter defaultCenter] removeObserver:self
						        name:UIKeyboardWillHideNotification
						      object:nil];
}

Then, the interesting part.

- (void)keyboardWasShown:(NSNotification *)notification
{
	/*
	 * Save away the original values of the UIScrollView. These might not be 0,
	 * in the event that you have a UITabBar, UINavigationBar, etc.
	 */
	if (!self.scrollViewInitialValuesSaved) {
		self.scrollViewInsets = self.scrollView.contentInset;
		self.scrollViewIndicatorInsets = self.scrollView.scrollIndicatorInsets;
		self.scrollViewOffset = self.scrollView.contentOffset;
 
		self.scrollViewInitialValuesSaved = YES;
	}
 
	/* Look at the ending frame, not the beginning frame to adjust for iOS 8 QuickType bar */
	CGFloat keyboardHeight = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue].size.height;
 
	/*
	 * Adjust the UIScrollView insets to account for the keyboard's height.
	 */
	self.scrollView.contentInset = UIEdgeInsetsMake(self.scrollViewInsets.top,
							self.scrollViewInsets.left,
							self.scrollViewInsets.bottom + keyboardHeight,
							self.scrollViewInsets.right);
	self.scrollView.scrollIndicatorInsets = UIEdgeInsetsMake(self.scrollViewIndicatorInsets.top,
								 self.scrollViewIndicatorInsets.left,
								 self.scrollViewIndicatorInsets.bottom + keyboardHeight,
								 self.scrollViewIndicatorInsets.right);
 
	/*
	 * Check if the keyboard is covering the bottom right corner of the control in question.
	 */
	CGRect contentRect = self.view.frame;
	contentRect.size.height -= keyboardHeight;
	CGPoint textFieldPoint = CGPointMake(CGRectGetMaxX(/* control */.frame), CGRectGetMaxY(/* control */.frame));
	/* The UIScrollView could have already been scrolled */
	textFieldPoint.y -= self.scrollView.contentOffset.y;
	if (!CGRectContainsPoint(contentRect, textFieldPoint)) {
		/* View is obstructed, have the UIScrollView scroll the view to visibility. */
		CGPoint scrollPoint = CGPointMake(0.0, CGRectGetMaxY(/* control */.frame) - contentRect.size.height);
		[self.scrollView setContentOffset:scrollPoint animated:YES];
	}
}
 
- (void)keyboardWillBeHidden:(NSNotification *)notification
{
	/*
	 * Back to the original resting position.
	 */
	[UIView animateWithDuration:0.3 animations:^() {
		self.scrollView.contentInset = self.scrollViewInsets;
		self.scrollView.scrollIndicatorInsets = self.scrollViewIndicatorInsets;
	}];
	[self.scrollView setContentOffset:self.scrollViewOffset animated:YES];
 
}

There’s a few things to note about where Apple’s solution went wrong:

  • The insets that get saved away could change at any point, especially if you leave a view and come back to it without dismissing the keyboard.
  • Only the top-left of the view was questioned as to whether or not the entire view is obstructed.
  • The implementation assumed that the UIScrollView had never been scrolled.
  • The height of the keyboard can change without redisplaying the keyboard under iOS 8, which makes the relative calculations based on UIKeyboardFrameBeginUserInfoKey incorrect.
  • Custom insets were ignored, which is problematic, especially considering UIViewController‘s
    automaticallyAdjustsScrollViewInsets
    property.

If you want to automatically track which UITextFields are being obstructed, remember that you can do so by assigning a property when a UITextField‘s textFieldDidBeginEditing: delegate method is called.

International Date Formatting Gotchas

Any iOS developer can tell you that you should always be formatting dates with an NSDateFormatter object (or the underlying C equivalent strptime, if you’re extremely concerned about speed). In Tappd That, I constantly need to parse dates that come across the wire in the format “Thu, 09 Jan 2014 12:03:00 +0000”. This is trivial using an NSDateFormatter, since I can just refer to the Unicode Technical Standard #35, revision 25 to set a dateFormat of @"EEE, dd MMM yyyy HH:mm:ss Z". Perfect, right?

Wrong. So wrong. This works as you’d expect it to 90% of the time, but fails miserably elsewhere…that is, if you don’t set a locale for the format of the string to be parsed. In Tappd That’s case, strings will always be retrieved from a web service serving US English style dates (ignoring HTTP’s Accept-Language header). Many countries use the English abbreviations for months and days of the week despite the Language setting on the phone. It’s the Region Format that you care about. Consider the following:

static NSDateFormatter *dateFormatter = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
	dateFormatter = [NSDateFormatter new];
	dateFormatter.formatterBehavior = NSDateFormatterBehavior10_4;
	dateFormatter.dateFormat = @"EEE, dd MMM yyyy HH:mm:ss Z";
});
 
NSDate *date = [dateFormatter dateForString:@"Thu, 09 Jan 2014 12:03:00 +0000"];
NSLog(@"%@", [NSDateFormatter localizedStringFromDate:date
                                            dateStyle:NSDateFormatterShortStyle
                                            timeStyle:NSDateFormatterShortStyle]);

With the Region Format set to “United States” (selected via Settings > General > International > Region Format), the code above prints “1/9/14, 7:03 AM“. “France” prints “09/01/14 07:03,” and now you think you’re an Internationalization expert since your code made the day come first. I sure did. Some random format you’ve never heard of? Comes out like the US. But what about “Danish (Denmark)?” (null). Yeah, didn’t see that one coming, did you? 

As I explained, the problem is that the NSDateFormatter is expecting the regional equivalents for the month and day of the week, even though the Language setting is English. The solution here is to add a locale. Since I know my dates coming from the web service will always be in English, I can set the locale as follows (or use strptime_l with NULL for the en_US_POSIX default):

dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];

It’s somewhat important to use the en_US_POSIX locale instead of just en_US. While they’ll both work currently (2014), if the United States ever decided to change their date format, en_US would return nil again, while en_US_POSIX would continue to work. Apple has a Technical Q&A about this topic that should be required reading for anyone formatting dates internationally. So why did the random country work? That’s in the documentation: “Note that many of the country codes do not have any supporting locale data in iOS.”

But wait, there’s more! You also need to account for alternate Calendars! Not all of us are in the year 2014 AD. The Buddhists are currently living in 2556 BE. In my case, I know that dates will always be coming from an English Gregorian calendar and the timezone is GMT (I parse that above with Z, but it’s always +0000. You think they’d just omit it.).

NSCalendar *gregorianCalendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
gregorianCalendar.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
gregorianCalendar.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"GMT"];
dateFormatter.calendar = gregorianCalendar;

While you’re thinking internationally, you also want to make sure that you’re using the currentLocale of the user’s device when printing dates to make sure they’re printed natively (this is the default for new NSDateFormatters, as you’ve discovered from my troubles above). This way, your Danish (Denmark) users can see “torsdag den 9. januar 2014 07.03.00 Eastern-normaltid” instead of “(null).”

Retrieving Response Body via AFNetworking with an HTTP Error Status Code

I use AFNetworking as a convenience layer to the NSURLSession classes in most iOS apps that would traditionally have a non-trivial amounts of networking code. I tend to not like using much third-party code, but the way Mattt Thompson has structured AFNetworking and the great community backing the project have led me to make an exception. AFNetworking makes it trivial to interact with the now-common JSON and XML-based APIs right out of the box. 

There are several schools of thought regarding transmitting error message via web APIs. Many APIs simply return an error HTTP status code (4XX, 5XX) in the header of the response. Others will return a successful status code, but the body of the response will contain an error message that the client must parse. Others, like Foursquare and Untappd, use a hybrid approach — an HTTP error status code is set in the header and the body contains a JSON object with the error message. In AFNetworking 1.x  (in the NSURLConnection classes), the NSError parameter of the failure block of AFHTTPClient convenience methods would report the body of the failed response in their userInfo property’s NSLocalizedRecoverySuggestionErrorKey key. This could be considered a bit of an abuse of NSError, but it worked well. As of 2.x (in the NSURLSession classes), this functionality was not implemented and there is no way out of the box to retrieve error messages sent from a web service via AFNetworking. 

Continue reading

301 HTTP Redirects and iOS Caching

I just learned the hard way that iOS permanently caches HTTP 301 (“Moved Permanently”) redirect responses from web servers. This makes sense, given that 301s are reserved for permanent redirections. As a result, I ended up permanently blocking access to my application’s web-based components for anyone who had my app open at the time of the change. The problem was that I was merely testing something, and blocking one entire subdomain was a bug in my configuration.

The good news is that the caching appears to be on a per-app basis, since Safari could access my website but my apps could not. The even better news is that you can easily program your way around this caching. If you’re using the new NSURLSession classes (and you are, right?), you can simply set the requestCachePolicy of your NSURLSessionConfiguration variable to NSURLRequestReloadIgnoringLocalCacheData.

NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
sessionConfiguration.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfiguration];