Is isIdleTimerDisabled disabled?

Like so many articles with leading titles, the answer to this one is simple: no, the idle timer still works fine. But that doesn’t mean we can’t have fun exploring a little along the way!

This topic came up while introducing support for iOS 13 application scenes to an existing app. This app supported back to iOS 11, and used the UIApplication property isIdleTimerDisabled shortly after launch to keep the phone awake as long as it was in the foreground.

After adding scene support, though, this stopped working. The app seemed to become subject to the idle timer again, with no discernible change in the code that disabled the timer in the first place. This post is the story of finding the problem, and the straightforward change that fixes it.

Before

First things first: what did the app look like when it worked? The key pieces were quite simple, and many of them could be found by the dozens in sample apps and on StackOverflow:

  • The deployment target was iOS 11.1
  • There was no existing scene support
    • The Info.plist had no UIApplicationSceneManifest key
    • The app delegate did not implement application(_:configurationForConnecting:options:)
  • The app disabled the idle timer immediately on launch

The code for this last point is about as short as it gets:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    UIApplication.shared.isIdleTimerDisabled = true
    // … a few other small pieces of configuration …
    return true
}

Notably, this was the only reference to the isIdleTimerDisabled property in the entire codebase. Before adding scene support, this had worked for years, including on the latest and greatest iOS — version 13.5, at time of writing.

In the process of tracing through the changes to come, I mirrored the behavior in a sample application. This post has links directly to different commits in the history of the sample; start with the first version, and follow the evolution of the app along with the post.

Test Note: Running applications under the debugger through Xcode will automatically disable the idle timer. When building or testing the app, make sure to install it, then launch it “by hand” on a test device. The effects will be most obvious if the “Auto-Lock” setting is 30 seconds.

During

With a working application in hand, let’s see how things break when scenes come into play.

For various reasons, the “real” app needed to configure scene support programmatically, without any configurations in the scene manifest. We can mirror that in the sample application easily enough: create a scene delegate class, and implement the “configuration for connecting scene” application delegate method.

class AppDelegate: UIApplicationDelegate {
    @available(iOS 13.0, *)
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let configuration = UISceneConfiguration(name: "Main", sessionRole: .windowApplication)
        configuration.sceneClass = UIWindowScene.self
        configuration.delegateClass = SceneDelegate.self
        configuration.storyboard = UIStoryboard(name: "Main", bundle: nil)
        return configuration
    }
}

class SceneDelegate: NSObject, UIWindowSceneDelegate {
    var window: UIWindow?
}

The app also needs to create a scene manifest, in order to inform UIKit that it should call the scene configuration method at all — but we don’t include any configurations, to force UIKit to obtain all the scene details from our runtime implementation.

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <true/>
</dict>

This version of the sample still runs and shows the expected user interface. However, it allows the phone screen to dim and lock with the Auto-Lock setting — not the behavior we expected, since the did-finish-launching delegate method still turns on isIdleTimerDisabled!

Diagnostics

So what’s different in how these two apps interact with UIKit? While the isIdleTimerDisabled property is on the UIApplication singleton, the lifecycle of the sample app does change significantly after adding scene support. We need a little more information to start debugging.

The first thing to do, then, is to make sure the flag at least appears to behave the same. Setting a breakpoint in application(_:didFinishLaunchingWithOptions:) lets us confirm that the property at least stays set:

(lldb) p application.isIdleTimerDisabled
(Bool) $R0 = false
// … step over the call that sets the idle timer disabled flag …
(lldb) p application.isIdleTimerDisabled
(Bool) $R2 = true

This calls for some stronger medicine. Now, it’s highly discouraged to use private methods on UIKit objects in shipping applications — but checking a few values here and there in debugging can be very informative. In this case, we’re interested in the private UIApplication method -_mainScene. (I’ll be interspersing some Objective-C for a moment, since it’s significantly easier to call – and talk about – these nonpublic methods in a more dynamic language.)

In this case, we can use that same breakpoint to check the return value of -[UIApplication _mainScene] right around the time we attempt to disable the idle timer. Interestingly, the mere presence of our scene configuration method changes this return value:

  • When we have not implemented application(_:configurationForConnecting:options:), the return value of -_mainScene is a non-nil object of private class FBSSceneImpl.
  • When we have implemented application(_:configurationForConnecting:options:), the return value of -_mainScene is nil!

Taking this one step further, we can set a symbolic breakpoint on -[UIApplication _mainScene] and see when it gets called. There are several times it hits during app launch, but one of them is noticeable: the private ObjC setter for the idle timer, -_setIdleTimerDisabled:forReason:, makes a call through to -_mainScene.

Coupled with the different return values, we can make a reasonable assumption – not an assertion, but a good guess – that we need a main scene before the idle timer can safely be disabled. This means a possible fix is close at hand: we can delay our call to the idle timer until after the first scene becomes active, by which time we would reasonably expect -_mainScene to be non-nil.

After

With all this debugging and guessing out of the way, we can test our fix by changing a single line of code in the sample app. Along with setting isIdleTimerDisabled = true inside the app delegate, we can copy that one call into the scene delegate, at the point that the scene is connected.

class SceneDelegate: NSObject, UIWindowSceneDelegate {
    var window: UIWindow?
    
    @available(iOS 13.0, *)
    func sceneDidBecomeActive(_ scene: UIScene) {
        UIApplication.shared.isIdleTimerDisabled = true
    }
}

Remember that this app targets iOS 11.1, so we need to keep the idle timer call in the application delegate as well. This way, when the app runs on devices before iOS 13 and the scene delegate isn’t involved, the idle timer can still be disabled as usual. What’s more, we need to mark this scene delegate method as available only on iOS 13 and up in order to avoid a compiler error.

Despite these couple of wrinkles, this change has the effect we want! When running (outside of Xcode), the sample app stays open and keeps the phone awake indefinitely.

This behavior has been filed with Apple as FB7717620. Until a fix, hopefully this information can help other apps that are adding scene support. Enjoy!