diff --git a/Signal/src/util/OWSScreenLock.swift b/Signal/src/util/OWSScreenLock.swift index 9db55613b..d8ae13f3b 100644 --- a/Signal/src/util/OWSScreenLock.swift +++ b/Signal/src/util/OWSScreenLock.swift @@ -11,7 +11,6 @@ import LocalAuthentication case success case cancel case failure(error:String) - case unexpectedFailure(error:String) } @objc public let screenLockTimeoutDefault = 15 * kMinuteInterval @@ -123,8 +122,6 @@ import LocalAuthentication switch outcome { case .failure(let error): completion(self.authenticationError(errorDescription: error)) - case .unexpectedFailure(let error): - completion(self.authenticationError(errorDescription: error)) case .success: self.setIsScreenLockEnabled(value: true) completion(nil) @@ -147,8 +144,6 @@ import LocalAuthentication switch outcome { case .failure(let error): completion(self.authenticationError(errorDescription: error)) - case .unexpectedFailure(let error): - completion(self.authenticationError(errorDescription: error)) case .success: self.setIsScreenLockEnabled(value: false) completion(nil) @@ -160,7 +155,6 @@ import LocalAuthentication @objc public func tryToUnlockScreenLock(success: @escaping (() -> Void), failure: @escaping ((Error) -> Void), - unexpectedFailure: @escaping ((Error) -> Void), cancel: @escaping (() -> Void)) { guard !ignoreUnlockUntilActive else { DispatchQueue.main.async { @@ -177,8 +171,6 @@ import LocalAuthentication switch outcome { case .failure(let error): failure(self.authenticationError(errorDescription: error)) - case .unexpectedFailure(let error): - unexpectedFailure(self.authenticationError(errorDescription: error)) case .success: success() case .cancel: @@ -223,7 +215,7 @@ import LocalAuthentication case .success: owsFail("\(self.logTag) local authentication unexpected success") completion(.failure(error:defaultErrorDescription)) - case .cancel, .failure, .unexpectedFailure: + case .cancel, .failure: completion(outcome) } return @@ -243,7 +235,7 @@ import LocalAuthentication case .success: owsFail("\(self.logTag) local authentication unexpected success") completion(.failure(error:defaultErrorDescription)) - case .cancel, .failure, .unexpectedFailure: + case .cancel, .failure: completion(outcome) } } @@ -304,10 +296,10 @@ import LocalAuthentication comment: "Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures.")) case .invalidContext: owsFail("\(self.logTag) context not valid.") - return .unexpectedFailure(error:defaultErrorDescription) + return .failure(error:defaultErrorDescription) case .notInteractive: owsFail("\(self.logTag) context not interactive.") - return .unexpectedFailure(error:defaultErrorDescription) + return .failure(error:defaultErrorDescription) } } return .failure(error:defaultErrorDescription) @@ -323,7 +315,11 @@ import LocalAuthentication private func screenLockContext() -> LAContext { let context = LAContext() - context.touchIDAuthenticationAllowableReuseDuration = TimeInterval(screenLockTimeout()) + // If user has set any non-zero timeout, recycle biometric auth + // in the same period as our normal screen lock timeout, up to + // max of 10 seconds. + context.touchIDAuthenticationAllowableReuseDuration = TimeInterval(min(10.0, screenLockTimeout())) + if #available(iOS 11.0, *) { assert(!context.interactionNotAllowed) } diff --git a/Signal/src/util/OWSScreenLockUI.m b/Signal/src/util/OWSScreenLockUI.m index 2f4fcf70b..41e24c563 100644 --- a/Signal/src/util/OWSScreenLockUI.m +++ b/Signal/src/util/OWSScreenLockUI.m @@ -4,22 +4,35 @@ #import "OWSScreenLockUI.h" #import "Signal-Swift.h" +#import +#import NS_ASSUME_NONNULL_BEGIN @interface OWSScreenLockUI () +@property (nonatomic) UIWindow *screenBlockingWindow; +@property (nonatomic) UIViewController *screenBlockingViewController; +@property (nonatomic) UIView *screenBlockingImageView; +@property (nonatomic) UIView *screenBlockingButton; +@property (nonatomic) NSArray *screenBlockingConstraints; +@property (nonatomic) NSString *screenBlockingSignature; + // Unlike UIApplication.applicationState, this state is // updated conservatively, e.g. the flag is cleared during // "will enter background." @property (nonatomic) BOOL appIsInactive; @property (nonatomic) BOOL appIsInBackground; -@property (nonatomic, nullable) NSDate *appEnteredBackgroundDate; -@property (nonatomic) UIWindow *screenBlockingWindow; -@property (nonatomic) BOOL hasUnlockedScreenLock; + @property (nonatomic) BOOL isShowingScreenLockUI; +@property (nonatomic) BOOL didLastUnlockAttemptFail; -@property (nonatomic, nullable) NSTimer *screenLockUITimer; +// We want to remain in "screen lock" mode while "local auth" +// UI is dismissing. +@property (nonatomic) BOOL shouldClearAuthUIWhenActive; + +@property (nonatomic, nullable) NSDate *appEnteredBackgroundDate; +@property (nonatomic, nullable) NSDate *appEnteredForegroundDate; @property (nonatomic, nullable) NSDate *lastUnlockAttemptDate; @property (nonatomic, nullable) NSDate *lastUnlockSuccessDate; @@ -112,11 +125,10 @@ NS_ASSUME_NONNULL_BEGIN { if (appIsInBackground) { if (!_appIsInBackground) { - // Whenever app enters background, clear this state. - self.hasUnlockedScreenLock = NO; - // Record the time when app entered background. self.appEnteredBackgroundDate = [NSDate new]; + + self.didLastUnlockAttemptFail = NO; } } @@ -148,42 +160,10 @@ NS_ASSUME_NONNULL_BEGIN if (self.screenBlockingWindow.hidden != !shouldShowBlockWindow) { DDLogInfo(@"%@, %@.", self.logTag, shouldShowBlockWindow ? @"showing block window" : @"hiding block window"); } - self.screenBlockingWindow.hidden = !shouldShowBlockWindow; + [self updateScreenBlockingWindow:shouldShowBlockWindow shouldHaveScreenLock:shouldHaveScreenLock animated:YES]; - [self.screenLockUITimer invalidate]; - self.screenLockUITimer = nil; - - if (shouldHaveScreenLock) { - // In pincode-only mode (e.g. device pincode is set - // but Touch ID/Face ID are not configured), the pincode - // unlock UI is fullscreen and the app becomes inactive. - // Hitting the home button will cancel the authentication - // UI but not send the app to the background. Therefore, - // to send the "locked" app to the background, you need - // to tap the home button twice. - // - // Therefore, if our last unlock attempt failed or was - // cancelled, wait a couple of second before re-presenting - // the "unlock screen lock UI" so that users have a chance - // to hit home button again. - BOOL shouldDelayScreenLockUI = YES; - if (!self.lastUnlockAttemptDate) { - shouldDelayScreenLockUI = NO; - } else if (self.lastUnlockAttemptDate && self.lastUnlockSuccessDate && - [self.lastUnlockSuccessDate isAfterDate:self.lastUnlockAttemptDate]) { - shouldDelayScreenLockUI = NO; - } - - if (shouldDelayScreenLockUI) { - DDLogVerbose(@"%@, Delaying Screen Lock UI.", self.logTag); - self.screenLockUITimer = [NSTimer weakScheduledTimerWithTimeInterval:1.25f - target:self - selector:@selector(tryToPresentScreenLockUI) - userInfo:nil - repeats:NO]; - } else { - [self tryToPresentScreenLockUI]; - } + if (shouldHaveScreenLock && !self.didLastUnlockAttemptFail) { + [self tryToPresentScreenLockUI]; } } @@ -191,13 +171,13 @@ NS_ASSUME_NONNULL_BEGIN { OWSAssertIsOnMainThread(); - [self.screenLockUITimer invalidate]; - self.screenLockUITimer = nil; - // If we no longer want to present the screen lock UI, abort. if (!self.shouldHaveScreenLock) { return; } + if (self.didLastUnlockAttemptFail) { + return; + } if (self.isShowingScreenLockUI) { return; } @@ -210,31 +190,30 @@ NS_ASSUME_NONNULL_BEGIN [OWSScreenLock.sharedManager tryToUnlockScreenLockWithSuccess:^{ DDLogInfo(@"%@ unlock screen lock succeeded.", self.logTag); self.isShowingScreenLockUI = NO; - self.hasUnlockedScreenLock = YES; self.lastUnlockSuccessDate = [NSDate new]; [self ensureScreenProtection]; } failure:^(NSError *error) { DDLogInfo(@"%@ unlock screen lock failed.", self.logTag); - self.isShowingScreenLockUI = NO; - [self showScreenLockFailureAlertWithMessage:error.localizedDescription]; - } - unexpectedFailure:^(NSError *error) { - DDLogInfo(@"%@ unlock screen lock unexpectedly failed.", self.logTag); - self.isShowingScreenLockUI = NO; + [self clearAuthUIWhenActive]; + + self.didLastUnlockAttemptFail = YES; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self ensureScreenProtection]; - }); + [self showScreenLockFailureAlertWithMessage:error.localizedDescription]; } cancel:^{ DDLogInfo(@"%@ unlock screen lock cancelled.", self.logTag); - self.isShowingScreenLockUI = NO; + + [self clearAuthUIWhenActive]; + + self.didLastUnlockAttemptFail = YES; // Re-show the unlock UI. [self ensureScreenProtection]; }]; + + [self ensureScreenProtection]; } - (BOOL)shouldHaveScreenProtection @@ -243,26 +222,57 @@ NS_ASSUME_NONNULL_BEGIN // // * App is inactive and... // * 'Screen Protection' is enabled. - BOOL shouldHaveScreenProtection = (self.appIsInactive && Environment.preferences.screenSecurityIsEnabled); - return shouldHaveScreenProtection; + if (!self.appIsInactive) { + return NO; + } else if (!Environment.preferences.screenSecurityIsEnabled) { + return NO; + } else { + return YES; + } +} + +- (BOOL)hasUnlockedScreenLock +{ + if (!self.lastUnlockSuccessDate) { + return NO; + } else if (!self.appEnteredBackgroundDate) { + return YES; + } else { + return [self.lastUnlockSuccessDate isAfterDate:self.appEnteredBackgroundDate]; + } } - (BOOL)shouldHaveScreenLock { - BOOL shouldHaveScreenLock = NO; if (![TSAccountManager isRegistered]) { // Don't show 'Screen Lock' if user is not registered. + DDLogVerbose(@"%@ shouldHaveScreenLock NO 1.", self.logTag); + return NO; } else if (!OWSScreenLock.sharedManager.isScreenLockEnabled) { // Don't show 'Screen Lock' if 'Screen Lock' isn't enabled. + DDLogVerbose(@"%@ shouldHaveScreenLock NO 2.", self.logTag); + return NO; } else if (self.hasUnlockedScreenLock) { // Don't show 'Screen Lock' if 'Screen Lock' has been unlocked. + DDLogVerbose(@"%@ shouldHaveScreenLock NO 3.", self.logTag); + return NO; } else if (self.appIsInBackground) { // Don't show 'Screen Lock' if app is in background. + DDLogVerbose(@"%@ shouldHaveScreenLock NO 4.", self.logTag); + return NO; + } else if (self.isShowingScreenLockUI) { + // Maintain blocking window in 'screen lock' mode while we're + // showing the 'Unlock Screen Lock' UI. + DDLogVerbose(@"%@ shouldHaveScreenLock YES 0.", self.logTag); + return YES; } else if (self.appIsInactive) { // Don't show 'Screen Lock' if app is inactive. + DDLogVerbose(@"%@ shouldHaveScreenLock NO 5.", self.logTag); + return NO; } else if (!self.appEnteredBackgroundDate) { // Show 'Screen Lock' if app has just launched. - shouldHaveScreenLock = YES; + DDLogVerbose(@"%@ shouldHaveScreenLock YES 1.", self.logTag); + return YES; } else { OWSAssert(self.appEnteredBackgroundDate); @@ -272,13 +282,14 @@ NS_ASSUME_NONNULL_BEGIN OWSAssert(screenLockTimeout >= 0); if (screenLockInterval < screenLockTimeout) { // Don't show 'Screen Lock' if 'Screen Lock' timeout hasn't elapsed. - shouldHaveScreenLock = NO; + DDLogVerbose(@"%@ shouldHaveScreenLock NO 6.", self.logTag); + return NO; } else { // Otherwise, show 'Screen Lock'. - shouldHaveScreenLock = YES; + DDLogVerbose(@"%@ shouldHaveScreenLock YES 2.", self.logTag); + return YES; } } - return shouldHaveScreenLock; } - (void)showScreenLockFailureAlertWithMessage:(NSString *)message @@ -307,13 +318,130 @@ NS_ASSUME_NONNULL_BEGIN UIWindow *window = [[UIWindow alloc] initWithFrame:rootWindow.bounds]; window.hidden = YES; window.opaque = YES; - window.userInteractionEnabled = NO; window.windowLevel = CGFLOAT_MAX; window.backgroundColor = UIColor.ows_materialBlueColor; - window.rootViewController = - [[UIStoryboard storyboardWithName:@"Launch Screen" bundle:nil] instantiateInitialViewController]; + + UIViewController *viewController = [UIViewController new]; + viewController.view.backgroundColor = UIColor.ows_materialBlueColor; + + + UIView *rootView = viewController.view; + + UIView *edgesView = [UIView containerView]; + [rootView addSubview:edgesView]; + [edgesView autoPinEdgeToSuperviewEdge:ALEdgeTop]; + [edgesView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; + [edgesView autoPinWidthToSuperview]; + + UIImage *image = [UIImage imageNamed:@"logoSignal"]; + UIImageView *imageView = [UIImageView new]; + imageView.image = image; + [edgesView addSubview:imageView]; + [imageView autoHCenterInSuperview]; + + const CGSize screenSize = UIScreen.mainScreen.bounds.size; + const CGFloat shortScreenDimension = MIN(screenSize.width, screenSize.height); + const CGFloat imageSize = round(shortScreenDimension / 3.f); + [imageView autoSetDimension:ALDimensionWidth toSize:imageSize]; + [imageView autoSetDimension:ALDimensionHeight toSize:imageSize]; + + const CGFloat kButtonHeight = 40.f; + OWSFlatButton *button = + [OWSFlatButton buttonWithTitle:NSLocalizedString(@"SCREEN_LOCK_UNLOCK_SIGNAL", + @"Label for button on lock screen that lets users unlock Signal.") + font:[OWSFlatButton fontForHeight:kButtonHeight] + titleColor:[UIColor ows_materialBlueColor] + backgroundColor:[UIColor whiteColor] + target:self + selector:@selector(showUnlockUI)]; + [edgesView addSubview:button]; + + [button autoSetDimension:ALDimensionHeight toSize:kButtonHeight]; + [button autoPinLeadingToSuperviewWithMargin:50.f]; + [button autoPinTrailingToSuperviewWithMargin:50.f]; + const CGFloat kVMargin = 65.f; + [button autoPinBottomToSuperviewWithMargin:kVMargin]; + + window.rootViewController = viewController; self.screenBlockingWindow = window; + self.screenBlockingViewController = viewController; + self.screenBlockingImageView = imageView; + self.screenBlockingButton = button; + + [self updateScreenBlockingWindow:YES shouldHaveScreenLock:NO animated:NO]; +} + +// 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:(BOOL)shouldShowBlockWindow + shouldHaveScreenLock:(BOOL)shouldHaveScreenLock + animated:(BOOL)animated +{ + OWSAssertIsOnMainThread(); + + self.screenBlockingWindow.hidden = !shouldShowBlockWindow; + + UIView *rootView = self.screenBlockingViewController.view; + + [NSLayoutConstraint deactivateConstraints:self.screenBlockingConstraints]; + + NSMutableArray *screenBlockingConstraints = [NSMutableArray new]; + + BOOL shouldShowUnlockButton = (!self.appIsInactive && !self.appIsInBackground && self.didLastUnlockAttemptFail); + + DDLogVerbose(@"%@ updateScreenBlockingWindow. shouldShowBlockWindow: %d, shouldHaveScreenLock: %d, " + @"shouldShowUnlockButton: %d.", + self.logTag, + shouldShowBlockWindow, + shouldHaveScreenLock, + shouldShowUnlockButton); + + NSString *signature = [NSString stringWithFormat:@"%d %d", shouldHaveScreenLock, self.isShowingScreenLockUI]; + if ([NSObject isNullableObject:self.screenBlockingSignature equalTo:signature]) { + // Skip redundant work to avoid interfering with ongoing animations. + return; + } + + self.screenBlockingButton.hidden = !shouldHaveScreenLock; + + if (self.isShowingScreenLockUI) { + const CGFloat kVMargin = 60.f; + [screenBlockingConstraints addObject:[self.screenBlockingImageView autoPinEdge:ALEdgeTop + toEdge:ALEdgeTop + ofView:rootView + withOffset:kVMargin]]; + } else { + [screenBlockingConstraints addObject:[self.screenBlockingImageView autoVCenterInSuperview]]; + } + + self.screenBlockingConstraints = screenBlockingConstraints; + self.screenBlockingSignature = signature; + + if (animated) { + [UIView animateWithDuration:0.35f + animations:^{ + [rootView layoutIfNeeded]; + }]; + } else { + [rootView layoutIfNeeded]; + } +} + +- (void)showUnlockUI +{ + OWSAssertIsOnMainThread(); + + DDLogInfo(@"showUnlockUI"); + + self.didLastUnlockAttemptFail = NO; + + [self ensureScreenProtection]; } #pragma mark - Events @@ -332,8 +460,25 @@ NS_ASSUME_NONNULL_BEGIN [self ensureScreenProtection]; } +- (void)clearAuthUIWhenActive +{ + // For continuity, continue to present blocking screen in "screen lock" mode while + // dismissing the "local auth UI". + if (self.appIsInactive) { + self.shouldClearAuthUIWhenActive = YES; + } else { + self.isShowingScreenLockUI = NO; + [self ensureScreenProtection]; + } +} + - (void)applicationDidBecomeActive:(NSNotification *)notification { + if (self.shouldClearAuthUIWhenActive) { + self.shouldClearAuthUIWhenActive = NO; + self.isShowingScreenLockUI = NO; + } + self.appIsInactive = NO; } @@ -347,12 +492,11 @@ NS_ASSUME_NONNULL_BEGIN // Clear the "delay Screen Lock UI" state; we don't want any // delays when presenting the "unlock screen lock UI" after // returning from background. - [self.screenLockUITimer invalidate]; - self.screenLockUITimer = nil; self.lastUnlockAttemptDate = nil; self.lastUnlockSuccessDate = nil; self.appIsInBackground = NO; + self.appEnteredForegroundDate = [NSDate new]; } - (void)applicationDidEnterBackground:(NSNotification *)notification diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 69dfde5cb..5c5fcc419 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1585,6 +1585,9 @@ /* Title for alert indicating that screen lock could not be unlocked. */ "SCREEN_LOCK_UNLOCK_FAILED" = "Authentication Failed"; +/* Label for button on lock screen that lets users unlock Signal. */ +"SCREEN_LOCK_UNLOCK_SIGNAL" = "Unlock Signal"; + /* No comment provided by engineer. */ "SEARCH_BYNAMEORNUMBER_PLACEHOLDER_TEXT" = "Search by name or number"; @@ -1786,15 +1789,6 @@ /* An explanation of the 'read receipts' setting. */ "SETTINGS_READ_RECEIPTS_SECTION_FOOTER" = "See and share when messages have been read. This setting is optional and applies to all conversations."; -/* Remove metadata table cell label */ -"SETTINGS_REMOVE_METADATA" = "Remove Media Metadata"; - -/* Remove metadata section footer */ -"SETTINGS_REMOVE_METADATA_DETAIL" = "Remove user-identifying metadata and GPS information when sending image and video messages."; - -/* Remove metadata section header */ -"SETTINGS_REMOVE_METADATA_TITLE" = "Metadata"; - /* Label for the 'screen lock activity timeout' setting of the privacy settings. */ "SETTINGS_SCREEN_LOCK_ACTIVITY_TIMEOUT" = "Screen Lock Timeout";