Fixing Simulator Status Bars for App Store Screenshots With Xcode 11 and iOS 13

Today Apple released the Xcode 11 GM release, meaning that it’s now time to create our builds using the iOS 13 SDK and submit to the App Store! If you’re like me and you use Fastlane’s Snapshot tool to automate the creation of your App Store screenshots using Xcode’s UI testing infrastructure, then you may have noticed something that broke with this new SDK: using SimulatorStatusMagic to clean up the simulator’s status bar. I use it on Landmarked, a side project I have at Detroit Labs, to make the App Store screenshots just a little nicer:

A screenshot of Landmarked

As you can tell, the status bar doesn’t say “Carrier,” the battery is full (important if you’re developing on a laptop), and the time has been set. In this case, since Landmarked is about Detroit, I set the time to 3:13 PM, but you may want to set it to 9:41 AM as per tradition. Doing this with SimulatorStatusMagic is easy:

#if targetEnvironment(simulator)
SDStatusBarManager.sharedInstance().bluetoothState = .hidden
SDStatusBarManager.sharedInstance().batteryDetailEnabled = false
SDStatusBarManager.sharedInstance().timeString = "3:13 PM"
SDStatusBarManager.sharedInstance().enableOverrides()
#endif

I use the #if targetEnvironment(simulator) bit to ensure that this doesn’t run if you’re running the UI tests on a real device, as it isn’t supported. Up until now, this has worked great. Of course, you should clean this up when you’re done—otherwise, these overrides persist across app launches on the running simulator. The above code runs in the test case’s setUp() method, and in the tearDown() method, you can clean up your work:

override func tearDown() {
    super.tearDown()
    #if targetEnvironment(simulator)
    SDStatusBarManager.sharedInstance().disableOverrides()
    #endif
}

So how do we do this on Xcode 11? With SimulatorStatusMagic not working, the first step is to avoid running it on iOS 13:

override func setUp() {
    #if targetEnvironment(simulator)
    if #available(iOS 13.0, *) {
        
    }
    else {
        SDStatusBarManager.sharedInstance().bluetoothState = .hidden
        SDStatusBarManager.sharedInstance().batteryDetailEnabled = false
        SDStatusBarManager.sharedInstance().timeString = "3:13 PM"
        SDStatusBarManager.sharedInstance().enableOverrides()
    }
    #endif
}
override func tearDown() {
    super.tearDown()

    #if targetEnvironment(simulator)
    if #available(iOS 13.0, *) {
        
    }
    else {
        SDStatusBarManager.sharedInstance().disableOverrides()
    }
    #endif
}

Looking at the release notes for Xcode 11, there’s a new simctl command to control the simulator status bar, aptly named status_bar. At the command line, to replicate the above behavior, we’d call it like this:

xcrun simctl status_bar booted override \
    --time "3:13 PM" \
    --dataNetwork wifi \
    --wifiMode active \
    --wifiBars 3 \
    --cellularMode notSupported \
    --batteryState discharging \
    --batteryLevel 100

But if you want to run this automatically, where to put it? You can’t call out to Process on iOS, but you need to call something on your Mac to run this command. Fortunately, Xcode has a way to do this by adding a pre-action to your test scheme:

A screenshot of Xcode editing a scheme’s pre-run actions

And of course, just as before, you can use a post-run action with the clear command to reset everything:

A screenshot of Xcode editing a scheme’s post-run actions

It works! Huzzah! Of course, there are still some things we can do better. If you run these UI tests on a device, and you have a simulator open, then that simulator will get these overrides while it’s running thanks to the booted parameter passed to simctl. Also, this approach doesn’t work when automating with Fastlane. To avoid that, we can use the TARGET_DEVICE_IDENTIFIER parameter to specifically boot and configure the device that the tests will run on (if the device is not booted, then simctl will fail to set the overrides). In your pre-action, make sure you’re inheriting build settings from the UI testing target, then add this snippet:

