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 Info.plist had no
- 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 classFBSSceneImpl
. - When we have implemented
application(_:configurationForConnecting:options:)
, the return value of-_mainScene
isnil
!
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!