//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
public import UIKit
extension UIWindow.Level {
// Behind everything, especially the root window.
public static let _background: UIWindow.Level = .init(rawValue: -1)
fileprivate static let _returnToCall: UIWindow.Level = .init(rawValue: UIWindow.Level.statusBar.rawValue - 1)
// In front of the root window, behind the screen blocking window.
// In front of the root window, behind the screen blocking window.
fileprivate static let _callView: UIWindow.Level = .init(rawValue: UIWindow.Level.normal.rawValue + 2)
// In front of the status bar and CallView
fileprivate static let _screenBlocking: UIWindow.Level = .init(rawValue: UIWindow.Level.statusBar.rawValue + 2)
}
class WindowManager {
init() {
AssertIsOnMainThread()
SwiftSingletons.register(self)
}
func setupWithRootWindow(_ rootWindow: UIWindow, screenBlockingWindow: UIWindow) {
AssertIsOnMainThread()
owsAssertBeta(self.rootWindow == nil)
owsAssertBeta(self.screenBlockingWindow == nil)
self.rootWindow = rootWindow
self.screenBlockingWindow = screenBlockingWindow
ensureWindowState()
}
func isAppWindow(_ window: UIWindow) -> Bool {
return window == rootWindow || window == returnToCallWindow || window == callViewWindow || window == screenBlockingWindow
}
var captchaWindow: UIWindow {
return shouldShowCallView ? callViewWindow : rootWindow
}
var isScreenBlockActive: Bool = false {
didSet {
AssertIsOnMainThread()
ensureWindowState()
}
}
func updateWindowFrames() {
let desiredFrame = CurrentAppContext().frame
for window in [rootWindow!, callViewWindow, screenBlockingWindow!] {
guard window.frame != desiredFrame else { continue }
window.frame = desiredFrame
}
}
// MARK: Windows
// UIWindow.Level.normal
var rootWindow: UIWindow!
// UIWindow.Level._returnToCall
private lazy var returnToCallWindow: UIWindow = {
AssertIsOnMainThread()
guard let rootWindow else {
owsFail("rootWindow is nil")
}
let window = OWSWindow(frame: rootWindow.bounds)
window.windowLevel = ._returnToCall
window.isHidden = true
window.isOpaque = true
window.clipsToBounds = true
window.rootViewController = returnToCallViewController
return window
}()
private lazy var returnToCallViewController = ReturnToCallViewController()
// UIWindow.Level._callView
lazy var callViewWindow: UIWindow = {
AssertIsOnMainThread()
guard let rootWindow else {
owsFail("rootWindow is nil")
}
let window = OWSWindow(frame: rootWindow.bounds)
window.windowLevel = ._callView
window.isHidden = true
window.isOpaque = true
window.backgroundColor = Theme.launchScreenBackgroundColor
window.rootViewController = nil
return window
}()
private func newCallNavigationController() -> UINavigationController {
let viewController = WindowRootViewController()
viewController.view.backgroundColor = Theme.launchScreenBackgroundColor
// NOTE: Do not use OWSNavigationController for call window.
// It adjusts the size of the navigation bar to reflect the
// call window. We don't want those adjustments made within
// the call window itself.
let navigationController = WindowRootNavigationViewController(rootViewController: viewController)
navigationController.isNavigationBarHidden = true
return navigationController
}
// UIWindow.Level._background if inactive,
// UIWindow.Level._screenBlocking() if active.
private var screenBlockingWindow: UIWindow!
// MARK: Window State
private func ensureWindowState() {
AssertIsOnMainThread()
// To avoid bad frames, we never want to hide the blocking window, so we manipulate
// its window level to "hide" it behind other windows. The other windows have fixed
// window level and are shown/hidden as necessary.
//
// Note that we always "hide" before we "show".
if isScreenBlockActive {
ensureScreenBlockWindowShown()
ensureRootWindowHidden()
ensureReturnToCallWindowHidden()
ensureCallViewWindowHidden()
}
// Show Call View
else if shouldShowCallView, callViewController != nil {
ensureCallViewWindowShown()
ensureRootWindowHidden()
ensureReturnToCallWindowHidden()
ensureScreenBlockWindowHidden()
}
// Show Root Window
else {
ensureRootWindowShown()
ensureScreenBlockWindowHidden()
// Add "Return to Call" banner
if callViewController != nil {
ensureReturnToCallWindowShown()
} else {
ensureReturnToCallWindowHidden()
}
ensureCallViewWindowHidden()
}
}
private func ensureRootWindowShown() {
AssertIsOnMainThread()
if rootWindow.isHidden {
Logger.info("showing root window.")
}
// By calling makeKeyAndVisible we ensure the rootViewController becomes first responder.
// In the normal case, that means the SignalViewController will call `becomeFirstResponder`
// on the vc on top of its navigation stack.
if !rootWindow.isKeyWindow || rootWindow.isHidden {
rootWindow.makeKeyAndVisible()
}
workAroundRotationIssue(rootWindow)
}
private func ensureRootWindowHidden() {
AssertIsOnMainThread()
guard !rootWindow.isHidden else { return }
Logger.info("hiding root window.")
rootWindow.isHidden = true
}
private func ensureReturnToCallWindowShown() {
AssertIsOnMainThread()
guard returnToCallWindow.isHidden else { return }
guard let callViewController else {
owsFailBeta("callViewController is nil")
return
}
Logger.info("showing 'return to call' window.")
returnToCallWindow.isHidden = false
returnToCallViewController.displayForCallViewController(callViewController)
}
private func ensureReturnToCallWindowHidden() {
AssertIsOnMainThread()
guard !returnToCallWindow.isHidden else { return }
Logger.info("hiding 'return to call' window.")
returnToCallWindow.isHidden = true
}
private func ensureCallViewWindowShown() {
AssertIsOnMainThread()
if callViewWindow.isHidden {
Logger.info("showing call window.")
}
callViewWindow.makeKeyAndVisible()
}
private func ensureCallViewWindowHidden() {
AssertIsOnMainThread()
guard !callViewWindow.isHidden else { return }
Logger.info("hiding call window.")
callViewWindow.isHidden = true
}
private func ensureScreenBlockWindowShown() {
AssertIsOnMainThread()
if screenBlockingWindow.windowLevel != ._screenBlocking {
Logger.info("showing block window.")
}
screenBlockingWindow.windowLevel = ._screenBlocking
screenBlockingWindow.makeKeyAndVisible()
}
private func ensureScreenBlockWindowHidden() {
AssertIsOnMainThread()
guard screenBlockingWindow.windowLevel != ._background else { return }
Logger.info("hiding block window.")
// Never hide the blocking window (that can lead to bad frames).
// Instead, manipulate its window level to move it in front of
// or behind the root window.
screenBlockingWindow.windowLevel = ._background
}
// MARK: Calls
var shouldShowCallView: Bool = false
var hasCall: Bool {
AssertIsOnMainThread()
return callViewController != nil
}
private var callViewController: CallViewControllerWindowReference?
func startCall<T: UIViewController & CallViewControllerWindowReference>(viewController: T) {
AssertIsOnMainThread()
Logger.info("startCall")
callViewController = viewController
// Attach callViewController to window.
let callNavigationController = self.newCallNavigationController()
self.callViewWindow.rootViewController = callNavigationController
callNavigationController.pushViewController(viewController, animated: false)
shouldShowCallView = true
// CallViewController only supports portrait for iPhones, but if we're _already_ landscape it won't
// automatically switch.
if !UIDevice.current.isIPad {
UIDevice.current.ows_setOrientation(.portrait)
}
ensureWindowState()
}
func endCall<T: UIViewController & CallViewControllerWindowReference>(viewController: T) {
AssertIsOnMainThread()
guard callViewController === viewController else {
Logger.warn("Ignoring end call request from obsolete call view controller.")
return
}
callViewWindow.rootViewController = nil
callViewController = nil
shouldShowCallView = false
ensureWindowState()
}
/// Minimizes the current call (or exits if it's not yet started).
@MainActor
func minimizeCallIfNeeded() {
callViewController?.minimizeIfNeeded()
}
func leaveCallView() {
AssertIsOnMainThread()
guard let callViewController else {
owsFailBeta("callViewController == nil")
return
}
owsAssertBeta(shouldShowCallView)
callViewController.willMoveToPip(pipWindow: returnToCallWindow)
shouldShowCallView = false
ensureWindowState()
}
func returnToCallView() {
AssertIsOnMainThread()
guard let callViewController else {
return
}
guard !shouldShowCallView else {
ensureWindowState()
return
}
shouldShowCallView = true
returnToCallViewController.resignCall()
callViewController.returnFromPip(pipWindow: returnToCallWindow)
ensureWindowState()
}
var isCallInPip: Bool {
return returnToCallViewController.isCallInPip
}
}
// This VC can become first responder
// when presented to ensure that the input accessory is updated.
private class WindowRootViewController: UIViewController {
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
UIDevice.current.defaultSupportedOrientations
}
}
private class WindowRootNavigationViewController: UINavigationController {
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
UIDevice.current.defaultSupportedOrientations
}
}
private func workAroundRotationIssue(_ window: UIWindow) {
// ### Symptom
//
// The app can get into a degraded state where the main window will incorrectly remain locked in
// portrait mode. Worse yet, the status bar and input window will continue to rotate with respect
// to the device orientation. So once you're in this degraded state, the status bar and input
// window can be in landscape while simultaneoulsy the view controller behind them is in portrait.
//
// ### To Reproduce
//
// On an iPhone6 (not reproducible on an iPhoneX)
//
// 0. Ensure "screen protection" is enabled (not necessarily screen lock)
// 1. Enter Conversation View Controller
// 2. Pop Keyboard
// 3. Begin dismissing keyboard with one finger, but stopping when it's about 50% dismissed,
// keep your finger there with the keyboard partially dismissed.
// 4. With your other hand, hit the home button to leave Signal.
// 5. Re-enter Signal
// 6. Rotate to landscape
//
// Expected: Conversation View, Input Toolbar window, and Settings Bar should all rotate to landscape.
// Actual: The input toolbar and the settings toolbar rotate to landscape, but the Conversation
// View remains in portrait, this looks super broken.
//
// ### Background
//
// Some debugging shows that the `ConversationViewController.view.window.isInterfaceAutorotationDisabled`
// is true. This is a private property, whose function we don't exactly know, but it seems like
// `interfaceAutorotation` is disabled when certain transition animations begin, and then
// re-enabled once the animation completes.
//
// My best guess is that autorotation is intended to be disabled for the duration of the
// interactive-keyboard-dismiss-transition, so when we start the interactive dismiss, autorotation
// has been disabled, but because we hide the main app window in the middle of the transition,
// autorotation doesn't have a chance to be re-enabled.
//
// ## So, The Fix
//
// If we find ourself in a situation where autorotation is disabled while showing the rootWindow,
// we re-enable autorotation.
// let encodedSelectorString1 = "isInterfaceAutorotationDisabled".encodedForSelector
let encodedSelectorString1 = "egVaAAZ2BHdydHZSBwYBBAEGcgZ6AQBVegVyc312dQ=="
guard let selectorString1 = encodedSelectorString1.decodedForSelector else {
owsFailDebug("selectorString1 was unexpectedly nil")
return
}
let selector1 = NSSelectorFromString(selectorString1)
guard window.responds(to: selector1) else {
owsFailDebug("failure: doesn't respond to selector1")
return
}
let imp1 = window.method(for: selector1)
typealias Selector1MethodType = @convention(c) (UIWindow, Selector) -> Bool
let func1: Selector1MethodType = unsafeBitCast(imp1, to: Selector1MethodType.self)
let isDisabled = func1(window, selector1)
guard isDisabled else {
return
}
Logger.info("autorotation is disabled.")
// The remainder of this method calls:
// [[UIScrollToDismissSupport supportForScreen:window.screen] finishScrollViewTransition]
// after verifying the methods/classes exist.
// let encodedKlassString = "UIScrollToDismissSupport".encodedForSelector
let encodedKlassString = "ZlpkdAQBfX1lAVV6BX56BQVkBwICAQQG"
guard let klassString = encodedKlassString.decodedForSelector else {
owsFailDebug("klassString was unexpectedly nil")
return
}
guard let klass = NSClassFromString(klassString) else {
owsFailDebug("klass was unexpectedly nil")
return
}
// let encodedSelector2String = "supportForScreen:".encodedForSelector
let encodedSelector2String = "BQcCAgEEBlcBBGR0BHZ2AEs="
guard let selector2String = encodedSelector2String.decodedForSelector else {
owsFailDebug("selector2String was unexpectedly nil")
return
}
let selector2 = NSSelectorFromString(selector2String)
guard klass.responds(to: selector2) else {
owsFailDebug("klass didn't respond to selector")
return
}
let imp2 = klass.method(for: selector2)
typealias Selector2MethodType = @convention(c) (AnyClass, Selector, UIScreen) -> AnyObject?
let func2: Selector2MethodType = unsafeBitCast(imp2, to: Selector2MethodType.self)
guard let dismissSupport = func2(klass, selector2, window.screen) else {
owsFailDebug("selector2String call unexpectedly returned nil")
return
}
// let encodedSelector3String = "finishScrollViewTransition".encodedForSelector
let encodedSelector3String = "d3oAegV5ZHQEAX19Z3p2CWUEcgAFegZ6AQA="
guard let selector3String = encodedSelector3String.decodedForSelector else {
owsFailDebug("selector3String was unexpectedly nil")
return
}
let selector3 = NSSelectorFromString(selector3String)
guard dismissSupport.responds(to: selector3) else {
owsFailDebug("dismissSupport didn't respond to selector")
return
}
let imp3 = dismissSupport.method(for: selector3)
typealias Selector3MethodType = @convention(c) (AnyObject, Selector) -> Void
let func3: Selector3MethodType = unsafeBitCast(imp3, to: Selector3MethodType.self)
func3(dismissSupport, selector3)
Logger.info("finished scrollView transition")
}