xcrun simctl boot "${TARGET_DEVICE_IDENTIFIER}"

xcrun simctl status_bar "${TARGET_DEVICE_IDENTIFIER}" override \
    --time "3:13 PM" \
    --dataNetwork wifi \
    --wifiMode active \
    --wifiBars 3 \
    --cellularMode notSupported \
    --batteryState discharging \
    --batteryLevel 100

With that, we’re successfully configuring our status bar on the simulator when it runs. Of course, the value of TARGET_DEVICE_IDENTIFIER won’t make sense to simctl if the destination is a device. Let’s add a quick check that we’re running on a simulator, and if not, exit early:

if [[ "${SDKROOT}" != *"simulator"* ]]; then
    exit 0
fi

Perfect! The value of SDKROOT is something like iphonesimulator13.0 when running on a simulator and iphoneos13 when running on a device, so this if statement will call exit when the SDK isn’t a simulator. Next up, we want to avoid running this code when the destination isn’t running at least iOS 13, so to do that, we’ll need to check the target device’s iOS version. Luckily for me, that code was readily available on StackExchange. The final version of our script looks like this:

function version {
    echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }';
}

# Don’t run on iOS devices.
if [[ "${SDKROOT}" != *"simulator"* ]]; then
    exit 0
fi

# Don’t run on iOS versions before 13.
if [ $(version "${TARGET_DEVICE_OS_VERSION}") -ge $(version "13") ]; then
    xcrun simctl boot "${TARGET_DEVICE_IDENTIFIER}"

    xcrun simctl status_bar "${TARGET_DEVICE_IDENTIFIER}" override \
        --time "3:13 PM" \
        --dataNetwork wifi \
        --wifiMode active \
        --wifiBars 3 \
        --cellularMode notSupported \
        --batteryState discharging \
        --batteryLevel 100
fi

Now, if you run your UI tests on an iOS simulator target running anything earlier than iOS 13, the pre-run action will exit early, and SimulatorStatusMagic will take care of it. This is important for our purposes as Landmarked still supports iOS 9 and the iPhone 4S’s 3.5″ screen, so we need to generate a screenshot for that size to be complete.

With this, our pre-run script is complete! The post-run is much simpler:

function version {
    echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }';
}

# Don’t run on iOS devices.
if [[ "${SDKROOT}" != *"simulator"* ]]; then
    exit 0
fi

# Don’t run on iOS versions before 13.
if [ $(version "${TARGET_DEVICE_OS_VERSION}") -ge $(version "13") ]; then
    xcrun simctl boot "${TARGET_DEVICE_IDENTIFIER}"
    xcrun simctl status_bar "${TARGET_DEVICE_IDENTIFIER}" clear
fi

I was able to use the above code to generate all of the screenshots for the iOS 13 version of Landmarked. Keep an eye out for version 1.1.3 as soon as iOS 13 is released! Here’s what it looks like on the new iPhone 11 Pro Max:

A screenshot of Landmarked on an iPhone 11 Pro Max.

Remote Nib Loading for Fun (But Not Profit)

A while ago I noticed an interesting API for creating a UINib object from data:

+ (UINib *)nibWithData:(NSData *)data bundle:(NSBundle *)bundleOrNil

At the time I didn’t have a use for it, until this exchange occurred on Twitter:


The resulting exchange was very fruitful, including this gem from ex-Apple employee Michael Jurewitz:

So I wouldn’t recommend using this in a shipping application, but I wanted to see if it worked. I created a simple app that loads a nib from a website, then tries to initialize a view controller’s view using it. You can view the whole project on GitHub, but here’s the relevant code:

//
//  JKAppDelegate.m
//  RemoteNibLoading
//
//  Created by Jeff Kelley on 1/2/13.
//  Copyright (c) 2013 Jeff Kelley. All rights reserved.
//


#import "JKAppDelegate.h"

