import Foundation import UIKit import Display import AsyncDisplayKit import SwiftSignalKit import Postbox import TelegramCore import TelegramPresentationData import AccountContext import LocalAuth import AppBundle import PasscodeInputFieldNode 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 context: AccountContext 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 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(context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, wallpaper: TelegramWallpaper, passcodeType: PasscodeEntryFieldType, biometricsType: LocalAuthBiometricAuthentication?, arguments: PasscodeEntryControllerPresentationArguments, statusBar: StatusBar) { self.context = context self.theme = theme self.strings = strings self.wallpaper = wallpaper self.passcodeType = passcodeType self.biometricsType = biometricsType self.arguments = arguments self.statusBar = statusBar 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.context.sharedContext.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 { if Int32(CFAbsoluteTimeGetCurrent()) - attempts.timestamp < 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: Double(attempts.timestamp + waitInterval - Int32(CFAbsoluteTimeGetCurrent())), repeat: false, completion: { [weak self] in if let strongSelf = self { strongSelf.timer = nil strongSelf.updateInvalidAttempts(strongSelf.invalidAttempts, animated: true) } }, 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) 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)) } } }