Signal-iOS/SignalMessaging/utils/OWSWindowManager.m
2020-11-19 16:04:22 -08:00

582 lines
18 KiB
Objective-C

//
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
//
#import "OWSWindowManager.h"
#import "Environment.h"
#import "UIFont+OWS.h"
#import "UIView+OWS.h"
#import <SignalMessaging/SignalMessaging-Swift.h>
NS_ASSUME_NONNULL_BEGIN
NSString *const OWSWindowManagerCallDidChangeNotification = @"OWSWindowManagerCallDidChangeNotification";
NSString *const IsScreenBlockActiveDidChangeNotification = @"IsScreenBlockActiveDidChangeNotification";
// Behind everything, especially the root window.
const UIWindowLevel UIWindowLevel_Background = -1.f;
const UIWindowLevel UIWindowLevel_ReturnToCall(void);
const UIWindowLevel UIWindowLevel_ReturnToCall(void)
{
return UIWindowLevelStatusBar - 1;
}
// In front of the root window, behind the screen blocking window.
const UIWindowLevel UIWindowLevel_CallView(void);
const UIWindowLevel UIWindowLevel_CallView(void)
{
return UIWindowLevelNormal + 1.f;
}
// In front of the status bar and CallView
const UIWindowLevel UIWindowLevel_ScreenBlocking(void);
const UIWindowLevel UIWindowLevel_ScreenBlocking(void)
{
return UIWindowLevelStatusBar + 2.f;
}
#pragma mark -
@implementation OWSWindowRootViewController
#pragma mark - Orientation
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
return UIDevice.currentDevice.defaultSupportedOrienations;
}
@end
#pragma mark -
@interface OWSWindowRootNavigationViewController : UINavigationController
@end
#pragma mark -
@implementation OWSWindowRootNavigationViewController : UINavigationController
#pragma mark - Orientation
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
return UIDevice.currentDevice.defaultSupportedOrienations;
}
@end
#pragma mark -
@interface OWSWindowManager ()
// UIWindowLevelNormal
@property (nonatomic) UIWindow *rootWindow;
// UIWindowLevel_ReturnToCall
@property (nonatomic) UIWindow *returnToCallWindow;
@property (nonatomic) ReturnToCallViewController *returnToCallViewController;
// UIWindowLevel_CallView
@property (nonatomic) UIWindow *callViewWindow;
@property (nonatomic) UINavigationController *callNavigationController;
// UIWindowLevel_Background if inactive,
// UIWindowLevel_ScreenBlocking() if active.
@property (nonatomic) UIWindow *screenBlockingWindow;
@property (nonatomic) BOOL shouldShowCallView;
@property (nonatomic, nullable) UIViewController<CallViewControllerWindowReference> *callViewController;
@end
#pragma mark -
@implementation OWSWindowManager
+ (instancetype)shared
{
OWSAssertDebug(Environment.shared.windowManager);
return Environment.shared.windowManager;
}
- (instancetype)initDefault
{
self = [super init];
if (!self) {
return self;
}
OWSAssertIsOnMainThread();
OWSSingletonAssert();
return self;
}
- (void)setupWithRootWindow:(UIWindow *)rootWindow screenBlockingWindow:(UIWindow *)screenBlockingWindow
{
OWSAssertIsOnMainThread();
OWSAssertDebug(rootWindow);
OWSAssertDebug(!self.rootWindow);
OWSAssertDebug(screenBlockingWindow);
OWSAssertDebug(!self.screenBlockingWindow);
self.rootWindow = rootWindow;
self.screenBlockingWindow = screenBlockingWindow;
self.returnToCallWindow = [self createReturnToCallWindow:rootWindow];
self.callViewWindow = [self createCallViewWindow:rootWindow];
[self ensureWindowState];
}
- (UIWindow *)createReturnToCallWindow:(UIWindow *)rootWindow
{
OWSAssertIsOnMainThread();
OWSAssertDebug(rootWindow);
UIWindow *window = [[OWSWindow alloc] initWithFrame:rootWindow.bounds];
window.hidden = YES;
window.windowLevel = UIWindowLevel_ReturnToCall();
window.opaque = YES;
window.clipsToBounds = YES;
ReturnToCallViewController *viewController = [ReturnToCallViewController new];
self.returnToCallViewController = viewController;
window.rootViewController = viewController;
return window;
}
- (UIWindow *)createCallViewWindow:(UIWindow *)rootWindow
{
OWSAssertIsOnMainThread();
OWSAssertDebug(rootWindow);
UIWindow *window = [[OWSWindow alloc] initWithFrame:rootWindow.bounds];
window.hidden = YES;
window.windowLevel = UIWindowLevel_CallView();
window.opaque = YES;
window.backgroundColor = Theme.launchScreenBackgroundColor;
UIViewController *viewController = [OWSWindowRootViewController new];
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.
OWSWindowRootNavigationViewController *navigationController =
[[OWSWindowRootNavigationViewController alloc] initWithRootViewController:viewController];
navigationController.navigationBarHidden = YES;
OWSAssertDebug(!self.callNavigationController);
self.callNavigationController = navigationController;
window.rootViewController = navigationController;
return window;
}
- (void)setIsScreenBlockActive:(BOOL)isScreenBlockActive
{
OWSAssertIsOnMainThread();
_isScreenBlockActive = isScreenBlockActive;
[self ensureWindowState];
[[NSNotificationCenter defaultCenter] postNotificationName:IsScreenBlockActiveDidChangeNotification
object:nil
userInfo:nil];
}
- (BOOL)isAppWindow:(UIWindow *)window
{
OWSAssertDebug(window);
return (window == self.rootWindow || window == self.returnToCallWindow || window == self.callViewWindow
|| window == self.screenBlockingWindow);
}
- (void)updateWindowFrames
{
OWSAssertIsOnMainThread();
for (UIWindow *window in @[
self.rootWindow,
self.callViewWindow,
self.screenBlockingWindow,
]) {
if (!CGRectEqualToRect(window.frame, CurrentAppContext().frame)) {
window.frame = CurrentAppContext().frame;
}
}
}
#pragma mark - Calls
- (void)setCallViewController:(nullable UIViewController<CallViewControllerWindowReference> *)callViewController
{
OWSAssertIsOnMainThread();
if (callViewController == _callViewController) {
return;
}
_callViewController = callViewController;
[NSNotificationCenter.defaultCenter postNotificationName:OWSWindowManagerCallDidChangeNotification object:nil];
}
- (void)startCall:(UIViewController<CallViewControllerWindowReference> *)callViewController
{
OWSAssertIsOnMainThread();
OWSAssertDebug(callViewController);
self.callViewController = callViewController;
// Attach callViewController to window.
[self.callNavigationController popToRootViewControllerAnimated:NO];
[self.callNavigationController pushViewController:callViewController animated:NO];
self.shouldShowCallView = YES;
// CallViewController only supports portrait for iPhones, but if we're _already_ landscape it won't
// automatically switch.
if (!UIDevice.currentDevice.isIPad) {
[UIDevice.currentDevice ows_setOrientation:UIDeviceOrientationPortrait];
}
[self ensureWindowState];
}
- (void)endCall:(UIViewController<CallViewControllerWindowReference> *)callViewController
{
OWSAssertIsOnMainThread();
OWSAssertDebug(callViewController);
OWSAssertDebug(self.callViewController);
if (self.callViewController != callViewController) {
OWSLogWarn(@"Ignoring end call request from obsolete call view controller.");
return;
}
// Dettach callViewController from window.
[self.callNavigationController popToRootViewControllerAnimated:NO];
self.callViewController = nil;
self.shouldShowCallView = NO;
[self ensureWindowState];
}
- (void)leaveCallView
{
OWSAssertIsOnMainThread();
OWSAssertDebug(self.callViewController);
OWSAssertDebug(self.shouldShowCallView);
self.shouldShowCallView = NO;
[self ensureWindowState];
}
- (void)returnToCallView
{
OWSAssertIsOnMainThread();
OWSAssertDebug(self.callViewController);
if (self.shouldShowCallView) {
[self ensureWindowState];
return;
}
self.shouldShowCallView = YES;
[self.returnToCallViewController resignCall];
[self.callViewController returnFromPipWithPipWindow:self.returnToCallWindow];
[self ensureWindowState];
}
- (BOOL)hasCall
{
OWSAssertIsOnMainThread();
return self.callViewController != nil;
}
#pragma mark - Window State
- (void)ensureWindowState
{
OWSAssertIsOnMainThread();
OWSAssertDebug(self.rootWindow);
OWSAssertDebug(self.returnToCallWindow);
OWSAssertDebug(self.callViewWindow);
OWSAssertDebug(self.screenBlockingWindow);
// 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 (self.isScreenBlockActive) {
// Show Screen Block.
[self ensureRootWindowHidden];
[self ensureReturnToCallWindowHidden];
[self ensureCallViewWindowHidden];
[self ensureScreenBlockWindowShown];
} else if (self.callViewController && self.shouldShowCallView) {
// Show Call View.
[self ensureRootWindowHidden];
[self ensureCallViewWindowShown];
[self ensureReturnToCallWindowHidden];
[self ensureScreenBlockWindowHidden];
} else {
// Show Root Window
[self ensureRootWindowShown];
[self ensureScreenBlockWindowHidden];
if (self.callViewController) {
// Add "Return to Call" banner
[self ensureReturnToCallWindowShown];
} else {
[self ensureReturnToCallWindowHidden];
}
[self ensureCallViewWindowHidden];
}
}
- (void)ensureRootWindowShown
{
OWSAssertIsOnMainThread();
if (self.rootWindow.hidden) {
OWSLogInfo(@"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 (!self.rootWindow.isKeyWindow || self.rootWindow.hidden) {
[self.rootWindow makeKeyAndVisible];
}
[self fixit_workAroundRotationIssue];
}
- (void)ensureRootWindowHidden
{
OWSAssertIsOnMainThread();
if (!self.rootWindow.hidden) {
OWSLogInfo(@"hiding root window.");
}
self.rootWindow.hidden = YES;
}
- (void)ensureReturnToCallWindowShown
{
OWSAssertIsOnMainThread();
if (!self.returnToCallWindow.hidden) {
return;
}
OWSLogInfo(@"showing 'return to call' window.");
self.returnToCallWindow.hidden = NO;
[self.returnToCallViewController displayForCallViewController:self.callViewController];
}
- (void)ensureReturnToCallWindowHidden
{
OWSAssertIsOnMainThread();
if (self.returnToCallWindow.hidden) {
return;
}
OWSLogInfo(@"hiding 'return to call' window.");
self.returnToCallWindow.hidden = YES;
}
- (void)ensureCallViewWindowShown
{
OWSAssertIsOnMainThread();
if (self.callViewWindow.hidden) {
OWSLogInfo(@"showing call window.");
}
[self.callViewWindow makeKeyAndVisible];
}
- (void)ensureCallViewWindowHidden
{
OWSAssertIsOnMainThread();
if (!self.callViewWindow.hidden) {
OWSLogInfo(@"hiding call window.");
}
self.callViewWindow.hidden = YES;
}
- (void)ensureScreenBlockWindowShown
{
OWSAssertIsOnMainThread();
if (self.screenBlockingWindow.windowLevel != UIWindowLevel_ScreenBlocking()) {
OWSLogInfo(@"showing block window.");
}
self.screenBlockingWindow.windowLevel = UIWindowLevel_ScreenBlocking();
[self.screenBlockingWindow makeKeyAndVisible];
}
- (void)ensureScreenBlockWindowHidden
{
OWSAssertIsOnMainThread();
if (self.screenBlockingWindow.windowLevel != UIWindowLevel_Background) {
OWSLogInfo(@"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.
self.screenBlockingWindow.windowLevel = UIWindowLevel_Background;
}
#pragma mark - Fixit
- (void)fixit_workAroundRotationIssue
{
// ### 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.
// NSString *encodedSelectorString1 = @"isInterfaceAutorotationDisabled".encodedForSelector;
NSString *encodedSelectorString1 = @"egVaAAZ2BHdydHZSBwYBBAEGcgZ6AQBVegVyc312dQ==";
NSString *_Nullable selectorString1 = encodedSelectorString1.decodedForSelector;
if (selectorString1 == nil) {
OWSFailDebug(@"selectorString1 was unexpectedly nil");
return;
}
SEL selector1 = NSSelectorFromString(selectorString1);
if (![self.rootWindow respondsToSelector:selector1]) {
OWSFailDebug(@"failure: doesn't respond to selector1");
return;
}
IMP imp1 = [self.rootWindow methodForSelector:selector1];
BOOL (*func1)(id, SEL) = (void *)imp1;
BOOL isDisabled = func1(self.rootWindow, selector1);
if (isDisabled) {
OWSLogInfo(@"autorotation is disabled.");
// The remainder of this method calls:
// [[UIScrollToDismissSupport supportForScreen:UIScreen.main] finishScrollViewTransition]
// after verifying the methods/classes exist.
// NSString *encodedKlassString = @"UIScrollToDismissSupport".encodedForSelector;
NSString *encodedKlassString = @"ZlpkdAQBfX1lAVV6BX56BQVkBwICAQQG";
NSString *_Nullable klassString = encodedKlassString.decodedForSelector;
if (klassString == nil) {
OWSFailDebug(@"klassString was unexpectedly nil");
return;
}
id klass = NSClassFromString(klassString);
if (klass == nil) {
OWSFailDebug(@"klass was unexpectedly nil");
return;
}
// NSString *encodedSelector2String = @"supportForScreen:".encodedForSelector;
NSString *encodedSelector2String = @"BQcCAgEEBlcBBGR0BHZ2AEs=";
NSString *_Nullable selector2String = encodedSelector2String.decodedForSelector;
if (selector2String == nil) {
OWSFailDebug(@"selector2String was unexpectedly nil");
return;
}
SEL selector2 = NSSelectorFromString(selector2String);
if (![klass respondsToSelector:selector2]) {
OWSFailDebug(@"klass didn't respond to selector");
return;
}
IMP imp2 = [klass methodForSelector:selector2];
id (*func2)(id, SEL, UIScreen *) = (void *)imp2;
id dismissSupport = func2(klass, selector2, UIScreen.mainScreen);
// NSString *encodedSelector3String = @"finishScrollViewTransition".encodedForSelector;
NSString *encodedSelector3String = @"d3oAegV5ZHQEAX19Z3p2CWUEcgAFegZ6AQA=";
NSString *_Nullable selector3String = encodedSelector3String.decodedForSelector;
if (selector3String == nil) {
OWSFailDebug(@"selector3String was unexpectedly nil");
return;
}
SEL selector3 = NSSelectorFromString(selector3String);
if (![dismissSupport respondsToSelector:selector3]) {
OWSFailDebug(@"dismissSupport didn't respond to selector");
return;
}
IMP imp3 = [dismissSupport methodForSelector:selector3];
void (*func3)(id, SEL) = (void *)imp3;
func3(dismissSupport, selector3);
OWSLogInfo(@"finished scrollView transition");
}
}
@end
NS_ASSUME_NONNULL_END