#import "JKLoadingViewController.h"
#import "JKRemoteNibViewController.h"


static NSString * const kRemoteNibURL = @"http://www.slaunchaman.com/JKRemoteNibViewController.nib";


@implementation JKAppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [self setWindow:[[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]];
    [[self window] setBackgroundColor:[UIColor whiteColor]];
    [[self window] makeKeyAndVisible];
    
    JKLoadingViewController *loadingViewController = [[JKLoadingViewController alloc] initWithNibName:nil
                                                                                               bundle:nil];
    
    [[self window] setRootViewController:loadingViewController];
    
    // Load the remote nib
    NSURL *remoteNibURL = [NSURL URLWithString:kRemoteNibURL];
    
    NSURLRequest *nibRequest = [NSURLRequest requestWithURL:remoteNibURL
                                                cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
                                            timeoutInterval:60.0];
    
    [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:YES];
    
    [NSURLConnection sendAsynchronousRequest:nibRequest
                                       queue:[NSOperationQueue mainQueue]
                           completionHandler:^(NSURLResponse *response,
                                               NSData *data,
                                               NSError *error) {
                               [[UIApplication sharedApplication] setNetworkActivityIndicatorVisible:NO];

                               if (data) {
                                   UINib *remoteNib = [UINib nibWithData:data bundle:nil];
                                   
                                   if (remoteNib) {
                                       JKRemoteNibViewController *remoteNibViewController =
                                       [[JKRemoteNibViewController alloc] init];
                                       
                                       // Load the nib, which will set up the view controller bindings
                                       [remoteNib instantiateWithOwner:remoteNibViewController options:nil];
                                       
                                       [[self window] setRootViewController:remoteNibViewController];
                                   }
                                   else {
                                       [[[UIAlertView alloc] initWithTitle:@"Error"
                                                                   message:@"Could not create nib from data."
                                                                  delegate:nil
                                                         cancelButtonTitle:@"Aww, shucks."
                                                         otherButtonTitles:nil] show];
                                   }
                               }
                               else {
                                   if (error) {
                                       [[[UIAlertView alloc] initWithTitle:@"Error"
                                                                   message:[error localizedDescription]
                                                                  delegate:nil
                                                         cancelButtonTitle:@"OK"
                                                         otherButtonTitles:nil] show];
                                   }
                               }
                           }];
    
    return YES;
}

@end

Would I recommend using this in a shipping app? Absolutely not, given Jury’s recommendations. But it is an interesting idea for enterprise, in-house, or jailbreak apps, and I can see the possibility for some very cool stuff to come out of it.

Enforcing iOS Security Settings in Third-Party Applications

A while back, I was working on an  application for a client with a very specific requirement. Since it collected personal data, the application could only run on iOS devices that were protected with a passcode. This requirement, seemingly very simple from the client’s perspective, was a bit of a hassle to implement on the programming side of things. There’s no simple method on UIDevice to determine if a passcode is set, nor is there a way to force that programatically. In fact, there’s no way to force most things like that. The iOS device isn’t the programmer’s, it’s the user’s. Except when it isn’t.

One of the things that you can do is to use the iPhone Configuration Utility to make a configuration profile. These profiles can support a range of things, from requiring a passcode (or even an advanced, non-numeric passcode) to WiFi settings, VPN to CardDAV settings. Creating a configuration profile that requires a passcode, then installing that configuration profile onto the device is a no-brainer. But how do you ensure that the application will only run in that case?

Disclaimer: Before I go any further, you should know that since this was an in-house project, none of the code that I wrote made it into the App Store. Therefore, I don’t know if this is kosher in an App Store app, nor do I recommend this approach for that.

