import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import Postbox import TelegramCore import SyncCore import TelegramPresentationData import AccountContext import LocalAuth import AppBundle import PasscodeInputFieldNode import MonotonicTime private let titleFont = Font.regular(20.0) private let subtitleFont = Font.regular(15.0) private let buttonFont = Font.regular(17.0) final class PasscodeEntryControllerNode: ASDisplayNode { private let accountManager: AccountManager private var theme: PresentationTheme private var strings: PresentationStrings private var wallpaper: TelegramWallpaper private let passcodeType: PasscodeEntryFieldType private let biometricsType: LocalAuthBiometricAuthentication? private let arguments: PasscodeEntryControllerPresentationArguments private var background: PasscodeBackground? private let statusBar: StatusBar private let modalPresentation: Bool private let backgroundNode: ASImageNode private let iconNode: PasscodeLockIconNode private let titleNode: PasscodeEntryLabelNode private let inputFieldNode: PasscodeInputFieldNode private let subtitleNode: PasscodeEntryLabelNode private let keyboardNode: PasscodeEntryKeyboardNode private let cancelButtonNode: HighlightableButtonNode private let deleteButtonNode: HighlightableButtonNode private let biometricButtonNode: HighlightableButtonNode private let effectView: UIVisualEffectView private var invalidAttempts: AccessChallengeAttempts? private var timer: SwiftSignalKit.Timer? private let hapticFeedback = HapticFeedback() private var validLayout: ContainerViewLayout? var checkPasscode: ((String) -> Void)? var requestBiometrics: (() -> Void)? init(accountManager: AccountManager, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, passcodeType: PasscodeEntryFieldType, biometricsType: LocalAuthBiometricAuthentication?, arguments: PasscodeEntryControllerPresentationArguments, statusBar: StatusBar, modalPresentation: Bool) { self.accountManager = accountManager self.theme = theme self.strings = strings self.wallpaper = wallpaper self.passcodeType = passcodeType self.biometricsType = biometricsType self.arguments = arguments self.statusBar = statusBar self.modalPresentation = modalPresentation self.backgroundNode = ASImageNode() self.backgroundNode.contentMode = .scaleToFill self.iconNode = PasscodeLockIconNode() self.titleNode = PasscodeEntryLabelNode() self.inputFieldNode = PasscodeInputFieldNode(color: .white, accentColor: .white, fieldType: passcodeType, keyboardAppearance: .dark, useCustomNumpad: true) self.subtitleNode = PasscodeEntryLabelNode() self.keyboardNode = PasscodeEntryKeyboardNode() self.cancelButtonNode = HighlightableButtonNode() self.deleteButtonNode = HighlightableButtonNode() self.biometricButtonNode = HighlightableButtonNode() self.effectView = UIVisualEffectView(effect: nil) super.init() self.setViewBlock({ return UITracingLayerView() }) self.backgroundColor = .clear self.iconNode.unlockedColor = theme.rootController.navigationBar.primaryTextColor self.keyboardNode.charactedEntered = { [weak self] character in self?.inputFieldNode.append(character) } self.inputFieldNode.complete = { [weak self] passcode in guard let strongSelf = self else { return } if strongSelf.shouldWaitBeforeNextAttempt() { strongSelf.animateError() } else { strongSelf.checkPasscode?(passcode) } } self.cancelButtonNode.setTitle(strings.Common_Cancel, with: buttonFont, with: .white, for: .normal) self.deleteButtonNode.setTitle(strings.Common_Delete, with: buttonFont, with: .white, for: .normal) if let biometricsType = self.biometricsType { switch biometricsType { case .touchId: self.biometricButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Settings/PasscodeTouchId"), color: .white), for: .normal) case .faceId: self.biometricButtonNode.setImage(generateTintedImage(image: UIImage(bundleImageName: "Settings/PasscodeFaceId"), color: .white), for: .normal) } } self.addSubnode(self.backgroundNode) self.addSubnode(self.iconNode) self.addSubnode(self.titleNode) self.addSubnode(self.inputFieldNode) self.addSubnode(self.subtitleNode) self.addSubnode(self.keyboardNode) self.addSubnode(self.deleteButtonNode) self.addSubnode(self.biometricButtonNode) if self.arguments.cancel != nil { self.addSubnode(self.cancelButtonNode) } } override func didLoad() { super.didLoad() self.view.insertSubview(self.effectView, at: 0) if self.arguments.cancel != nil { self.cancelButtonNode.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside) } self.deleteButtonNode.addTarget(self, action: #selector(self.deletePressed), forControlEvents: .touchUpInside) self.biometricButtonNode.addTarget(self, action: #selector(self.biometricsPressed), forControlEvents: .touchUpInside) } @objc private func cancelPressed() { self.animateOut(down: true) self.arguments.cancel?() } @objc private func deletePressed() { self.hapticFeedback.tap() self.inputFieldNode.delete() } @objc private func biometricsPressed() { self.requestBiometrics?() } func activateInput() { self.inputFieldNode.activateInput() } func updatePresentationData(_ presentationData: PresentationData) { self.theme = presentationData.theme self.strings = presentationData.strings self.wallpaper = presentationData.chatWallpaper self.deleteButtonNode.setTitle(self.strings.Common_Delete, with: buttonFont, with: .white, for: .normal) if let validLayout = self.validLayout { self.containerLayoutUpdated(validLayout, navigationBarHeight: 0.0, transition: .immediate) } } func updateBackground() { guard let validLayout = self.validLayout else { return } var size = validLayout.size if let background = self.background, background.size == size { return } switch self.wallpaper { case .image, .file: if let image = chatControllerBackgroundImage(theme: self.theme, wallpaper: self.wallpaper, mediaBox: self.accountManager.mediaBox, composed: false, knockoutMode: false) { self.background = ImageBasedPasscodeBackground(image: image, size: size) } else { self.background = GradientPasscodeBackground(size: size, backgroundColors: self.theme.passcode.backgroundColors.colors, buttonColor: self.theme.passcode.buttonColor) } default: self.background = GradientPasscodeBackground(size: size, backgroundColors: self.theme.passcode.backgroundColors.colors, buttonColor: self.theme.passcode.buttonColor) } if let background = self.background { self.backgroundNode.image = background.backgroundImage self.keyboardNode.updateBackground(background) self.inputFieldNode.updateBackground(background.foregroundImage, size: background.size) } } private let waitInterval: Int32 = 60 private func shouldWaitBeforeNextAttempt() -> Bool { if let attempts = self.invalidAttempts { if attempts.count >= 6 { var bootTimestamp: Int32 = 0 let uptime = getDeviceUptimeSeconds(&bootTimestamp) if attempts.bootTimestamp != bootTimestamp { return true } if uptime - attempts.uptime < waitInterval { return true } else { return false } } else { return false } } else { return false } } func updateInvalidAttempts(_ attempts: AccessChallengeAttempts?, animated: Bool = false) { self.invalidAttempts = attempts if let attempts = attempts { var text = NSAttributedString(string: "") if attempts.count >= 6 && self.shouldWaitBeforeNextAttempt() { text = NSAttributedString(string: self.strings.PasscodeSettings_TryAgainIn1Minute, font: subtitleFont, textColor: .white) self.timer?.invalidate() let timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: true, completion: { [weak self] in if let strongSelf = self { if !strongSelf.shouldWaitBeforeNextAttempt() { strongSelf.updateInvalidAttempts(strongSelf.invalidAttempts, animated: true) strongSelf.timer?.invalidate() strongSelf.timer = nil } } }, queue: Queue.mainQueue()) self.timer = timer timer.start() } self.subtitleNode.setAttributedText(text, animation: animated ? .crossFade : .none, completion: {}) } else { self.subtitleNode.setAttributedText(NSAttributedString(string: ""), animation: animated ? .crossFade : .none, completion: {}) } } func hideBiometrics() { self.biometricButtonNode.layer.animateScale(from: 1.0, to: 0.00001, duration: 0.25, delay: 0.0, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, completion: { [weak self] _ in self?.biometricButtonNode.isHidden = true }) self.animateError() } func initialAppearance(fadeIn: Bool = false) { if fadeIn { let effect = self.theme.overallDarkAppearance ? UIBlurEffect(style: .dark) : UIBlurEffect(style: .light) UIView.animate(withDuration: 0.3, animations: { if #available(iOS 9.0, *) { self.effectView.effect = effect } else { self.effectView.alpha = 1.0 } }) self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } self.titleNode.setAttributedText(NSAttributedString(string: self.strings.EnterPasscode_EnterPasscode, font: titleFont, textColor: .white), animation: .none) } func animateIn(iconFrame: CGRect, completion: @escaping () -> Void = {}) { let effect = self.theme.overallDarkAppearance ? UIBlurEffect(style: .dark) : UIBlurEffect(style: .light) UIView.animate(withDuration: 0.3, animations: { if #available(iOS 9.0, *) { self.effectView.effect = effect } else { self.effectView.alpha = 1.0 } }) self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) if !iconFrame.isEmpty { self.iconNode.animateIn(fromScale: 0.416) self.iconNode.layer.animatePosition(from: iconFrame.center.offsetBy(dx: 6.0, dy: 6.0), to: self.iconNode.layer.position, duration: 0.45) } self.statusBar.layer.removeAnimation(forKey: "opacity") self.statusBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) self.subtitleNode.isHidden = true self.inputFieldNode.isHidden = true self.keyboardNode.isHidden = true self.cancelButtonNode.isHidden = true self.deleteButtonNode.isHidden = true self.biometricButtonNode.isHidden = true self.titleNode.setAttributedText(NSAttributedString(string: self.strings.Passcode_AppLockedAlert.replacingOccurrences(of: "\n", with: " "), font: titleFont, textColor: .white), animation: .slideIn, completion: { self.subtitleNode.isHidden = false self.inputFieldNode.isHidden = false self.keyboardNode.isHidden = false self.cancelButtonNode.isHidden = false self.deleteButtonNode.isHidden = false self.biometricButtonNode.isHidden = false self.subtitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) self.inputFieldNode.animateIn() self.keyboardNode.animateIn() var biometricDelay = 0.3 if case .alphanumeric = self.passcodeType { biometricDelay = 0.0 } else { self.cancelButtonNode.layer.animateScale(from: 0.0001, to: 1.0, duration: 0.25, delay: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) self.deleteButtonNode.layer.animateScale(from: 0.0001, to: 1.0, duration: 0.25, delay: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) } self.biometricButtonNode.layer.animateScale(from: 0.0001, to: 1.0, duration: 0.25, delay: biometricDelay, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue) Queue.mainQueue().after(1.5, { self.titleNode.setAttributedText(NSAttributedString(string: self.strings.EnterPasscode_EnterPasscode, font: titleFont, textColor: .white), animation: .crossFade) }) completion() }) } func animateOut(down: Bool = false, completion: @escaping () -> Void = {}) { self.statusBar.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) self.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: down ? self.bounds.size.height : -self.bounds.size.height), duration: 0.2, removeOnCompletion: false, additive: true, completion: { _ in completion() }) } func animateSuccess() { self.iconNode.animateUnlock() self.inputFieldNode.animateSuccess() } func animateError() { self.inputFieldNode.reset() self.inputFieldNode.layer.addShakeAnimation(amplitude: -30.0, duration: 0.5, count: 6, decay: true) self.iconNode.layer.addShakeAnimation(amplitude: -8.0, duration: 0.5, count: 6, decay: true) self.hapticFeedback.error() } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { self.validLayout = layout self.updateBackground() let bounds = CGRect(origin: CGPoint(), size: layout.size) transition.updateFrame(node: self.backgroundNode, frame: bounds) transition.updateFrame(view: self.effectView, frame: bounds) switch self.passcodeType { case .digits6, .digits4: self.keyboardNode.alpha = 1.0 self.deleteButtonNode.alpha = 1.0 case .alphanumeric: self.keyboardNode.alpha = 0.0 self.deleteButtonNode.alpha = 0.0 } let isLandscape = layout.orientation == .landscape && layout.deviceMetrics.type != .tablet let keyboardHidden = self.keyboardNode.alpha == 0.0 let layoutSize: CGSize if isLandscape { if keyboardHidden { layoutSize = CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right, height: layout.size.height) } else { layoutSize = CGSize(width: layout.size.width / 2.0, height: layout.size.height) } } else { layoutSize = layout.size } if layout.size.width == 320.0 || (isLandscape && keyboardHidden) { self.iconNode.alpha = 0.0 } let passcodeLayout = PasscodeLayout(layout: layout, modalPresentation: self.modalPresentation) let inputFieldOffset: CGFloat if isLandscape { let bottomInset = layout.inputHeight ?? 0.0 if !keyboardHidden || bottomInset == 0.0 { inputFieldOffset = floor(layoutSize.height / 2.0 + 12.0) } else { inputFieldOffset = floor(layoutSize.height - bottomInset) / 2.0 - 40.0 } } else { inputFieldOffset = passcodeLayout.inputFieldOffset } let inputFieldFrame = self.inputFieldNode.updateLayout(size: layoutSize, topOffset: inputFieldOffset, transition: transition) transition.updateFrame(node: self.inputFieldNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left, y: 0.0), size: layoutSize)) let titleFrame: CGRect if isLandscape { let titleSize = self.titleNode.updateLayout(size: CGSize(width: layoutSize.width, height: layout.size.height), transition: transition) titleFrame = CGRect(origin: CGPoint(x: layout.safeInsets.left, y: inputFieldFrame.minY - titleSize.height - 16.0), size: titleSize) } else { let titleSize = self.titleNode.updateLayout(size: layout.size, transition: transition) titleFrame = CGRect(origin: CGPoint(x: 0.0, y: passcodeLayout.titleOffset), size: titleSize) } transition.updateFrame(node: self.titleNode, frame: titleFrame) let iconSize = CGSize(width: 35.0, height: 37.0) let iconFrame: CGRect if isLandscape { iconFrame = CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layoutSize.width - iconSize.width) / 2.0) + 6.0, y: titleFrame.minY - iconSize.height - 14.0), size: iconSize) } else { iconFrame = CGRect(origin: CGPoint(x: floor((layoutSize.width - iconSize.width) / 2.0) + 6.0, y: layout.insets(options: .statusBar).top + 15.0), size: iconSize) } transition.updateFrame(node: self.iconNode, frame: iconFrame) var subtitleOffset = passcodeLayout.subtitleOffset if case .alphanumeric = self.passcodeType { subtitleOffset = 16.0 } let subtitleSize = self.subtitleNode.updateLayout(size: layoutSize, transition: transition) transition.updateFrame(node: self.subtitleNode, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left, y: inputFieldFrame.maxY + subtitleOffset), size: subtitleSize)) let (keyboardFrame, keyboardButtonSize) = self.keyboardNode.updateLayout(layout: passcodeLayout, transition: transition) transition.updateFrame(node: self.keyboardNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: layout.size)) let bottomInset = layout.inputHeight ?? 0.0 let cancelSize = self.cancelButtonNode.measure(layout.size) var bottomButtonY = layout.size.height - layout.intrinsicInsets.bottom - cancelSize.height - passcodeLayout.keyboard.deleteOffset var cancelX = floor(keyboardFrame.minX + keyboardButtonSize.width / 2.0 - cancelSize.width / 2.0) var cancelY = bottomButtonY if bottomInset > 0 && keyboardHidden { cancelX = floor((layout.size.width - cancelSize.width) / 2.0) cancelY = layout.size.height - bottomInset - cancelSize.height - 15.0 - layout.intrinsicInsets.bottom } else if isLandscape { bottomButtonY = keyboardFrame.maxY - keyboardButtonSize.height + floor((keyboardButtonSize.height - cancelSize.height) / 2.0) cancelY = bottomButtonY } transition.updateFrame(node: self.cancelButtonNode, frame: CGRect(origin: CGPoint(x: cancelX, y: cancelY), size: cancelSize)) let deleteSize = self.deleteButtonNode.measure(layout.size) transition.updateFrame(node: self.deleteButtonNode, frame: CGRect(origin: CGPoint(x: floor(keyboardFrame.maxX - keyboardButtonSize.width / 2.0 - deleteSize.width / 2.0), y: bottomButtonY), size: deleteSize)) if let biometricIcon = self.biometricButtonNode.image(for: .normal) { var biometricX = layout.safeInsets.left + floor((layoutSize.width - biometricIcon.size.width) / 2.0) var biometricY: CGFloat = 0.0 if isLandscape { if bottomInset > 0 && keyboardHidden { biometricX = cancelX + cancelSize.width + 64.0 } biometricY = cancelY + floor((cancelSize.height - biometricIcon.size.height) / 2.0) } else { if bottomInset > 0 && keyboardHidden { biometricY = inputFieldFrame.maxY + floor((layout.size.height - bottomInset - inputFieldFrame.maxY - biometricIcon.size.height) / 2.0) } else { biometricY = keyboardFrame.maxY + passcodeLayout.keyboard.biometricsOffset } } transition.updateFrame(node: self.biometricButtonNode, frame: CGRect(origin: CGPoint(x: biometricX, y: biometricY), size: biometricIcon.size)) } } }