session-ios/Session/Signal/OWSScreenLockUI.m

507 lines
16 KiB
Objective-C

//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import "OWSScreenLockUI.h"
#import "OWSWindowManager.h"
#import "Session-Swift.h"
#import <SignalUtilitiesKit/ScreenLockViewController.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import <SessionUtilitiesKit/UIView+OWS.h>
NS_ASSUME_NONNULL_BEGIN
@interface OWSScreenLockUI () <ScreenLockViewDelegate>
@property (nonatomic) UIWindow *screenBlockingWindow;
@property (nonatomic) ScreenLockViewController *screenBlockingViewController;
// Unlike UIApplication.applicationState, this state reflects the
// notifications, i.e. "did become active", "will resign active",
// "will enter foreground", "did enter background".
//
// We want to update our state to reflect these transitions and have
// the "update" logic be consistent with "last reported" state. i.e.
// when you're responding to "will resign active", we need to behave
// as though we're already inactive.
//
// Secondly, we need to show the screen protection _before_ we become
// inactive in order for it to be reflected in the app switcher.
@property (nonatomic) BOOL appIsInactiveOrBackground;
@property (nonatomic) BOOL appIsInBackground;
@property (nonatomic) BOOL isShowingScreenLockUI;
@property (nonatomic) BOOL didLastUnlockAttemptFail;
// We want to remain in "screen lock" mode while "local auth"
// UI is dismissing. So we lazily clear isShowingScreenLockUI
// using this property.
@property (nonatomic) BOOL shouldClearAuthUIWhenActive;
// Indicates whether or not the user is currently locked out of
// the app. Should only be set if OWSScreenLock.isScreenLockEnabled.
//
// * The user is locked out by default on app launch.
// * The user is also locked out if they spend more than
// "timeout" seconds outside the app. When the user leaves
// the app, a "countdown" begins.
@property (nonatomic) BOOL isScreenLockLocked;
// The "countdown" until screen lock takes effect.
@property (nonatomic, nullable) NSDate *screenLockCountdownDate;
@end
#pragma mark -
@implementation OWSScreenLockUI
+ (instancetype)sharedManager
{
static OWSScreenLockUI *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] initDefault];
});
return instance;
}
- (instancetype)initDefault
{
self = [super init];
if (!self) {
return self;
}
OWSAssertIsOnMainThread();
OWSSingletonAssert();
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)observeNotifications
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationDidBecomeActive:)
name:OWSApplicationDidBecomeActiveNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillResignActive:)
name:OWSApplicationWillResignActiveNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationWillEnterForeground:)
name:OWSApplicationWillEnterForegroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(applicationDidEnterBackground:)
name:OWSApplicationDidEnterBackgroundNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(screenLockDidChange:)
name:OWSScreenLock.ScreenLockDidChange
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(clockDidChange:)
name:NSSystemClockDidChangeNotification
object:nil];
}
- (void)setupWithRootWindow:(UIWindow *)rootWindow
{
OWSAssertIsOnMainThread();
OWSAssertDebug(rootWindow);
[self createScreenBlockingWindowWithRootWindow:rootWindow];
OWSAssertDebug(self.screenBlockingWindow);
}
- (void)startObserving
{
_appIsInactiveOrBackground = [UIApplication sharedApplication].applicationState != UIApplicationStateActive;
[self observeNotifications];
// Hide the screen blocking window until "app is ready" to
// avoid blocking the loading view.
[self updateScreenBlockingWindow:ScreenLockUIStateNone animated:NO];
// Initialize the screen lock state.
//
// It's not safe to access OWSScreenLock.isScreenLockEnabled
// until the app is ready.
[AppReadiness runNowOrWhenAppWillBecomeReady:^{
self.isScreenLockLocked = OWSScreenLock.sharedManager.isScreenLockEnabled;
[self ensureUI];
}];
}
#pragma mark - Methods
- (void)tryToActivateScreenLockBasedOnCountdown
{
OWSAssertDebug(!self.appIsInBackground);
OWSAssertIsOnMainThread();
if (!AppReadiness.isAppReady) {
// It's not safe to access OWSScreenLock.isScreenLockEnabled
// until the app is ready.
//
// We don't need to try to lock the screen lock;
// It will be initialized by `setupWithRootWindow`.
OWSLogVerbose(@"tryToActivateScreenLockUponBecomingActive NO 0");
return;
}
if (!OWSScreenLock.sharedManager.isScreenLockEnabled) {
// Screen lock is not enabled.
OWSLogVerbose(@"tryToActivateScreenLockUponBecomingActive NO 1");
return;
}
if (self.isScreenLockLocked) {
// Screen lock is already activated.
OWSLogVerbose(@"tryToActivateScreenLockUponBecomingActive NO 2");
return;
}
if (!self.screenLockCountdownDate) {
// We became inactive, but never started a countdown.
OWSLogVerbose(@"tryToActivateScreenLockUponBecomingActive NO 3");
return;
}
NSTimeInterval countdownInterval = fabs([self.screenLockCountdownDate timeIntervalSinceNow]);
OWSAssertDebug(countdownInterval >= 0);
NSTimeInterval screenLockTimeout = OWSScreenLock.sharedManager.screenLockTimeout;
OWSAssertDebug(screenLockTimeout >= 0);
if (countdownInterval >= screenLockTimeout) {
self.isScreenLockLocked = YES;
OWSLogVerbose(
@"tryToActivateScreenLockUponBecomingActive YES 4 (%0.3f >= %0.3f)", countdownInterval, screenLockTimeout);
} else {
OWSLogVerbose(
@"tryToActivateScreenLockUponBecomingActive NO 5 (%0.3f < %0.3f)", countdownInterval, screenLockTimeout);
}
}
// Setter for property indicating that the app is either
// inactive or in the background, e.g. not "foreground and active."
- (void)setAppIsInactiveOrBackground:(BOOL)appIsInactiveOrBackground
{
OWSAssertIsOnMainThread();
_appIsInactiveOrBackground = appIsInactiveOrBackground;
if (appIsInactiveOrBackground) {
if (!self.isShowingScreenLockUI) {
[self startScreenLockCountdownIfNecessary];
}
} else {
[self tryToActivateScreenLockBasedOnCountdown];
OWSLogInfo(@"setAppIsInactiveOrBackground clear screenLockCountdownDate.");
self.screenLockCountdownDate = nil;
}
[self ensureUI];
}
// Setter for property indicating that the app is in the background.
// If true, by definition the app is not active.
- (void)setAppIsInBackground:(BOOL)appIsInBackground
{
OWSAssertIsOnMainThread();
_appIsInBackground = appIsInBackground;
if (self.appIsInBackground) {
[self startScreenLockCountdownIfNecessary];
} else {
[self tryToActivateScreenLockBasedOnCountdown];
}
[self ensureUI];
}
- (void)startScreenLockCountdownIfNecessary
{
OWSLogVerbose(@"startScreenLockCountdownIfNecessary: %d", self.screenLockCountdownDate != nil);
if (!self.screenLockCountdownDate) {
OWSLogInfo(@"startScreenLockCountdown.");
self.screenLockCountdownDate = [NSDate new];
}
self.didLastUnlockAttemptFail = NO;
}
// Ensure that:
//
// * The blocking window has the correct state.
// * That we show the "iOS auth UI to unlock" if necessary.
- (void)ensureUI
{
OWSAssertIsOnMainThread();
if (!AppReadiness.isAppReady) {
[AppReadiness runNowOrWhenAppWillBecomeReady:^{
[self ensureUI];
}];
return;
}
ScreenLockUIState desiredUIState = self.desiredUIState;
OWSLogVerbose(@"ensureUI: %@", NSStringForScreenLockUIState(desiredUIState));
[self updateScreenBlockingWindow:desiredUIState animated:YES];
// Show the "iOS auth UI to unlock" if necessary.
if (desiredUIState == ScreenLockUIStateScreenLock && !self.didLastUnlockAttemptFail) {
[self tryToPresentAuthUIToUnlockScreenLock];
}
}
- (void)tryToPresentAuthUIToUnlockScreenLock
{
OWSAssertIsOnMainThread();
if (self.isShowingScreenLockUI) {
// We're already showing the auth UI; abort.
return;
}
if (self.appIsInactiveOrBackground) {
// Never show the auth UI unless active.
return;
}
OWSLogInfo(@"try to unlock screen lock");
self.isShowingScreenLockUI = YES;
[OWSScreenLock.sharedManager
tryToUnlockScreenLockWithSuccess:^{
OWSLogInfo(@"unlock screen lock succeeded.");
self.isShowingScreenLockUI = NO;
self.isScreenLockLocked = NO;
[self ensureUI];
}
failure:^(NSError *error) {
OWSLogInfo(@"unlock screen lock failed.");
[self clearAuthUIWhenActive];
self.didLastUnlockAttemptFail = YES;
[self showScreenLockFailureAlertWithMessage:error.localizedDescription];
}
unexpectedFailure:^(NSError *error) {
OWSLogInfo(@"unlock screen lock unexpectedly failed.");
// Local Authentication isn't working properly.
// This isn't covered by the docs or the forums but in practice
// it appears to be effective to retry again after waiting a bit.
dispatch_async(dispatch_get_main_queue(), ^{
[self clearAuthUIWhenActive];
});
}
cancel:^{
OWSLogInfo(@"unlock screen lock cancelled.");
[self clearAuthUIWhenActive];
self.didLastUnlockAttemptFail = YES;
// Re-show the unlock UI.
[self ensureUI];
}];
[self ensureUI];
}
// Determines what the state of the app should be.
- (ScreenLockUIState)desiredUIState
{
if (self.isScreenLockLocked) {
if (self.appIsInactiveOrBackground) {
OWSLogVerbose(@"desiredUIState: screen protection 1.");
return ScreenLockUIStateScreenProtection;
} else {
OWSLogVerbose(@"desiredUIState: screen lock 2.");
return ScreenLockUIStateScreenLock;
}
}
if (!self.appIsInactiveOrBackground) {
// App is inactive or background.
OWSLogVerbose(@"desiredUIState: none 3.");
return ScreenLockUIStateNone;
}
if (Environment.shared.preferences.screenSecurityIsEnabled) {
OWSLogVerbose(@"desiredUIState: screen protection 4.");
return ScreenLockUIStateScreenProtection;
} else {
OWSLogVerbose(@"desiredUIState: none 5.");
return ScreenLockUIStateNone;
}
}
- (void)showScreenLockFailureAlertWithMessage:(NSString *)message
{
OWSAssertIsOnMainThread();
[OWSAlerts showAlertWithTitle:NSLocalizedString(@"SCREEN_LOCK_UNLOCK_FAILED",
@"Title for alert indicating that screen lock could not be unlocked.")
message:message
buttonTitle:nil
buttonAction:^(UIAlertAction *action) {
// After the alert, update the UI.
[self ensureUI];
}
fromViewController:self.screenBlockingWindow.rootViewController];
}
// 'Screen Blocking' window obscures the app screen:
//
// * In the app switcher.
// * During 'Screen Lock' unlock process.
- (void)createScreenBlockingWindowWithRootWindow:(UIWindow *)rootWindow
{
OWSAssertIsOnMainThread();
OWSAssertDebug(rootWindow);
UIWindow *window = [[UIWindow alloc] initWithFrame:rootWindow.bounds];
window.hidden = NO;
window.windowLevel = UIWindowLevel_Background;
window.opaque = YES;
window.backgroundColor = UIColor.ows_materialBlueColor;
ScreenLockViewController *viewController = [ScreenLockViewController new];
viewController.delegate = self;
window.rootViewController = viewController;
self.screenBlockingWindow = window;
self.screenBlockingViewController = viewController;
}
// The "screen blocking" window has three possible states:
//
// * "Just a logo". Used when app is launching and in app switcher. Must match the "Launch Screen"
// storyboard pixel-for-pixel.
// * "Screen Lock, local auth UI presented". Move the Signal logo so that it is visible.
// * "Screen Lock, local auth UI not presented". Move the Signal logo so that it is visible,
// show "unlock" button.
- (void)updateScreenBlockingWindow:(ScreenLockUIState)desiredUIState animated:(BOOL)animated
{
OWSAssertIsOnMainThread();
BOOL shouldShowBlockWindow = desiredUIState != ScreenLockUIStateNone;
[OWSWindowManager.sharedManager setIsScreenBlockActive:shouldShowBlockWindow];
[self.screenBlockingViewController updateUIWithState:desiredUIState
isLogoAtTop:self.isShowingScreenLockUI
animated:animated];
}
#pragma mark - Events
- (void)screenLockDidChange:(NSNotification *)notification
{
[self ensureUI];
}
- (void)clearAuthUIWhenActive
{
// For continuity, continue to present blocking screen in "screen lock" mode while
// dismissing the "local auth UI".
if (self.appIsInactiveOrBackground) {
self.shouldClearAuthUIWhenActive = YES;
} else {
self.isShowingScreenLockUI = NO;
[self ensureUI];
}
}
- (void)applicationDidBecomeActive:(NSNotification *)notification
{
if (self.shouldClearAuthUIWhenActive) {
self.shouldClearAuthUIWhenActive = NO;
self.isShowingScreenLockUI = NO;
}
self.appIsInactiveOrBackground = NO;
}
- (void)applicationWillResignActive:(NSNotification *)notification
{
self.appIsInactiveOrBackground = YES;
}
- (void)applicationWillEnterForeground:(NSNotification *)notification
{
self.appIsInBackground = NO;
}
- (void)applicationDidEnterBackground:(NSNotification *)notification
{
self.appIsInBackground = YES;
}
// Whenever the device date/time is edited by the user,
// trigger screen lock immediately if enabled.
- (void)clockDidChange:(NSNotification *)notification
{
OWSLogInfo(@"clock did change");
if (!AppReadiness.isAppReady) {
// It's not safe to access OWSScreenLock.isScreenLockEnabled
// until the app is ready.
//
// We don't need to try to lock the screen lock;
// It will be initialized by `setupWithRootWindow`.
OWSLogVerbose(@"clockDidChange 0");
return;
}
self.isScreenLockLocked = OWSScreenLock.sharedManager.isScreenLockEnabled;
// NOTE: this notifications fires _before_ applicationDidBecomeActive,
// which is desirable. Don't assume that though; call ensureUI
// just in case it's necessary.
[self ensureUI];
}
#pragma mark - ScreenLockViewDelegate
- (void)unlockButtonWasTapped
{
OWSAssertIsOnMainThread();
if (self.appIsInactiveOrBackground) {
// This button can be pressed while the app is inactive
// for a brief window while the iOS auth UI is dismissing.
return;
}
OWSLogInfo(@"unlockButtonWasTapped");
self.didLastUnlockAttemptFail = NO;
[self ensureUI];
}
@end
NS_ASSUME_NONNULL_END