Published on

Not all apps are born with a tap

Authors

Introduction

So we recently had a bug in NanoFlick! A user reported logging in and it treated them as... mostly a fresh user. All of their flicks were completely gone, and the intro page showed again a devastating bug.

We got the app container to see how this could've happened and were met with a surprise: the user's documents directory was completely pristine, with the flicks and all the data still there in a very well-kept state. Additionally, fully crashing the app and restarting it didn't reproduce the issue. What gives?

Attempts to reproduce this only yielded fruit when the user ran out of battery while using the app, charged it, and then opened the app again after rebooting the phone. Worse, connecting the phone to the debugger and running the app from Xcode didn't reproduce the issue either, regardless if it was done before or after the reboot.

I had some guesses for why the flick loading was failing, as that was a very complicated issue, but the intro page presentation logic was very very simple and very very early:

 func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    // unrelated initialization and settings

    // Pick whether to go to onboarding based off of initial state
    let hasStarted = UserDefaults.standard.bool(forKey: AppDelegate.INITIAL_RUN_DEFAULT)
    if !hasStarted && !Device.isPreview {
        let swiftUIController = InterfaceLockedHostingView(rootView: TutorialPageView(nfViewController: self.window!.rootViewController!, isSettingsPage: false))
        self.window?.rootViewController = swiftUIController
    }
}

Hard to imagine what could go wrong here. Perhaps UserDefaults was getting corrupted? At the very least, it's definitely necessarily involved, and some googling revealed similar problems. As most of these community mysteries go, our savior is Quinn "The Eskimo" from apple's forums, who pointed us in the direction of app prewarming.

App Prewarming

Basically, iOS 15 introduced a feature where the system can launch your app in the background to speed up the launch time. This is documented as a partial initialization (static initializers, objective-c class initializers, and every non-main entrypoint is called). This is, at this time, incorrect. The system will call UIApplicationMain() and will call application(_:willFinishLaunchingWithOptions:).

The Bug

This is by itself, fine. NanoFlick has no real dependency on any resource like the GPU being available, and the app is designed to be able to handle being backgrounded and foregrounded at any time. This is why in almost every prewarming scenario, NanoFlick's behavior is identical to a normal launch.

Unfortunately, if the app is prewarmed after boot but before the user performs the initial unlock, NanoFlick's UserDefaults, along with the rest of the app's data, is completely inaccessible.

I can't find a way to opt out of prewarming, and I can't find a way to detect if the app was prewarmed. Fortunately, the fix is simple: just don't do anything in application(_:willFinishLaunchingWithOptions:) if the app is prewarmed without the user having unlocked the phone.

func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    precondition(application.isProtectedDataAvailable)
    // rest of initialization
}

Conclusion

The big point here is that application(_:willFinishLaunchingWithOptions:) runs kinda whenever it wants, and you can't really control or test for the conditions.

You probably want to write better than this, but doing so will probably require some substantial overhauls of testing. Hopefully the TCA rewrite will have some graceful way to handle this, but until then, NanoFlick will take a second longer to launch after a reboot.