|
|
@ -9,6 +9,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
NS_ASSUME_NONNULL_BEGIN
|
|
|
|
NS_ASSUME_NONNULL_BEGIN
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
typedef NS_ENUM(NSUInteger, ScreenLockUIState) {
|
|
|
|
|
|
|
|
ScreenLockUIStateNone,
|
|
|
|
|
|
|
|
// Shown while app is inactive or background, if enabled.
|
|
|
|
|
|
|
|
ScreenLockUIStateScreenProtection,
|
|
|
|
|
|
|
|
// Shown while app is active, if enabled.
|
|
|
|
|
|
|
|
ScreenLockUIStateScreenLock,
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NSString *NSStringForScreenLockUIState(ScreenLockUIState value);
|
|
|
|
|
|
|
|
NSString *NSStringForScreenLockUIState(ScreenLockUIState value)
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
switch (value) {
|
|
|
|
|
|
|
|
case ScreenLockUIStateNone:
|
|
|
|
|
|
|
|
return @"ScreenLockUIStateNone";
|
|
|
|
|
|
|
|
case ScreenLockUIStateScreenProtection:
|
|
|
|
|
|
|
|
return @"ScreenLockUIStateScreenProtection";
|
|
|
|
|
|
|
|
case ScreenLockUIStateScreenLock:
|
|
|
|
|
|
|
|
return @"ScreenLockUIStateScreenLock";
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const UIWindowLevel UIWindowLevel_Background = -1.f;
|
|
|
|
|
|
|
|
|
|
|
|
@interface OWSScreenLockUI ()
|
|
|
|
@interface OWSScreenLockUI ()
|
|
|
|
|
|
|
|
|
|
|
|
@property (nonatomic) UIWindow *screenBlockingWindow;
|
|
|
|
@property (nonatomic) UIWindow *screenBlockingWindow;
|
|
|
@ -18,35 +41,43 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
@property (nonatomic) NSArray<NSLayoutConstraint *> *screenBlockingConstraints;
|
|
|
|
@property (nonatomic) NSArray<NSLayoutConstraint *> *screenBlockingConstraints;
|
|
|
|
@property (nonatomic) NSString *screenBlockingSignature;
|
|
|
|
@property (nonatomic) NSString *screenBlockingSignature;
|
|
|
|
|
|
|
|
|
|
|
|
// Unlike UIApplication.applicationState, this state is
|
|
|
|
// Unlike UIApplication.applicationState, this state reflects the
|
|
|
|
// updated conservatively, e.g. the flag is cleared during
|
|
|
|
// notifications, i.e. "did become active", "will resign active",
|
|
|
|
// "will enter background."
|
|
|
|
// "will enter foreground", "did enter background".
|
|
|
|
@property (nonatomic) BOOL appIsInactive;
|
|
|
|
//
|
|
|
|
|
|
|
|
// 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 appIsInBackground;
|
|
|
|
|
|
|
|
|
|
|
|
@property (nonatomic) BOOL isShowingScreenLockUI;
|
|
|
|
@property (nonatomic) BOOL isShowingScreenLockUI;
|
|
|
|
@property (nonatomic) BOOL didLastUnlockAttemptFail;
|
|
|
|
@property (nonatomic) BOOL didLastUnlockAttemptFail;
|
|
|
|
|
|
|
|
|
|
|
|
// We want to remain in "screen lock" mode while "local auth"
|
|
|
|
// We want to remain in "screen lock" mode while "local auth"
|
|
|
|
// UI is dismissing.
|
|
|
|
// UI is dismissing. So we lazily clear isShowingScreenLockUI
|
|
|
|
|
|
|
|
// using this property.
|
|
|
|
@property (nonatomic) BOOL shouldClearAuthUIWhenActive;
|
|
|
|
@property (nonatomic) BOOL shouldClearAuthUIWhenActive;
|
|
|
|
|
|
|
|
|
|
|
|
// Indicates whether or not the user is currently locked out of
|
|
|
|
// Indicates whether or not the user is currently locked out of
|
|
|
|
// the app. Only applies if OWSScreenLock.isScreenLockEnabled.
|
|
|
|
// the app. Should only be set if OWSScreenLock.isScreenLockEnabled.
|
|
|
|
//
|
|
|
|
//
|
|
|
|
// * The user is locked out out by default on app launch.
|
|
|
|
// * The user is locked out by default on app launch.
|
|
|
|
// * The user is also locked out if they spend more than
|
|
|
|
// * The user is also locked out if they spend more than
|
|
|
|
// "timeout" seconds outside the app. When the user leaves
|
|
|
|
// "timeout" seconds outside the app. When the user leaves
|
|
|
|
// the app, a "countdown" begins.
|
|
|
|
// the app, a "countdown" begins.
|
|
|
|
@property (nonatomic) BOOL isScreenLockUnlocked;
|
|
|
|
@property (nonatomic) BOOL isScreenLockLocked;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// The "countdown" until screen lock takes effect.
|
|
|
|
@property (nonatomic, nullable) NSDate *screenLockCountdownDate;
|
|
|
|
@property (nonatomic, nullable) NSDate *screenLockCountdownDate;
|
|
|
|
|
|
|
|
|
|
|
|
// We normally start the "countdown" when the app enters the background,
|
|
|
|
@property (nonatomic) UIWindow *rootWindow;
|
|
|
|
// But we also want to start the "countdown" if the app is inactive for
|
|
|
|
|
|
|
|
// more than N seconds.
|
|
|
|
|
|
|
|
@property (nonatomic, nullable) NSTimer *inactiveTimer;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@property (nonatomic, nullable) UIResponder *rootWindowResponder;
|
|
|
|
|
|
|
|
|
|
|
|
@end
|
|
|
|
@end
|
|
|
|
|
|
|
|
|
|
|
@ -72,6 +103,10 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
return self;
|
|
|
|
return self;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_appIsInactiveOrBackground = [UIApplication sharedApplication].applicationState != UIApplicationStateActive;
|
|
|
|
|
|
|
|
|
|
|
|
[self observeNotifications];
|
|
|
|
[self observeNotifications];
|
|
|
|
|
|
|
|
|
|
|
|
OWSSingletonAssert();
|
|
|
|
OWSSingletonAssert();
|
|
|
@ -102,17 +137,13 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
selector:@selector(applicationDidEnterBackground:)
|
|
|
|
selector:@selector(applicationDidEnterBackground:)
|
|
|
|
name:OWSApplicationDidEnterBackgroundNotification
|
|
|
|
name:OWSApplicationDidEnterBackgroundNotification
|
|
|
|
object:nil];
|
|
|
|
object:nil];
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
|
|
|
|
|
|
selector:@selector(registrationStateDidChange)
|
|
|
|
|
|
|
|
name:RegistrationStateDidChangeNotification
|
|
|
|
|
|
|
|
object:nil];
|
|
|
|
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
|
|
selector:@selector(screenLockDidChange:)
|
|
|
|
selector:@selector(screenLockDidChange:)
|
|
|
|
name:OWSScreenLock.ScreenLockDidChange
|
|
|
|
name:OWSScreenLock.ScreenLockDidChange
|
|
|
|
object:nil];
|
|
|
|
object:nil];
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
|
|
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
|
|
selector:@selector(screenLockWasEnabled:)
|
|
|
|
selector:@selector(clockDidChange:)
|
|
|
|
name:OWSScreenLock.ScreenLockWasEnabled
|
|
|
|
name:NSSystemClockDidChangeNotification
|
|
|
|
object:nil];
|
|
|
|
object:nil];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -121,27 +152,50 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
OWSAssert(rootWindow);
|
|
|
|
OWSAssert(rootWindow);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.rootWindow = rootWindow;
|
|
|
|
|
|
|
|
|
|
|
|
[self prepareScreenProtectionWithRootWindow:rootWindow];
|
|
|
|
[self prepareScreenProtectionWithRootWindow:rootWindow];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize the screen lock state.
|
|
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
// It's not safe to access OWSScreenLock.isScreenLockEnabled
|
|
|
|
|
|
|
|
// until the app is ready.
|
|
|
|
[AppReadiness runNowOrWhenAppIsReady:^{
|
|
|
|
[AppReadiness runNowOrWhenAppIsReady:^{
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
self.isScreenLockLocked = OWSScreenLock.sharedManager.isScreenLockEnabled;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureUI];
|
|
|
|
}];
|
|
|
|
}];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Methods
|
|
|
|
#pragma mark - Methods
|
|
|
|
|
|
|
|
|
|
|
|
- (void)tryToActivateScreenLockUponBecomingActive
|
|
|
|
- (void)tryToActivateScreenLockBasedOnCountdown
|
|
|
|
{
|
|
|
|
{
|
|
|
|
OWSAssert(!self.appIsInactive);
|
|
|
|
OWSAssert(!self.appIsInBackground);
|
|
|
|
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
if (!self.isScreenLockUnlocked) {
|
|
|
|
if (!AppReadiness.isAppReady) {
|
|
|
|
// Screen lock is already activated.
|
|
|
|
// 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`.
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 0", self.logTag);
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 0", self.logTag);
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!OWSScreenLock.sharedManager.isScreenLockEnabled) {
|
|
|
|
|
|
|
|
// Screen lock is not enabled.
|
|
|
|
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 1", self.logTag);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (self.isScreenLockLocked) {
|
|
|
|
|
|
|
|
// Screen lock is already activated.
|
|
|
|
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 2", self.logTag);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
if (!self.screenLockCountdownDate) {
|
|
|
|
if (!self.screenLockCountdownDate) {
|
|
|
|
// We became inactive, but never started a countdown.
|
|
|
|
// We became inactive, but never started a countdown.
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 1", self.logTag);
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 3", self.logTag);
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
NSTimeInterval countdownInterval = fabs([self.screenLockCountdownDate timeIntervalSinceNow]);
|
|
|
|
NSTimeInterval countdownInterval = fabs([self.screenLockCountdownDate timeIntervalSinceNow]);
|
|
|
@ -149,99 +203,108 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
NSTimeInterval screenLockTimeout = OWSScreenLock.sharedManager.screenLockTimeout;
|
|
|
|
NSTimeInterval screenLockTimeout = OWSScreenLock.sharedManager.screenLockTimeout;
|
|
|
|
OWSAssert(screenLockTimeout >= 0);
|
|
|
|
OWSAssert(screenLockTimeout >= 0);
|
|
|
|
if (countdownInterval >= screenLockTimeout) {
|
|
|
|
if (countdownInterval >= screenLockTimeout) {
|
|
|
|
self.isScreenLockUnlocked = NO;
|
|
|
|
self.isScreenLockLocked = YES;
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive YES 1 (%0.3f >= %0.3f)",
|
|
|
|
|
|
|
|
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive YES 4 (%0.3f >= %0.3f)",
|
|
|
|
self.logTag,
|
|
|
|
self.logTag,
|
|
|
|
countdownInterval,
|
|
|
|
countdownInterval,
|
|
|
|
screenLockTimeout);
|
|
|
|
screenLockTimeout);
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 2 (%0.3f < %0.3f)",
|
|
|
|
DDLogVerbose(@"%@ tryToActivateScreenLockUponBecomingActive NO 5 (%0.3f < %0.3f)",
|
|
|
|
self.logTag,
|
|
|
|
self.logTag,
|
|
|
|
countdownInterval,
|
|
|
|
countdownInterval,
|
|
|
|
screenLockTimeout);
|
|
|
|
screenLockTimeout);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)setAppIsInactive:(BOOL)appIsInactive
|
|
|
|
// Setter for property indicating that the app is either
|
|
|
|
|
|
|
|
// inactive or in the background, e.g. not "foreground and active."
|
|
|
|
|
|
|
|
- (void)setAppIsInactiveOrBackground:(BOOL)appIsInactiveOrBackground
|
|
|
|
{
|
|
|
|
{
|
|
|
|
_appIsInactive = appIsInactive;
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
if (!appIsInactive) {
|
|
|
|
_appIsInactiveOrBackground = appIsInactiveOrBackground;
|
|
|
|
[self tryToActivateScreenLockUponBecomingActive];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (appIsInactiveOrBackground) {
|
|
|
|
|
|
|
|
if (!self.isShowingScreenLockUI) {
|
|
|
|
|
|
|
|
[self startScreenLockCountdownIfNecessary];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
[self tryToActivateScreenLockBasedOnCountdown];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DDLogInfo(@"%@ setAppIsInactiveOrBackground clear screenLockCountdownDate.", self.logTag);
|
|
|
|
self.screenLockCountdownDate = nil;
|
|
|
|
self.screenLockCountdownDate = nil;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[self startInactiveTimerIfNecessary];
|
|
|
|
[self ensureUI];
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Setter for property indicating that the app is in the background.
|
|
|
|
|
|
|
|
// If true, by definition the app is not active.
|
|
|
|
- (void)setAppIsInBackground:(BOOL)appIsInBackground
|
|
|
|
- (void)setAppIsInBackground:(BOOL)appIsInBackground
|
|
|
|
{
|
|
|
|
{
|
|
|
|
if (appIsInBackground && !_appIsInBackground) {
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
[self startScreenLockCountdownIfNecessary];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_appIsInBackground = appIsInBackground;
|
|
|
|
_appIsInBackground = appIsInBackground;
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
if (self.appIsInBackground) {
|
|
|
|
|
|
|
|
[self startScreenLockCountdownIfNecessary];
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
[self tryToActivateScreenLockBasedOnCountdown];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureUI];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)startScreenLockCountdownIfNecessary
|
|
|
|
- (void)startScreenLockCountdownIfNecessary
|
|
|
|
{
|
|
|
|
{
|
|
|
|
|
|
|
|
DDLogVerbose(@"%@ startScreenLockCountdownIfNecessary: %d", self.logTag, self.screenLockCountdownDate != nil);
|
|
|
|
|
|
|
|
|
|
|
|
if (!self.screenLockCountdownDate) {
|
|
|
|
if (!self.screenLockCountdownDate) {
|
|
|
|
DDLogVerbose(@"%@ startScreenLockCountdownIfNecessary.", self.logTag);
|
|
|
|
DDLogInfo(@"%@ startScreenLockCountdown.", self.logTag);
|
|
|
|
self.screenLockCountdownDate = [NSDate new];
|
|
|
|
self.screenLockCountdownDate = [NSDate new];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.didLastUnlockAttemptFail = NO;
|
|
|
|
self.didLastUnlockAttemptFail = NO;
|
|
|
|
|
|
|
|
|
|
|
|
[self clearInactiveTimer];
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)ensureScreenProtection
|
|
|
|
// Ensure that:
|
|
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
// * The blocking window has the correct state.
|
|
|
|
|
|
|
|
// * That we show the "iOS auth UI to unlock" if necessary.
|
|
|
|
|
|
|
|
- (void)ensureUI
|
|
|
|
{
|
|
|
|
{
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
if (!AppReadiness.isAppReady) {
|
|
|
|
if (!AppReadiness.isAppReady) {
|
|
|
|
[AppReadiness runNowOrWhenAppIsReady:^{
|
|
|
|
[AppReadiness runNowOrWhenAppIsReady:^{
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}];
|
|
|
|
}];
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
BOOL shouldHaveScreenLock = self.shouldHaveScreenLock;
|
|
|
|
ScreenLockUIState desiredUIState = self.desiredUIState;
|
|
|
|
BOOL shouldHaveScreenProtection = self.shouldHaveScreenProtection;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BOOL shouldShowBlockWindow = shouldHaveScreenProtection || shouldHaveScreenLock;
|
|
|
|
DDLogVerbose(@"%@, ensureUI: %@", self.logTag, NSStringForScreenLockUIState(desiredUIState));
|
|
|
|
DDLogVerbose(@"%@, shouldHaveScreenProtection: %d, shouldHaveScreenLock: %d, shouldShowBlockWindow: %d",
|
|
|
|
|
|
|
|
self.logTag,
|
|
|
|
[self updateScreenBlockingWindow:desiredUIState animated:YES];
|
|
|
|
shouldHaveScreenProtection,
|
|
|
|
|
|
|
|
shouldHaveScreenLock,
|
|
|
|
|
|
|
|
shouldShowBlockWindow);
|
|
|
|
|
|
|
|
if (self.screenBlockingWindow.hidden != !shouldShowBlockWindow) {
|
|
|
|
|
|
|
|
DDLogInfo(@"%@, %@.", self.logTag, shouldShowBlockWindow ? @"showing block window" : @"hiding block window");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
[self updateScreenBlockingWindow:shouldShowBlockWindow shouldHaveScreenLock:shouldHaveScreenLock animated:YES];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldHaveScreenLock && !self.didLastUnlockAttemptFail) {
|
|
|
|
// Show the "iOS auth UI to unlock" if necessary.
|
|
|
|
[self tryToPresentScreenLockUI];
|
|
|
|
if (desiredUIState == ScreenLockUIStateScreenLock && !self.didLastUnlockAttemptFail) {
|
|
|
|
|
|
|
|
[self tryToPresentAuthUIToUnlockScreenLock];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)tryToPresentScreenLockUI
|
|
|
|
- (void)tryToPresentAuthUIToUnlockScreenLock
|
|
|
|
{
|
|
|
|
{
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
// If we no longer want to present the screen lock UI, abort.
|
|
|
|
if (self.isShowingScreenLockUI) {
|
|
|
|
if (!self.shouldHaveScreenLock) {
|
|
|
|
// We're already showing the auth UI; abort.
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (self.didLastUnlockAttemptFail) {
|
|
|
|
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (self.isShowingScreenLockUI) {
|
|
|
|
if (self.appIsInactiveOrBackground) {
|
|
|
|
|
|
|
|
// Never show the auth UI unless active.
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -254,9 +317,9 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
|
|
|
|
|
|
|
|
self.isShowingScreenLockUI = NO;
|
|
|
|
self.isShowingScreenLockUI = NO;
|
|
|
|
|
|
|
|
|
|
|
|
self.isScreenLockUnlocked = YES;
|
|
|
|
self.isScreenLockLocked = NO;
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
failure:^(NSError *error) {
|
|
|
|
failure:^(NSError *error) {
|
|
|
|
DDLogInfo(@"%@ unlock screen lock failed.", self.logTag);
|
|
|
|
DDLogInfo(@"%@ unlock screen lock failed.", self.logTag);
|
|
|
@ -285,54 +348,37 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
self.didLastUnlockAttemptFail = YES;
|
|
|
|
self.didLastUnlockAttemptFail = YES;
|
|
|
|
|
|
|
|
|
|
|
|
// Re-show the unlock UI.
|
|
|
|
// Re-show the unlock UI.
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}];
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (BOOL)shouldHaveScreenProtection
|
|
|
|
// Determines what the state of the app should be.
|
|
|
|
|
|
|
|
- (ScreenLockUIState)desiredUIState
|
|
|
|
{
|
|
|
|
{
|
|
|
|
// Show 'Screen Protection' if:
|
|
|
|
if (self.isScreenLockLocked) {
|
|
|
|
//
|
|
|
|
if (self.appIsInactiveOrBackground) {
|
|
|
|
// * App is inactive and...
|
|
|
|
DDLogVerbose(@"%@ desiredUIState: screen protection 1.", self.logTag);
|
|
|
|
// * 'Screen Protection' is enabled.
|
|
|
|
return ScreenLockUIStateScreenProtection;
|
|
|
|
if (!self.appIsInactive) {
|
|
|
|
} else {
|
|
|
|
return NO;
|
|
|
|
DDLogVerbose(@"%@ desiredUIState: screen lock 2.", self.logTag);
|
|
|
|
} else if (!Environment.preferences.screenSecurityIsEnabled) {
|
|
|
|
return ScreenLockUIStateScreenLock;
|
|
|
|
return NO;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
|
|
|
|
return YES;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- (BOOL)shouldHaveScreenLock
|
|
|
|
if (!self.appIsInactiveOrBackground) {
|
|
|
|
{
|
|
|
|
// App is inactive or background.
|
|
|
|
if (![TSAccountManager isRegistered]) {
|
|
|
|
DDLogVerbose(@"%@ desiredUIState: none 3.", self.logTag);
|
|
|
|
// Don't show 'Screen Lock' if user is not registered.
|
|
|
|
return ScreenLockUIStateNone;
|
|
|
|
DDLogVerbose(@"%@ shouldHaveScreenLock NO 1.", self.logTag);
|
|
|
|
}
|
|
|
|
return NO;
|
|
|
|
|
|
|
|
} else if (!OWSScreenLock.sharedManager.isScreenLockEnabled) {
|
|
|
|
if (Environment.preferences.screenSecurityIsEnabled) {
|
|
|
|
// Don't show 'Screen Lock' if 'Screen Lock' isn't enabled.
|
|
|
|
DDLogVerbose(@"%@ desiredUIState: screen protection 4.", self.logTag);
|
|
|
|
DDLogVerbose(@"%@ shouldHaveScreenLock NO 2.", self.logTag);
|
|
|
|
return ScreenLockUIStateScreenProtection;
|
|
|
|
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 {
|
|
|
|
} else {
|
|
|
|
BOOL shouldHaveScreenLock = !self.isScreenLockUnlocked;
|
|
|
|
DDLogVerbose(@"%@ desiredUIState: none 5.", self.logTag);
|
|
|
|
DDLogVerbose(@"%@ shouldHaveScreenLock ? %d.", self.logTag, shouldHaveScreenLock);
|
|
|
|
return ScreenLockUIStateNone;
|
|
|
|
return shouldHaveScreenLock;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -346,7 +392,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
buttonTitle:nil
|
|
|
|
buttonTitle:nil
|
|
|
|
buttonAction:^(UIAlertAction *action) {
|
|
|
|
buttonAction:^(UIAlertAction *action) {
|
|
|
|
// After the alert, re-show the unlock UI.
|
|
|
|
// After the alert, re-show the unlock UI.
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}];
|
|
|
|
}];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -360,15 +406,14 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
OWSAssert(rootWindow);
|
|
|
|
OWSAssert(rootWindow);
|
|
|
|
|
|
|
|
|
|
|
|
UIWindow *window = [[UIWindow alloc] initWithFrame:rootWindow.bounds];
|
|
|
|
UIWindow *window = [[UIWindow alloc] initWithFrame:rootWindow.bounds];
|
|
|
|
window.hidden = YES;
|
|
|
|
window.hidden = NO;
|
|
|
|
|
|
|
|
window.windowLevel = UIWindowLevel_Background;
|
|
|
|
window.opaque = YES;
|
|
|
|
window.opaque = YES;
|
|
|
|
window.windowLevel = CGFLOAT_MAX;
|
|
|
|
|
|
|
|
window.backgroundColor = UIColor.ows_materialBlueColor;
|
|
|
|
window.backgroundColor = UIColor.ows_materialBlueColor;
|
|
|
|
|
|
|
|
|
|
|
|
UIViewController *viewController = [UIViewController new];
|
|
|
|
UIViewController *viewController = [UIViewController new];
|
|
|
|
viewController.view.backgroundColor = UIColor.ows_materialBlueColor;
|
|
|
|
viewController.view.backgroundColor = UIColor.ows_materialBlueColor;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
UIView *rootView = viewController.view;
|
|
|
|
UIView *rootView = viewController.view;
|
|
|
|
|
|
|
|
|
|
|
|
UIView *edgesView = [UIView containerView];
|
|
|
|
UIView *edgesView = [UIView containerView];
|
|
|
@ -413,7 +458,8 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
self.screenBlockingImageView = imageView;
|
|
|
|
self.screenBlockingImageView = imageView;
|
|
|
|
self.screenBlockingButton = button;
|
|
|
|
self.screenBlockingButton = button;
|
|
|
|
|
|
|
|
|
|
|
|
[self updateScreenBlockingWindow:YES shouldHaveScreenLock:NO animated:NO];
|
|
|
|
// Default to screen protection until we know otherwise.
|
|
|
|
|
|
|
|
[self updateScreenBlockingWindow:ScreenLockUIStateNone animated:NO];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The "screen blocking" window has three possible states:
|
|
|
|
// The "screen blocking" window has three possible states:
|
|
|
@ -423,13 +469,48 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
// * "Screen Lock, local auth UI presented". Move the Signal logo so that it is visible.
|
|
|
|
// * "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,
|
|
|
|
// * "Screen Lock, local auth UI not presented". Move the Signal logo so that it is visible,
|
|
|
|
// show "unlock" button.
|
|
|
|
// show "unlock" button.
|
|
|
|
- (void)updateScreenBlockingWindow:(BOOL)shouldShowBlockWindow
|
|
|
|
- (void)updateScreenBlockingWindow:(ScreenLockUIState)desiredUIState animated:(BOOL)animated
|
|
|
|
shouldHaveScreenLock:(BOOL)shouldHaveScreenLock
|
|
|
|
|
|
|
|
animated:(BOOL)animated
|
|
|
|
|
|
|
|
{
|
|
|
|
{
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
self.screenBlockingWindow.hidden = !shouldShowBlockWindow;
|
|
|
|
BOOL shouldShowBlockWindow = desiredUIState != ScreenLockUIStateNone;
|
|
|
|
|
|
|
|
if (self.rootWindow.hidden != shouldShowBlockWindow) {
|
|
|
|
|
|
|
|
DDLogInfo(@"%@, %@.", self.logTag, shouldShowBlockWindow ? @"showing block window" : @"hiding block window");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// When we show the block window, try to capture the first responder of
|
|
|
|
|
|
|
|
// the root window before it is hidden.
|
|
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
// When we hide the root window, its first responder will resign.
|
|
|
|
|
|
|
|
if (shouldShowBlockWindow && !self.rootWindow.hidden) {
|
|
|
|
|
|
|
|
self.rootWindowResponder = [UIResponder currentFirstResponder];
|
|
|
|
|
|
|
|
DDLogInfo(@"%@ trying to capture self.rootWindowResponder: %@", self.logTag, self.rootWindowResponder);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// * Show/hide the app's root window as necessary.
|
|
|
|
|
|
|
|
// * 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.
|
|
|
|
|
|
|
|
if (shouldShowBlockWindow) {
|
|
|
|
|
|
|
|
// Show the blocking window in front of the status bar.
|
|
|
|
|
|
|
|
self.screenBlockingWindow.windowLevel = UIWindowLevelStatusBar + 1;
|
|
|
|
|
|
|
|
self.rootWindow.hidden = YES;
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
self.screenBlockingWindow.windowLevel = UIWindowLevel_Background;
|
|
|
|
|
|
|
|
[self.rootWindow makeKeyAndVisible];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// When we hide the block window, try to restore the first
|
|
|
|
|
|
|
|
// responder of the root window.
|
|
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
// It's important we restore first responder status once the user completes
|
|
|
|
|
|
|
|
// In some cases, (RegistrationLock Reminder) it just puts the keyboard back where
|
|
|
|
|
|
|
|
// the user needs it, saving them a tap.
|
|
|
|
|
|
|
|
// But in the case of an inputAccessoryView, like the ConversationViewController,
|
|
|
|
|
|
|
|
// failing to restore firstResponder could hide the input toolbar.
|
|
|
|
|
|
|
|
DDLogInfo(@"%@ trying to restore self.rootWindowResponder: %@", self.logTag, self.rootWindowResponder);
|
|
|
|
|
|
|
|
[self.rootWindowResponder becomeFirstResponder];
|
|
|
|
|
|
|
|
self.rootWindowResponder = nil;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
UIView *rootView = self.screenBlockingViewController.view;
|
|
|
|
UIView *rootView = self.screenBlockingViewController.view;
|
|
|
|
|
|
|
|
|
|
|
@ -437,15 +518,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
|
|
|
|
|
|
|
|
NSMutableArray<NSLayoutConstraint *> *screenBlockingConstraints = [NSMutableArray new];
|
|
|
|
NSMutableArray<NSLayoutConstraint *> *screenBlockingConstraints = [NSMutableArray new];
|
|
|
|
|
|
|
|
|
|
|
|
BOOL shouldShowUnlockButton = (!self.appIsInactive && !self.appIsInBackground && self.didLastUnlockAttemptFail);
|
|
|
|
BOOL shouldHaveScreenLock = desiredUIState == ScreenLockUIStateScreenLock;
|
|
|
|
|
|
|
|
|
|
|
|
DDLogVerbose(@"%@ updateScreenBlockingWindow. shouldShowBlockWindow: %d, shouldHaveScreenLock: %d, "
|
|
|
|
|
|
|
|
@"shouldShowUnlockButton: %d.",
|
|
|
|
|
|
|
|
self.logTag,
|
|
|
|
|
|
|
|
shouldShowBlockWindow,
|
|
|
|
|
|
|
|
shouldHaveScreenLock,
|
|
|
|
|
|
|
|
shouldShowUnlockButton);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NSString *signature = [NSString stringWithFormat:@"%d %d", shouldHaveScreenLock, self.isShowingScreenLockUI];
|
|
|
|
NSString *signature = [NSString stringWithFormat:@"%d %d", shouldHaveScreenLock, self.isShowingScreenLockUI];
|
|
|
|
if ([NSObject isNullableObject:self.screenBlockingSignature equalTo:signature]) {
|
|
|
|
if ([NSObject isNullableObject:self.screenBlockingSignature equalTo:signature]) {
|
|
|
|
// Skip redundant work to avoid interfering with ongoing animations.
|
|
|
|
// Skip redundant work to avoid interfering with ongoing animations.
|
|
|
@ -481,48 +554,35 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
{
|
|
|
|
{
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
DDLogInfo(@"showUnlockUI");
|
|
|
|
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;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DDLogInfo(@"%@ unlockButtonTapped", self.logTag);
|
|
|
|
|
|
|
|
|
|
|
|
self.didLastUnlockAttemptFail = NO;
|
|
|
|
self.didLastUnlockAttemptFail = NO;
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Events
|
|
|
|
#pragma mark - Events
|
|
|
|
|
|
|
|
|
|
|
|
- (void)screenLockDidChange:(NSNotification *)notification
|
|
|
|
- (void)screenLockDidChange:(NSNotification *)notification
|
|
|
|
{
|
|
|
|
{
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- (void)screenLockWasEnabled:(NSNotification *)notification
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
// When we enable screen lock, consider that an unlock.
|
|
|
|
|
|
|
|
self.isScreenLockUnlocked = YES;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DDLogVerbose(@"%@ screenLockWasEnabled", self.logTag);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- (void)registrationStateDidChange
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
OWSAssertIsOnMainThread();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DDLogInfo(@"registrationStateDidChange");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)clearAuthUIWhenActive
|
|
|
|
- (void)clearAuthUIWhenActive
|
|
|
|
{
|
|
|
|
{
|
|
|
|
// For continuity, continue to present blocking screen in "screen lock" mode while
|
|
|
|
// For continuity, continue to present blocking screen in "screen lock" mode while
|
|
|
|
// dismissing the "local auth UI".
|
|
|
|
// dismissing the "local auth UI".
|
|
|
|
if (self.appIsInactive) {
|
|
|
|
if (self.appIsInactiveOrBackground) {
|
|
|
|
self.shouldClearAuthUIWhenActive = YES;
|
|
|
|
self.shouldClearAuthUIWhenActive = YES;
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
self.isShowingScreenLockUI = NO;
|
|
|
|
self.isShowingScreenLockUI = NO;
|
|
|
|
[self ensureScreenProtection];
|
|
|
|
[self ensureUI];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
@ -533,12 +593,12 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
self.isShowingScreenLockUI = NO;
|
|
|
|
self.isShowingScreenLockUI = NO;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.appIsInactive = NO;
|
|
|
|
self.appIsInactiveOrBackground = NO;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)applicationWillResignActive:(NSNotification *)notification
|
|
|
|
- (void)applicationWillResignActive:(NSNotification *)notification
|
|
|
|
{
|
|
|
|
{
|
|
|
|
self.appIsInactive = YES;
|
|
|
|
self.appIsInactiveOrBackground = YES;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)applicationWillEnterForeground:(NSNotification *)notification
|
|
|
|
- (void)applicationWillEnterForeground:(NSNotification *)notification
|
|
|
@ -551,29 +611,27 @@ NS_ASSUME_NONNULL_BEGIN
|
|
|
|
self.appIsInBackground = YES;
|
|
|
|
self.appIsInBackground = YES;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Inactive Timer
|
|
|
|
// Whenever the device date/time is edited by the user,
|
|
|
|
|
|
|
|
// trigger screen lock immediately if enabled.
|
|
|
|
- (void)inactiveTimerDidFire
|
|
|
|
- (void)clockDidChange:(NSNotification *)notification
|
|
|
|
{
|
|
|
|
{
|
|
|
|
[self startScreenLockCountdownIfNecessary];
|
|
|
|
DDLogInfo(@"%@ clock did change", self.logTag);
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
- (void)startInactiveTimerIfNecessary
|
|
|
|
if (!AppReadiness.isAppReady) {
|
|
|
|
{
|
|
|
|
// It's not safe to access OWSScreenLock.isScreenLockEnabled
|
|
|
|
if (self.appIsInactive && !self.isShowingScreenLockUI && !self.inactiveTimer) {
|
|
|
|
// until the app is ready.
|
|
|
|
[self.inactiveTimer invalidate];
|
|
|
|
//
|
|
|
|
self.inactiveTimer = [NSTimer weakScheduledTimerWithTimeInterval:45.f
|
|
|
|
// We don't need to try to lock the screen lock;
|
|
|
|
target:self
|
|
|
|
// It will be initialized by `setupWithRootWindow`.
|
|
|
|
selector:@selector(inactiveTimerDidFire)
|
|
|
|
DDLogVerbose(@"%@ clockDidChange 0", self.logTag);
|
|
|
|
userInfo:nil
|
|
|
|
return;
|
|
|
|
repeats:NO];
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
self.isScreenLockLocked = OWSScreenLock.sharedManager.isScreenLockEnabled;
|
|
|
|
|
|
|
|
|
|
|
|
- (void)clearInactiveTimer
|
|
|
|
// NOTE: this notifications fires _before_ applicationDidBecomeActive,
|
|
|
|
{
|
|
|
|
// which is desirable. Don't assume that though; call ensureUI
|
|
|
|
[self.inactiveTimer invalidate];
|
|
|
|
// just in case it's necessary.
|
|
|
|
self.inactiveTimer = nil;
|
|
|
|
[self ensureUI];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@end
|
|
|
|
@end
|
|
|
|