One thing that you can do is to include a self-signed certificate in a configuration profile. Those of you familiar with OpenSSL may be groaning as you realize where this horrible, horrible workaround is headed. I created a new certificate authority. With that new certificate authority, I signed a separate certificate that I had created. Verifying this certificate, then, requires that the verifying party accept the certificate authority’s certificate as valid. Well, since you can set that in the configuration profile, I did, along with the passcode requirement. Then, in the app, I bundled the certificate that I had signed with my CA.

When the app starts up, it attempts to verify the certificate. In the case where the configuration profile is installed and the CA’s certificate is in the system’s keychain as trusted, this is no problem: the certificate checks out and my app is free to go. If that validation fails, however, then I know that the certificate from the CA is in the system, so I know that the configuration profile is installed, as well.

Why this works for a passcode so well is that to install a configuration profile on a device without a passcode when the profile requires one is that you can’t install it without setting a passcode in the process. For the client, this was Good Enough, and the app shipped and worked properly. It’s worth noting, though, that the less the end-user knows about this process, the better. To circumvent the passcode restriction, all one would have to do would be to modify the configuration profile to still include the CA’s certificate, but not the pas code requirement. For that reason I can’t recommend this for anything like EMR or tax records, but for minor demographic information like we were collecting, this sufficed.

I realize this didn’t include any code, but the individual portions aren’t that hard, and I don’t have access to the original code so I’d have to re-write them all. Here they are in a nicely-formatted list for those keeping score at home:

  1. Create a new certificate authority with OpenSSL.
  2. Create a new certificate, then sign it with that certificate authority you just created.
  3. Create a configuration profile in the iTunes Configuration Utility with the settings you would like to enforce.
  4. In the “Credentials” section in the iTunes Configuration Utility, add your CA’s public-facing certificate to the configuration profile.
  5. Add the certificate you signed with your CA to your application’s bundle.
  6. In your application, verify the certificate you included.
  7. Distribute the configuration profile along with your application to end users.

Like I said, this is far from perfect. But when you’re working with an enterprise client who has Big Needs, this is one trick to keep in your back pocket when you’re up against a deadline.

Selling “Physical Goods” or “Goods and Services Used Outside of the Application” in an iOS app

Since this question has come up a few times, I thought I’d record my answer to a common question:

Can I use an iPhone app to sell something outside of the App Store?

Reading Apple’s rules for the App Store makes developers nervous. We’re walking on eggshells, hoping that our application will not run afoul of the myriad of regulations put forth by Apple for inclusion into the store. So, questions like this come up. I’ll reproduce my answer here:

Obviously, I am not a lawyer, but I think you’ll be OK. Here’s my interpretation of the three relevant rules from the developer guidelines (emphasis mine):

11.1 Apps that unlock or enable additional features or functionality with mechanisms other than the App Store will be rejected.

11.2 Apps utilizing a system other than the In App Purchase API (IAP) to purchase content, functionality, or services in an app will be rejected.

11.3 Apps using IAP to purchase physical goods or goods and services used outside of the application will be rejected.

The first rule prohibits you from unlocking anything inside of your app with something other than the App Store. This would prevent you from, say, making a game that downloads new levels from your server based on your membership to a website.

The second rule prohibits you from, say, making a game and enabling PayPal in it to unlock more levels. Apple wants you to use in-app purchase for that.

The third rule—and this is where it gets interesting—prohibits you from using in-app purchase in an application to buy “physical goods” or “goods and services used outside of the application.” Nowhere does it say, however, that you can’t use other purchasing systems.

With that third rule, I think what Apple is saying is this: anything that runs on the iPhone must be purchased through the App Store, and everything purchased in the App Store must run on iOS. For something like insurance, which isn’t new functionality in the app, I think you’ll be OK. This is absolutely worth an e-mail to Apple’s technical support staff, but if you look at Amazon’s app, you can purchase physical goods using Amazon’s checkout system.

I’d love to be proven right or wrong on this. If my reading is correct, then using an iOS app as a storefront is perfectly acceptable. If I’m reading this too charitably, then you’re better off making this type of application for a different platform.