diff --git a/WordPress/Classes/System/UITesting/UITestConfigurator.swift b/WordPress/Classes/System/UITesting/UITestConfigurator.swift index 6a73c02697a9..7803b365c7b0 100644 --- a/WordPress/Classes/System/UITesting/UITestConfigurator.swift +++ b/WordPress/Classes/System/UITesting/UITestConfigurator.swift @@ -7,7 +7,10 @@ struct UITestConfigurator { CommandLine.arguments.contains("-ui-testing") } - static func prepareApplicationForUITests(in app: UIApplication, window: UIWindow) { + /// Applies the process-scoped part of the UI-test setup: flag parsing and, when + /// requested, wiping persistent state. Must run at the very start of the launch + /// sequence, before anything reads the flags or touches Core Data and user defaults. + static func prepareApplicationForUITests() { let arguments = CommandLine.arguments if arguments.contains("-ui-testing") { flags.insert(.disableLogging) @@ -23,7 +26,7 @@ struct UITestConfigurator { } if arguments.contains("-ui-test-disable-animations") { flags.insert(.disableAnimations) - disableAnimations(in: app, window: window) + UIView.setAnimationsEnabled(false) } if arguments.contains("-ui-test-use-mock-data") { flags.insert(.useMockData) @@ -37,11 +40,13 @@ struct UITestConfigurator { } } - /// This method will disable animations and speed-up keyboad input if command-line arguments includes "NoAnimations" - /// It was designed to be used in UI test suites. To enable it just pass a launch argument into XCUIApplicaton. - private static func disableAnimations(in app: UIApplication, window: UIWindow) { - UIView.setAnimationsEnabled(false) - window.layer.speed = MAXFLOAT + /// Applies the window-scoped part of the UI-test setup. Runs whenever the main + /// window is created, which (under the scene life cycle) happens at scene connect, + /// after `prepareApplicationForUITests()` already parsed the flags. + static func prepareWindowForUITests(_ window: UIWindow) { + if isEnabled(.disableAnimations) { + window.layer.speed = MAXFLOAT + } } private static func resetEverything() { diff --git a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift index 8290d07fab7e..d5e7f389c5dc 100644 --- a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift +++ b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift @@ -4,8 +4,10 @@ import WordPressData import WordPressShared @objc extension WordPressAppDelegate { - public func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - + /// Routes an inbound URL (custom scheme deep links, magic-login, migration, OAuth). + /// Called by `WordPressSceneDelegate` for inbound URLs. + @discardableResult + func handle(url: URL) -> Bool { let redactedURL = LoggingURLRedactor.redactedURL(url) DDLogInfo("Application launched with URL: \(redactedURL)") @@ -19,10 +21,13 @@ import WordPressShared } /// WordPress only. Handle deeplink from JP that requests data export. - let wordPressExportRouter = MigrationDeepLinkRouter(urlForScheme: URL(string: AppScheme.wordpressMigrationV1.rawValue), - routes: [WordPressExportRoute()]) + let wordPressExportRouter = MigrationDeepLinkRouter( + urlForScheme: URL(string: AppScheme.wordpressMigrationV1.rawValue), + routes: [WordPressExportRoute()] + ) if AppConfiguration.isWordPress, - wordPressExportRouter.canHandle(url: url) { + wordPressExportRouter.canHandle(url: url) + { wordPressExportRouter.handle(url: url) return true } @@ -59,7 +64,8 @@ import WordPressShared private func handleViewPost(url: URL) -> Bool { guard let params = url.queryItems, let blogId = params.intValue(of: "blogId"), - let postId = params.intValue(of: "postId") else { + let postId = params.intValue(of: "postId") + else { return false } RootViewCoordinator.sharedPresenter.showReader(path: .post(postID: postId, siteID: blogId)) @@ -71,7 +77,8 @@ import WordPressShared guard let params = url.queryItems, let siteId = params.intValue(of: "siteId"), - let blog = try? Blog.lookup(withID: siteId, in: ContextManager.shared.mainContext) else { + let blog = try? Blog.lookup(withID: siteId, in: ContextManager.shared.mainContext) + else { return false } @@ -110,7 +117,8 @@ import WordPressShared private func handleDebugging(url: URL) -> Bool { guard let params = url.queryItems, let debugType = params.value(of: "type"), - let debugKey = params.value(of: "key") else { + let debugKey = params.value(of: "key") + else { return false } @@ -131,8 +139,9 @@ import WordPressShared /// This is mostly a return of the old functionality: https://github.com/wordpress-mobile/WordPress-iOS/blob/d89b7ec712be1f2e11fb1228089771a25f5587c5/WordPress/Classes/ViewRelated/System/WPTabBarController.m#L388``` private func handleNewPost(url: URL) -> Bool { guard let params = url.queryItems, - let contentRaw = params.value(of: NewPostKey.content) else { - return false + let contentRaw = params.value(of: NewPostKey.content) + else { + return false } let title = params.value(of: NewPostKey.title) @@ -156,7 +165,10 @@ import WordPressShared RootViewCoordinator.sharedPresenter.rootViewController.present(postVC, animated: true, completion: nil) - WPAppAnalytics.track(.editorCreatedPost, withProperties: [WPAppAnalyticsKeyTapSource: "url_scheme", WPAppAnalyticsKeyPostType: "post"]) + WPAppAnalytics.track( + .editorCreatedPost, + withProperties: [WPAppAnalyticsKeyTapSource: "url_scheme", WPAppAnalyticsKeyPostType: "post"] + ) return true } @@ -169,8 +181,9 @@ import WordPressShared /// text. May support other formats, such as HTML or Markdown in the future. private func handleNewPage(url: URL) -> Bool { guard let params = url.queryItems, - let contentRaw = params.value(of: NewPostKey.content) else { - return false + let contentRaw = params.value(of: NewPostKey.content) + else { + return false } let title = params.value(of: NewPostKey.title) @@ -183,7 +196,12 @@ import WordPressShared // Should more formats be accepted be accepted in the future, this line would have to be expanded to accomodate it. let contentEscaped = contentRaw.escapeHtmlNamedEntities() - RootViewCoordinator.sharedPresenter.showPageEditor(blog: blog, title: title, content: contentEscaped, source: "url_scheme") + RootViewCoordinator.sharedPresenter.showPageEditor( + blog: blog, + title: title, + content: contentEscaped, + source: "url_scheme" + ) return true } @@ -198,7 +216,7 @@ import WordPressShared private extension Array where Element == URLQueryItem { func value(of key: String) -> String? { - return self.first(where: { $0.name == key })?.value + self.first(where: { $0.name == key })?.value } func intValue(of key: String) -> Int? { @@ -213,8 +231,9 @@ private extension URL { var queryItems: [URLQueryItem]? { guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), let queryItems = components.queryItems, - !queryItems.isEmpty else { - return nil + !queryItems.isEmpty + else { + return nil } return queryItems } diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index 285d6949daa9..22c2ab37bf20 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -36,9 +36,15 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { @objc lazy var windowManager: WindowManager = { - guard let window else { - fatalError("The App cannot run without a window.") - } + if window == nil { + // Reachable only when a code path builds UI before any scene has connected + // (e.g. during a background launch); such paths should be guarded instead. + // Recover by creating the window now: `WordPressSceneDelegate` attaches it to + // the scene once one connects. + assertionFailure("windowManager accessed before any scene connected") + window = UIWindow(frame: UIScreen.main.bounds) + } + let window = window! return switch BuildSettings.current.brand { case .wordpress: WindowManager(window: window) case .jetpack: JetpackWindowManager(window: window) @@ -95,18 +101,14 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { ]) } - let window = UIWindow(frame: UIScreen.main.bounds) - self.window = window - WordPressAPIInternal.setupLogger(appId: Bundle.main.bundleIdentifier!) DesignSystem.FontManager.registerCustomFonts() AssertionLoggerDependencyContainer.logger = AssertionLogger() - UITestConfigurator.prepareApplicationForUITests(in: application, window: window) + UITestConfigurator.prepareApplicationForUITests() // The following extensive logging configuration detects if extensive logging is enabled internally. wpkURLSessionNotifyingDelegate = PulseNetworkLogger() - AppAppearance.overrideAppearance() MemoryCache.shared.register() MediaImageService.migrateCacheIfNeeded() PostCoordinator.shared.delegate = self @@ -117,27 +119,98 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // Configure WPCom API overrides configureWordPressComApi() - configureWordPressAuthenticator() - ReachabilityUtils.configure() configureSelfHostedChallengeHandler() updateFeatureFlags() updateRemoteConfig() - window.makeKeyAndVisible() - // Restore a disassociated account prior to fixing tokens. AccountService(coreDataStack: ContextManager.shared).restoreDisassociatedAccountIfNecessary() - customizeAppearance() configureAnalytics() - runStartupSequence(with: launchOptions ?? [:]) + runStartupSequence() return true } + /// Opts the app into the UIScene life cycle and names the scene delegate. Doing + /// this in code (instead of a UIApplicationSceneManifest in each Info.plist) + /// keeps the configuration in one place for all three app targets. The unit test + /// host runs TestingAppDelegate, which doesn't implement this method, so tests + /// keep the legacy life cycle. + public func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + configuration.delegateClass = WordPressSceneDelegate.self + return configuration + } + + /// Whether `showInitialUI(in:)` has already built the UI. The scene can disconnect + /// and reconnect while the process stays alive, so the build must run exactly once. + private(set) var hasConfiguredInitialUI = false + + /// Set during the first scene connect, because `sceneWillEnterForeground` also + /// fires right after it. The legacy `applicationWillEnterForeground` did not fire + /// during a cold launch, and `handleWillEnterForeground` keeps those semantics. + private var isInitialSceneConnect = false + + /// Builds and shows the app's UI in the given window scene. Runs when the main + /// scene connects, which (under the scene life cycle) is after `didFinishLaunching` + /// and can happen multiple times per process: the system may disconnect the scene + /// in the background and reconnect it later. After the first connect, the existing + /// window (with all its UI state) is simply reattached to the new scene. + func showInitialUI(in windowScene: UIWindowScene) { + if hasConfiguredInitialUI { + window?.windowScene = windowScene + window?.makeKeyAndVisible() + return + } + hasConfiguredInitialUI = true + isInitialSceneConnect = true + + let window: UIWindow + if let existingWindow = self.window { + // Created by the `windowManager` emergency fallback before any scene + // connected; adopt it instead of orphaning the UI already built in it. + existingWindow.windowScene = windowScene + window = existingWindow + } else { + window = UIWindow(windowScene: windowScene) + self.window = window + } + + UITestConfigurator.prepareWindowForUITests(window) + + // Must run here (not in willFinishLaunching): it accesses `windowManager`, + // which requires `window`. startObservingAppleIDCredentialRevoked also + // requires the authenticator to be initialized first. A background-only + // launch therefore runs without the revocation observer; the + // `checkAppleIDCredentialState` call in `handleDidBecomeActive` covers the + // gap at the next foreground. + configureWordPressAuthenticator() + startObservingAppleIDCredentialRevoked() + customizeAppearance() + // Reapplies the user's saved light/dark override; reads the app's window, so it + // can only take effect once the window exists. + AppAppearance.overrideAppearance() + + window.makeKeyAndVisible() + + // The 3D Touch / Home Screen quick actions probe the trait collection of + // `UIApplication.shared.mainWindow`, which only exists once the window above is key. + shortcutCreator.createShortcutsIf3DTouchAvailable(AccountHelper.isLoggedIn) + + windowManager.showUI() + restoreAppState() + + DebugMenuViewController.configure(in: window) + } + public func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -155,7 +228,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // (e.g. the share extension's background URL session) post notices that it // delivers as local notifications, with no UI visible. setupNoticePresenter() - DebugMenuViewController.configure(in: window) AppTips.initialize() // This was necessary to properly load fonts for the Stories editor. I believe external libraries may require this call to access fonts. @@ -165,8 +237,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) }) - startObservingAppleIDCredentialRevoked() - NotificationCenter.default.post(name: .applicationLaunchCompleted, object: nil) DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { WKWebView.warmup() @@ -184,7 +254,9 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { Loggers.app.info("\(self) \(#function)") } - public func applicationDidEnterBackground(_ application: UIApplication) { + // MARK: - Activation handlers (shared by app- and scene-lifecycle) + + func handleDidEnterBackground() { Loggers.app.info("\(self) \(#function)") analytics?.trackApplicationDidEnterBackground(screenName: currentlySelectedScreen) @@ -212,23 +284,35 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { } } - public func applicationWillEnterForeground(_ application: UIApplication) { + func handleWillEnterForeground() { Loggers.app.info("\(self) \(#function)") + // Refresh on every foreground, including the one right after the initial + // scene connect: a background-launched process may sit for hours between + // the launch-time refresh and the user opening the app. updateFeatureFlags() updateRemoteConfig() + // Skip the migration check on the call that follows the initial scene + // connect: the window phase already ran it via `windowManager.showUI()`, + // and running it twice emits its analytics event twice per launch. Scene + // reconnects are a real return from the background and fall through. + if isInitialSceneConnect { + isInitialSceneConnect = false + return + } + // JetpackWindowManager is only available in the Jetpack target. if let windowManager = windowManager as? JetpackWindowManager { windowManager.startMigrationFlowIfNeeded() } } - public func applicationWillResignActive(_ application: UIApplication) { + func handleWillResignActive() { Loggers.app.info("\(self) \(#function)") } - public func applicationDidBecomeActive(_ application: UIApplication) { + func handleDidBecomeActive() { Loggers.app.info("\(self) \(#function)") analytics?.trackApplicationDidBecomeActive() @@ -239,15 +323,6 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { GutenbergSettings().performGutenbergPhase2MigrationIfNeeded() } - public func application( - _ application: UIApplication, - performActionFor shortcutItem: UIApplicationShortcutItem, - completionHandler: @escaping (Bool) -> Void - ) { - let handler = WP3DTouchShortcutHandler() - completionHandler(handler.handleShortcutItem(shortcutItem)) - } - public func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, @@ -263,19 +338,14 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { } } - public func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { + /// Routes an inbound `NSUserActivity` (universal links via web browsing, Spotlight, Handoff). + func handle(userActivity: NSUserActivity) { if userActivity.activityType == NSUserActivityTypeBrowsingWeb { handleWebActivity(userActivity) } else { // Spotlight search SearchManager.shared.handle(activity: userActivity) } - - return true } // Note that this method only appears to be called for iPhone devices, not iPad. @@ -295,7 +365,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Setup - func runStartupSequence(with launchOptions: [UIApplication.LaunchOptionsKey: Any] = [:]) { + func runStartupSequence() { // Local notifications addNotificationObservers() @@ -308,7 +378,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { TracksLogging.delegate = libraryLogger WPKitSetLoggingDelegate(libraryLogger) WPSetLoggingDelegate(libraryLogger) - printDebugLaunchInfoWithLaunchOptions(launchOptions) + printDebugLaunchInfo() toggleExtraDebuggingIfNeeded() } @@ -345,12 +415,7 @@ public class WordPressAppDelegate: UIResponder, UIApplicationDelegate { setupWordPressExtensions() - shortcutCreator.createShortcutsIf3DTouchAvailable(AccountHelper.isLoggedIn) - AccountService.loadDefaultAccountCookies() - - windowManager.showUI() - restoreAppState() } private func mergeDuplicateAccountsIfNeeded() { @@ -609,7 +674,7 @@ extension WordPressAppDelegate { // MARK: - Debugging extension WordPressAppDelegate { - func printDebugLaunchInfoWithLaunchOptions(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) { + func printDebugLaunchInfo() { let unknown = "Unknown" let device = UIDevice.current @@ -644,7 +709,6 @@ extension WordPressAppDelegate { Loggers.app.info("Language: \(currentLanguage)") Loggers.app.info("UDID: \(udid)") Loggers.app.info("APN token: \(PushNotificationsManager.shared.deviceToken ?? "None")") - Loggers.app.info("Launch options: \(String(describing: launchOptions ?? [:]))") AccountHelper.logBlogsAndAccounts(context: mainContext) Loggers.app.info("===========================================================================") @@ -724,14 +788,26 @@ extension WordPressAppDelegate { // If the notification object is not nil, then it's a login if notification.object != nil { setupWordPressExtensions() - startObservingAppleIDCredentialRevoked() + // Pre-UI logins (the observer requires the authenticator, which is + // configured at first scene connect) are covered by `showInitialUI`, + // which registers the observer itself. + if hasConfiguredInitialUI { + startObservingAppleIDCredentialRevoked() + } AccountService.loadDefaultAccountCookies() } else { trackLogoutIfNeeded() removeShareExtensionConfiguration() removeNotificationExtensionConfiguration() - windowManager.showFullscreenSignIn() - stopObservingAppleIDCredentialRevoked() + // Skip when the UI was never built (e.g. the account is removed during a + // background launch): there is no UI to show sign-in in, `showInitialUI` + // shows it anyway once a scene connects and finds no account, and the + // Apple ID observer was never registered (stopping it would hit + // `WordPressAuthenticator.shared` before the authenticator exists). + if hasConfiguredInitialUI { + windowManager.showFullscreenSignIn() + stopObservingAppleIDCredentialRevoked() + } clearReaderStuff() } diff --git a/WordPress/Classes/System/WordPressSceneDelegate.swift b/WordPress/Classes/System/WordPressSceneDelegate.swift new file mode 100644 index 000000000000..e4e63f858011 --- /dev/null +++ b/WordPress/Classes/System/WordPressSceneDelegate.swift @@ -0,0 +1,79 @@ +import UIKit + +/// The app's single-window scene delegate. +/// +/// `WordPressAppDelegate` still owns `window` and `windowManager`; this delegate +/// creates the scene-attached window and forwards every scene callback into the +/// AppDelegate's shared handlers so the routing logic lives in one place. +@objc(WordPressSceneDelegate) +final class WordPressSceneDelegate: UIResponder, UIWindowSceneDelegate { + + private var appDelegate: WordPressAppDelegate? { + WordPressAppDelegate.shared + } + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { + return + } + + appDelegate?.showInitialUI(in: windowScene) + + // Drain any launch-time entry points through the same methods that handle + // them while the app is running. + self.scene(scene, openURLContexts: connectionOptions.urlContexts) + for activity in connectionOptions.userActivities { + self.scene(scene, continue: activity) + } + if let shortcutItem = connectionOptions.shortcutItem { + _ = handle(shortcutItem: shortcutItem) + } + } + + // MARK: - Entry points + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + appDelegate?.handle(url: context.url) + } + } + + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + appDelegate?.handle(userActivity: userActivity) + } + + func windowScene( + _ windowScene: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + completionHandler(handle(shortcutItem: shortcutItem)) + } + + /// Handles a Home Screen quick action. Returns whether the item was handled. + private func handle(shortcutItem: UIApplicationShortcutItem) -> Bool { + WP3DTouchShortcutHandler().handleShortcutItem(shortcutItem) + } + + // MARK: - Activation + + func sceneDidBecomeActive(_ scene: UIScene) { + appDelegate?.handleDidBecomeActive() + } + + func sceneWillResignActive(_ scene: UIScene) { + appDelegate?.handleWillResignActive() + } + + func sceneDidEnterBackground(_ scene: UIScene) { + appDelegate?.handleDidEnterBackground() + } + + func sceneWillEnterForeground(_ scene: UIScene) { + appDelegate?.handleWillEnterForeground() + } +} diff --git a/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift b/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift index 2edd785ce75e..93718084b0e8 100644 --- a/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift +++ b/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift @@ -303,7 +303,11 @@ extension PushNotificationsManager { WPAnalytics.track(.supportReceivedResponseFromSupport) - if applicationState == .background { + // Pre-build the Me screen so it's ready when the user opens the app. Skip it + // when the UI was never built (a background launch). Keyed on the UI flag, not + // window presence: the windowManager emergency fallback can create a window + // while no scene has ever connected. + if applicationState == .background, WordPressAppDelegate.shared?.hasConfiguredInitialUI == true { RootViewCoordinator.sharedPresenter.showMeScreen() } @@ -391,7 +395,12 @@ extension PushNotificationsManager { userInteraction: Bool, completionHandler: ((UIBackgroundFetchResult) -> Void)? ) -> Bool { - guard applicationState == .inactive else { + // A notification-tap cold launch reaches this point while the application + // state is still .background under the scene life cycle (the scene has + // connected but the foreground transition hasn't completed). A tap is + // explicit user navigation regardless of state, so accept it too; same + // workaround as the authentication handler above. + guard applicationState == .inactive || (applicationState == .background && userInteraction) else { return false }