486 lines
16 KiB
Swift
486 lines
16 KiB
Swift
//
|
|
// 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")
|
|
}
|