From 405cd5ed25481f54989dcce8411b572e9982f41e Mon Sep 17 00:00:00 2001 From: Niels Andriesse Date: Thu, 12 Dec 2019 16:07:08 +1100 Subject: [PATCH] Implement link device screen redesign --- Signal.xcodeproj/project.pbxproj | 12 +- .../src/Loki/Redesign/Components/Button.swift | 5 +- .../View Controllers/DeviceLinkingModal.swift | 15 +- .../View Controllers/DisplayNameVC.swift | 1 - .../Redesign/View Controllers/LandingVC.swift | 125 +++++++- .../View Controllers/LinkDeviceVC.swift | 287 ++++++++++++++++++ .../LinkDeviceVCDelegate.swift | 5 + .../translations/en.lproj/Localizable.strings | 4 + 8 files changed, 428 insertions(+), 26 deletions(-) create mode 100644 Signal/src/Loki/Redesign/View Controllers/LinkDeviceVC.swift create mode 100644 Signal/src/Loki/Redesign/View Controllers/LinkDeviceVCDelegate.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index b32f042f3..848b729ea 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -577,6 +577,8 @@ B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */; }; B85357C123A1B81900AAF6CD /* SeedReminderViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357C023A1B81900AAF6CD /* SeedReminderViewDelegate.swift */; }; B85357C323A1BD1200AAF6CD /* SeedVCV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357C223A1BD1200AAF6CD /* SeedVCV2.swift */; }; + B85357C523A1F13800AAF6CD /* LinkDeviceVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357C423A1F13800AAF6CD /* LinkDeviceVC.swift */; }; + B85357C723A1FB5100AAF6CD /* LinkDeviceVCDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357C623A1FB5100AAF6CD /* LinkDeviceVCDelegate.swift */; }; B86BD08423399ACF000F5AE3 /* Modal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08323399ACF000F5AE3 /* Modal.swift */; }; B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08523399CEF000F5AE3 /* SeedModal.swift */; }; B885D5F4233491AB00EE0D8E /* DeviceLinkingModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B885D5F3233491AB00EE0D8E /* DeviceLinkingModal.swift */; }; @@ -1415,6 +1417,8 @@ B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedReminderView.swift; sourceTree = ""; }; B85357C023A1B81900AAF6CD /* SeedReminderViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedReminderViewDelegate.swift; sourceTree = ""; }; B85357C223A1BD1200AAF6CD /* SeedVCV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedVCV2.swift; sourceTree = ""; }; + B85357C423A1F13800AAF6CD /* LinkDeviceVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDeviceVC.swift; sourceTree = ""; }; + B85357C623A1FB5100AAF6CD /* LinkDeviceVCDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDeviceVCDelegate.swift; sourceTree = ""; }; B86BD08323399ACF000F5AE3 /* Modal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Modal.swift; sourceTree = ""; }; B86BD08523399CEF000F5AE3 /* SeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedModal.swift; sourceTree = ""; }; B885D5F3233491AB00EE0D8E /* DeviceLinkingModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLinkingModal.swift; sourceTree = ""; }; @@ -2788,11 +2792,11 @@ B8B26C90234D8CBD004ED98C /* MentionCandidateSelectionViewDelegate.swift */, B8BB82AC238F734800BA5194 /* ProfilePictureView.swift */, B8BB82B02390C37000BA5194 /* SearchBar.swift */, + B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */, + B85357C023A1B81900AAF6CD /* SeedReminderViewDelegate.swift */, B8BB82B82394911B00BA5194 /* Separator.swift */, B8CCF638239721E20091D419 /* TabBar.swift */, B8BB82B423947F2D00BA5194 /* TextField.swift */, - B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */, - B85357C023A1B81900AAF6CD /* SeedReminderViewDelegate.swift */, ); path = Components; sourceTree = ""; @@ -2819,6 +2823,8 @@ B8BB82A4238F627000BA5194 /* HomeVC.swift */, B8CCF63E23975CFB0091D419 /* JoinPublicChatVC.swift */, B82B40872399EB0E00A248E7 /* LandingVC.swift */, + B85357C423A1F13800AAF6CD /* LinkDeviceVC.swift */, + B85357C623A1FB5100AAF6CD /* LinkDeviceVCDelegate.swift */, B86BD08323399ACF000F5AE3 /* Modal.swift */, B8CCF63623961D6D0091D419 /* NewPrivateChatVC.swift */, B894D0742339EDCF00B4D94D /* NukeDataModal.swift */, @@ -3966,6 +3972,7 @@ B8BB82B12390C37000BA5194 /* SearchBar.swift in Sources */, 348BB25D20A0C5530047AEC2 /* ContactShareViewHelper.swift in Sources */, 34B3F8801E8DF1700035BE1A /* InviteFlow.swift in Sources */, + B85357C523A1F13800AAF6CD /* LinkDeviceVC.swift in Sources */, 457C87B82032645C008D52D6 /* DebugUINotifications.swift in Sources */, 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */, 4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */, @@ -4058,6 +4065,7 @@ 34D2CCDF206939B400CB1A14 /* DebugUIMessagesAction.m in Sources */, 340FC8AC204DAC8D007AEB0F /* PrivacySettingsTableViewController.m in Sources */, B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */, + B85357C723A1FB5100AAF6CD /* LinkDeviceVCDelegate.swift in Sources */, 340FC8C5204DE223007AEB0F /* DebugUIBackup.m in Sources */, 4C11AA5020FD59C700351FBD /* MessageStatusView.swift in Sources */, 340FC8AE204DAC8D007AEB0F /* OWSSoundSettingsViewController.m in Sources */, diff --git a/Signal/src/Loki/Redesign/Components/Button.swift b/Signal/src/Loki/Redesign/Components/Button.swift index 1b8b84a16..d94d18b89 100644 --- a/Signal/src/Loki/Redesign/Components/Button.swift +++ b/Signal/src/Loki/Redesign/Components/Button.swift @@ -4,7 +4,7 @@ final class Button : UIButton { private let size: Size enum Style { - case unimportant, regular, prominentOutline, prominentFilled + case unimportant, regular, prominentOutline, prominentFilled, regularBorderless } enum Size { @@ -33,6 +33,7 @@ final class Button : UIButton { case .regular: fillColor = UIColor.clear case .prominentOutline: fillColor = UIColor.clear case .prominentFilled: fillColor = Colors.accent + case .regularBorderless: fillColor = UIColor.clear } let borderColor: UIColor switch style { @@ -40,6 +41,7 @@ final class Button : UIButton { case .regular: borderColor = Colors.text case .prominentOutline: borderColor = Colors.accent case .prominentFilled: borderColor = Colors.accent + case .regularBorderless: borderColor = UIColor.clear } let textColor: UIColor switch style { @@ -47,6 +49,7 @@ final class Button : UIButton { case .regular: textColor = Colors.text case .prominentOutline: textColor = Colors.accent case .prominentFilled: textColor = Colors.text + case .regularBorderless: textColor = Colors.text } let height: CGFloat switch size { diff --git a/Signal/src/Loki/Redesign/View Controllers/DeviceLinkingModal.swift b/Signal/src/Loki/Redesign/View Controllers/DeviceLinkingModal.swift index c4cca45d2..fb5138a31 100644 --- a/Signal/src/Loki/Redesign/View Controllers/DeviceLinkingModal.swift +++ b/Signal/src/Loki/Redesign/View Controllers/DeviceLinkingModal.swift @@ -107,13 +107,7 @@ final class DeviceLinkingModal : Modal, DeviceLinkingSessionDelegate { case .master: qrCodeImageView.set(.height, to: 128) let hexEncodedPublicKey = OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey - let data = hexEncodedPublicKey.data(using: .utf8) - let filter = CIFilter(name: "CIQRCodeGenerator")! - filter.setValue(data, forKey: "inputMessage") - let qrCodeAsCIImage = filter.outputImage! - let scaledQRCodeAsCIImage = qrCodeAsCIImage.transformed(by: CGAffineTransform(scaleX: 4.8, y: 4.8)) - let qrCode = UIImage(ciImage: scaledQRCodeAsCIImage) - qrCodeImageView.image = qrCode + qrCodeImageView.image = QRCode.generate(for: hexEncodedPublicKey) case .slave: spinner.set(.height, to: 64) spinner.startAnimating() @@ -126,7 +120,7 @@ final class DeviceLinkingModal : Modal, DeviceLinkingSessionDelegate { }() subtitleLabel.text = { switch mode { - case .master: return NSLocalizedString("Create a new account on your other device and click \"Link Device\" when you're at the \"Create Your Loki Messenger Account\" step to start the linking process", comment: "") + case .master: return NSLocalizedString("Create a new account on your other device and click \"Link to an existing account\" to start the linking process", comment: "") case .slave: return NSLocalizedString("Please check that the words below match the ones shown on your other device", comment: "") } }() @@ -190,8 +184,9 @@ final class DeviceLinkingModal : Modal, DeviceLinkingSessionDelegate { print("[Loki] Failed to add device link due to error: \(error).") } Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in - self.delegate?.handleDeviceLinkAuthorized(deviceLink) - self.dismiss(animated: true, completion: nil) + self.dismiss(animated: true) { + self.delegate?.handleDeviceLinkAuthorized(deviceLink) + } } } diff --git a/Signal/src/Loki/Redesign/View Controllers/DisplayNameVC.swift b/Signal/src/Loki/Redesign/View Controllers/DisplayNameVC.swift index b22dc7329..505b1f0e0 100644 --- a/Signal/src/Loki/Redesign/View Controllers/DisplayNameVC.swift +++ b/Signal/src/Loki/Redesign/View Controllers/DisplayNameVC.swift @@ -155,7 +155,6 @@ final class DisplayNameVC : UIViewController { return showError(title: NSLocalizedString("Please pick a shorter display name", comment: "")) } TSAccountManager.sharedInstance().didRegister() - UserDefaults.standard.set(true, forKey: "didUpdateForMainnet") OWSProfileManager.shared().updateLocalProfileName(displayName, avatarImage: nil, success: { }, failure: { }) // Try to save the user name but ignore the result let homeVC = HomeVC() navigationController!.setViewControllers([ homeVC ], animated: true) diff --git a/Signal/src/Loki/Redesign/View Controllers/LandingVC.swift b/Signal/src/Loki/Redesign/View Controllers/LandingVC.swift index e4bd83b8b..622130e48 100644 --- a/Signal/src/Loki/Redesign/View Controllers/LandingVC.swift +++ b/Signal/src/Loki/Redesign/View Controllers/LandingVC.swift @@ -1,5 +1,5 @@ -final class LandingVC : UIViewController { +final class LandingVC : UIViewController, LinkDeviceVCDelegate, DeviceLinkingModalDelegate { private var fakeChatViewContentOffset: CGPoint! // MARK: Components @@ -9,6 +9,30 @@ final class LandingVC : UIViewController { return result }() + private lazy var registerButton: Button = { + let result = Button(style: .prominentFilled, size: .large) + result.setTitle(NSLocalizedString("Create Account", comment: ""), for: UIControl.State.normal) + result.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.addTarget(self, action: #selector(register), for: UIControl.Event.touchUpInside) + return result + }() + + private lazy var restoreButton: Button = { + let result = Button(style: .prominentOutline, size: .large) + result.setTitle(NSLocalizedString("Continue your Loki Messenger", comment: ""), for: UIControl.State.normal) + result.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.addTarget(self, action: #selector(restore), for: UIControl.Event.touchUpInside) + return result + }() + + private lazy var linkButton: Button = { + let result = Button(style: .regularBorderless, size: .small) + result.setTitle(NSLocalizedString("Link to an existing account", comment: ""), for: UIControl.State.normal) + result.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize) + result.addTarget(self, action: #selector(linkDevice), for: UIControl.Event.touchUpInside) + return result + }() + // MARK: Settings override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } @@ -48,16 +72,14 @@ final class LandingVC : UIViewController { // Set up spacers let topSpacer = UIView.vStretchingSpacer() let bottomSpacer = UIView.vStretchingSpacer() - // Set up register button - let registerButton = Button(style: .prominentFilled, size: .large) - registerButton.setTitle(NSLocalizedString("Create Account", comment: ""), for: UIControl.State.normal) - registerButton.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) - registerButton.addTarget(self, action: #selector(register), for: UIControl.Event.touchUpInside) - // Set up restore button - let restoreButton = Button(style: .prominentOutline, size: .large) - restoreButton.setTitle(NSLocalizedString("Continue your Loki Messenger", comment: ""), for: UIControl.State.normal) - restoreButton.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) - restoreButton.addTarget(self, action: #selector(restore), for: UIControl.Event.touchUpInside) + // Set up link button container + let linkButtonContainer = UIView() + linkButtonContainer.set(.height, to: Values.onboardingButtonBottomOffset) + linkButtonContainer.addSubview(linkButton) + linkButton.pin(.leading, to: .leading, of: linkButtonContainer, withInset: Values.massiveSpacing) + linkButton.pin(.top, to: .top, of: linkButtonContainer) + linkButtonContainer.pin(.trailing, to: .trailing, of: linkButton, withInset: Values.massiveSpacing) + linkButtonContainer.pin(.bottom, to: .bottom, of: linkButton, withInset: 10) // Set up button stack view let buttonStackView = UIStackView(arrangedSubviews: [ registerButton, restoreButton ]) buttonStackView.axis = .vertical @@ -71,7 +93,7 @@ final class LandingVC : UIViewController { buttonStackViewContainer.pin(.trailing, to: .trailing, of: buttonStackView, withInset: Values.massiveSpacing) buttonStackViewContainer.pin(.bottom, to: .bottom, of: buttonStackView) // Set up main stack view - let mainStackView = UIStackView(arrangedSubviews: [ topSpacer, titleLabelContainer, UIView.spacer(withHeight: Values.mediumSpacing), fakeChatView, bottomSpacer, buttonStackViewContainer, UIView.spacer(withHeight: Values.onboardingButtonBottomOffset) ]) + let mainStackView = UIStackView(arrangedSubviews: [ topSpacer, titleLabelContainer, UIView.spacer(withHeight: Values.mediumSpacing), fakeChatView, bottomSpacer, buttonStackViewContainer, linkButtonContainer ]) mainStackView.axis = .vertical mainStackView.alignment = .fill view.addSubview(mainStackView) @@ -102,4 +124,83 @@ final class LandingVC : UIViewController { let restoreVC = RestoreVC() navigationController!.pushViewController(restoreVC, animated: true) } + + @objc private func linkDevice() { + let linkDeviceVC = LinkDeviceVC() + linkDeviceVC.delegate = self + let navigationController = OWSNavigationController(rootViewController: linkDeviceVC) + present(navigationController, animated: true, completion: nil) + } + + // MARK: Device Linking + func requestDeviceLink(with hexEncodedPublicKey: String) { + guard ECKeyPair.isValidHexEncodedPublicKey(candidate: hexEncodedPublicKey) else { + let alert = UIAlertController(title: NSLocalizedString("Invalid Public Key", comment: ""), message: NSLocalizedString("Please make sure the public key you entered is correct and try again.", comment: ""), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), accessibilityIdentifier: nil, style: .default, handler: nil)) + return present(alert, animated: true, completion: nil) + } + let seed = Randomness.generateRandomBytes(16)! + let keyPair = Curve25519.generateKeyPair(fromSeed: seed + seed) + let identityManager = OWSIdentityManager.shared() + let databaseConnection = identityManager.value(forKey: "dbConnection") as! YapDatabaseConnection + databaseConnection.setObject(seed.toHexString(), forKey: "LKLokiSeed", inCollection: OWSPrimaryStorageIdentityKeyStoreCollection) + databaseConnection.setObject(keyPair, forKey: OWSPrimaryStorageIdentityKeyStoreIdentityKey, inCollection: OWSPrimaryStorageIdentityKeyStoreCollection) + TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = keyPair.hexEncodedPublicKey + TSAccountManager.sharedInstance().didRegister() + setUserInteractionEnabled(false) + let _ = LokiStorageAPI.getDeviceLinks(associatedWith: hexEncodedPublicKey).done(on: DispatchQueue.main) { [weak self] deviceLinks in + guard let self = self else { return } + defer { self.setUserInteractionEnabled(true) } + guard deviceLinks.count < 2 else { + let alert = UIAlertController(title: "Multi Device Limit Reached", message: "It's currently not allowed to link more than one device.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", accessibilityIdentifier: nil, style: .default, handler: nil)) + return self.present(alert, animated: true, completion: nil) + } + let appDelegate = UIApplication.shared.delegate as! AppDelegate + appDelegate.startLongPollerIfNeeded() + let deviceLinkingModal = DeviceLinkingModal(mode: .slave, delegate: self) + deviceLinkingModal.modalPresentationStyle = .overFullScreen + deviceLinkingModal.modalTransitionStyle = .crossDissolve + self.present(deviceLinkingModal, animated: true, completion: nil) + let linkingRequestMessage = DeviceLinkingUtilities.getLinkingRequestMessage(for: hexEncodedPublicKey) + ThreadUtil.enqueue(linkingRequestMessage) + }.catch(on: DispatchQueue.main) { [weak self] _ in + let appDelegate = UIApplication.shared.delegate as! AppDelegate + appDelegate.stopLongPollerIfNeeded() + DispatchQueue.main.async { + // FIXME: For some reason resetForRegistration() complains about not being on the main queue + // without this (even though the catch closure should be executed on the main queue) + TSAccountManager.sharedInstance().resetForReregistration() + } + guard let self = self else { return } + let alert = UIAlertController(title: NSLocalizedString("Couldn't Link Device", comment: ""), message: NSLocalizedString("Please check your internet connection and try again", comment: ""), preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", accessibilityIdentifier: nil, style: .default, handler: nil)) + self.present(alert, animated: true, completion: nil) + self.setUserInteractionEnabled(true) + } + } + + func handleDeviceLinkAuthorized(_ deviceLink: DeviceLink) { + let userDefaults = UserDefaults.standard + userDefaults.set(deviceLink.master.hexEncodedPublicKey, forKey: "masterDeviceHexEncodedPublicKey") + fakeChatViewContentOffset = fakeChatView.contentOffset + DispatchQueue.main.async { + self.fakeChatView.contentOffset = self.fakeChatViewContentOffset + } + let homeVC = HomeVC() + navigationController!.setViewControllers([ homeVC ], animated: true) + } + + func handleDeviceLinkingModalDismissed() { + let appDelegate = UIApplication.shared.delegate as! AppDelegate + appDelegate.stopLongPollerIfNeeded() + TSAccountManager.sharedInstance().resetForReregistration() + } + + // MARK: Convenience + private func setUserInteractionEnabled(_ isEnabled: Bool) { + [ registerButton, restoreButton, linkButton ].forEach { + $0.isUserInteractionEnabled = isEnabled + } + } } diff --git a/Signal/src/Loki/Redesign/View Controllers/LinkDeviceVC.swift b/Signal/src/Loki/Redesign/View Controllers/LinkDeviceVC.swift new file mode 100644 index 000000000..ed09e2d90 --- /dev/null +++ b/Signal/src/Loki/Redesign/View Controllers/LinkDeviceVC.swift @@ -0,0 +1,287 @@ + +final class LinkDeviceVC : UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { + private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) + private var pages: [UIViewController] = [] + private var targetVCIndex: Int? + var delegate: LinkDeviceVCDelegate? + + // MARK: Settings + override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } + + // MARK: Components + private lazy var tabBar: TabBar = { + let tabs = [ + TabBar.Tab(title: NSLocalizedString("Enter Public Key", comment: "")) { [weak self] in + guard let self = self else { return } + self.pageVC.setViewControllers([ self.pages[0] ], direction: .forward, animated: false, completion: nil) + }, + TabBar.Tab(title: NSLocalizedString("Scan QR Code", comment: "")) { [weak self] in + guard let self = self else { return } + self.pageVC.setViewControllers([ self.pages[1] ], direction: .forward, animated: false, completion: nil) + } + ] + return TabBar(tabs: tabs) + }() + + private lazy var enterPublicKeyVC: EnterPublicKeyVC = { + let result = EnterPublicKeyVC() + result.linkDeviceVC = self + return result + }() + + private lazy var scanQRCodePlaceholderVC: ScanQRCodePlaceholderVC = { + let result = ScanQRCodePlaceholderVC() + result.linkDeviceVC = self + return result + }() + + private lazy var scanQRCodeWrapperVC: ScanQRCodeWrapperVC = { + let message = NSLocalizedString("Link to your existing device by going into your in-app settings and clicking \"Linked Devices\".", comment: "") + let result = ScanQRCodeWrapperVC(message: message) + result.delegate = self + return result + }() + + // MARK: Lifecycle + override func viewDidLoad() { + // Set gradient background + view.backgroundColor = .clear + let gradient = Gradients.defaultLokiBackground + view.setGradient(gradient) + // Set navigation bar background color + let navigationBar = navigationController!.navigationBar + navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) + navigationBar.shadowImage = UIImage() + navigationBar.isTranslucent = false + navigationBar.barTintColor = Colors.navigationBarBackground + // Set up navigation bar buttons + let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) + closeButton.tintColor = Colors.text + navigationItem.leftBarButtonItem = closeButton + // Customize title + let titleLabel = UILabel() + titleLabel.text = NSLocalizedString("Link Device", comment: "") + titleLabel.textColor = Colors.text + titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) + navigationItem.titleView = titleLabel + // Set up page VC + let hasCameraAccess = (AVCaptureDevice.authorizationStatus(for: .video) == .authorized) + pages = [ enterPublicKeyVC, (hasCameraAccess ? scanQRCodeWrapperVC : scanQRCodePlaceholderVC) ] + pageVC.dataSource = self + pageVC.delegate = self + pageVC.setViewControllers([ enterPublicKeyVC ], direction: .forward, animated: false, completion: nil) + // Set up tab bar + view.addSubview(tabBar) + tabBar.pin(.leading, to: .leading, of: view) + tabBar.pin(.top, to: .top, of: view, withInset: navigationBar.height()) + view.pin(.trailing, to: .trailing, of: tabBar) + // Set up page VC constraints + let pageVCView = pageVC.view! + view.addSubview(pageVCView) + pageVCView.pin(.leading, to: .leading, of: view) + pageVCView.pin(.top, to: .bottom, of: tabBar) + view.pin(.trailing, to: .trailing, of: pageVCView) + view.pin(.bottom, to: .bottom, of: pageVCView) + let screen = UIScreen.main.bounds + pageVCView.set(.width, to: screen.width) + let height = navigationController!.view.bounds.height - navigationBar.height() - Values.tabBarHeight + pageVCView.set(.height, to: height) + enterPublicKeyVC.constrainHeight(to: height) + scanQRCodePlaceholderVC.constrainHeight(to: height) + } + + // MARK: General + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController), index != 0 else { return nil } + return pages[index - 1] + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController), index != (pages.count - 1) else { return nil } + return pages[index + 1] + } + + fileprivate func handleCameraAccessGranted() { + pages[1] = scanQRCodeWrapperVC + pageVC.setViewControllers([ scanQRCodeWrapperVC ], direction: .forward, animated: false, completion: nil) + } + + // MARK: Updating + func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + guard let targetVC = pendingViewControllers.first, let index = pages.firstIndex(of: targetVC) else { return } + targetVCIndex = index + } + + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating isFinished: Bool, previousViewControllers: [UIViewController], transitionCompleted isCompleted: Bool) { + guard isCompleted, let index = targetVCIndex else { return } + tabBar.selectTab(at: index) + } + + // MARK: Interaction + @objc private func close() { + dismiss(animated: true, completion: nil) + } + + func controller(_ controller: OWSQRCodeScanningViewController, didDetectQRCodeWith string: String) { + let hexEncodedPublicKey = string + requestDeviceLink(with: hexEncodedPublicKey) + } + + fileprivate func requestDeviceLink(with hexEncodedPublicKey: String) { + delegate?.requestDeviceLink(with: hexEncodedPublicKey) + dismiss(animated: true, completion: nil) + } +} + +private final class EnterPublicKeyVC : UIViewController { + weak var linkDeviceVC: LinkDeviceVC! + private var bottomConstraint: NSLayoutConstraint! + private var linkButtonBottomConstraint: NSLayoutConstraint! + + // MARK: Components + private lazy var publicKeyTextField = TextField(placeholder: NSLocalizedString("Enter your public key", comment: "")) + + // MARK: Lifecycle + override func viewDidLoad() { + // Remove background color + view.backgroundColor = .clear + // Set up title label + let titleLabel = UILabel() + titleLabel.textColor = Colors.text + titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) + titleLabel.text = NSLocalizedString("Enter your public key", comment: "") + titleLabel.numberOfLines = 0 + titleLabel.lineBreakMode = .byWordWrapping + // Set up explanation label + let explanationLabel = UILabel() + explanationLabel.textColor = Colors.text + explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) + explanationLabel.text = "explanation explanation explanation explanation explanation explanation explanation explanation explanation explanation explanation explanation explanation explanation explanation explanation" + explanationLabel.numberOfLines = 0 + explanationLabel.lineBreakMode = .byWordWrapping + // Link button + let linkButton = Button(style: .prominentOutline, size: .large) + linkButton.setTitle(NSLocalizedString("Continue", comment: ""), for: UIControl.State.normal) + linkButton.addTarget(self, action: #selector(requestDeviceLink), for: UIControl.Event.touchUpInside) + let linkButtonContainer = UIView() + linkButtonContainer.addSubview(linkButton) + linkButton.pin(.leading, to: .leading, of: linkButtonContainer, withInset: 80) + linkButton.pin(.top, to: .top, of: linkButtonContainer) + linkButtonContainer.pin(.trailing, to: .trailing, of: linkButton, withInset: 80) + linkButtonBottomConstraint = linkButtonContainer.pin(.bottom, to: .bottom, of: linkButton, withInset: Values.veryLargeSpacing) + // Set up top stack view + let topStackView = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, publicKeyTextField ]) + topStackView.axis = .vertical + topStackView.spacing = Values.largeSpacing + // Set up spacers + let topSpacer = UIView.vStretchingSpacer() + let bottomSpacer = UIView.vStretchingSpacer() + // Set up stack view + let stackView = UIStackView(arrangedSubviews: [ topSpacer, topStackView, bottomSpacer, linkButtonContainer ]) + stackView.axis = .vertical + stackView.alignment = .fill + stackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.veryLargeSpacing, bottom: 0, right: Values.veryLargeSpacing) + stackView.isLayoutMarginsRelativeArrangement = true + view.addSubview(stackView) + stackView.pin(.leading, to: .leading, of: view) + stackView.pin(.top, to: .top, of: view) + stackView.pin(.trailing, to: .trailing, of: view) + bottomConstraint = stackView.pin(.bottom, to: .bottom, of: view) + topSpacer.heightAnchor.constraint(equalTo: bottomSpacer.heightAnchor, multiplier: 1).isActive = true + // Set up width constraint + view.set(.width, to: UIScreen.main.bounds.width) + // Dismiss keyboard on tap + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + view.addGestureRecognizer(tapGestureRecognizer) + // Listen to keyboard notifications + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) + notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: General + func constrainHeight(to height: CGFloat) { + view.set(.height, to: height) + } + + @objc private func dismissKeyboard() { + publicKeyTextField.resignFirstResponder() + } + + // MARK: Updating + @objc private func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { + guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } + bottomConstraint.constant = -newHeight + linkButtonBottomConstraint.constant = Values.mediumSpacing + UIView.animate(withDuration: 0.25) { + self.view.layoutIfNeeded() + } + } + + @objc private func handleKeyboardWillHideNotification(_ notification: Notification) { + bottomConstraint.constant = 0 + linkButtonBottomConstraint.constant = Values.veryLargeSpacing + UIView.animate(withDuration: 0.25) { + self.view.layoutIfNeeded() + } + } + + // MARK: Interaction + @objc private func requestDeviceLink() { + let hexEncodedPublicKey = publicKeyTextField.text?.trimmingCharacters(in: .whitespaces) ?? "" + linkDeviceVC.requestDeviceLink(with: hexEncodedPublicKey) + } +} + +private final class ScanQRCodePlaceholderVC : UIViewController { + weak var linkDeviceVC: LinkDeviceVC! + + override func viewDidLoad() { + // Remove background color + view.backgroundColor = .clear + // Set up explanation label + let explanationLabel = UILabel() + explanationLabel.textColor = Colors.text + explanationLabel.font = .systemFont(ofSize: Values.smallFontSize) + explanationLabel.text = NSLocalizedString("Loki Messenger needs camera access to scan QR codes", comment: "") + explanationLabel.numberOfLines = 0 + explanationLabel.textAlignment = .center + explanationLabel.lineBreakMode = .byWordWrapping + // Set up call to action button + let callToActionButton = UIButton() + callToActionButton.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) + callToActionButton.setTitleColor(Colors.accent, for: UIControl.State.normal) + callToActionButton.setTitle(NSLocalizedString("Enable Camera Access", comment: ""), for: UIControl.State.normal) + callToActionButton.addTarget(self, action: #selector(requestCameraAccess), for: UIControl.Event.touchUpInside) + // Set up stack view + let stackView = UIStackView(arrangedSubviews: [ explanationLabel, callToActionButton ]) + stackView.axis = .vertical + stackView.spacing = Values.mediumSpacing + stackView.alignment = .center + // Set up constraints + view.set(.width, to: UIScreen.main.bounds.width) + view.addSubview(stackView) + stackView.pin(.leading, to: .leading, of: view, withInset: Values.massiveSpacing) + view.pin(.trailing, to: .trailing, of: stackView, withInset: Values.massiveSpacing) + let verticalCenteringConstraint = stackView.center(.vertical, in: view) + verticalCenteringConstraint.constant = -16 // Makes things appear centered visually + } + + func constrainHeight(to height: CGFloat) { + view.set(.height, to: height) + } + + @objc private func requestCameraAccess() { + ows_ask(forCameraPermissions: { [weak self] hasCameraAccess in + if hasCameraAccess { + self?.linkDeviceVC.handleCameraAccessGranted() + } else { + // Do nothing + } + }) + } +} diff --git a/Signal/src/Loki/Redesign/View Controllers/LinkDeviceVCDelegate.swift b/Signal/src/Loki/Redesign/View Controllers/LinkDeviceVCDelegate.swift new file mode 100644 index 000000000..db4536eb5 --- /dev/null +++ b/Signal/src/Loki/Redesign/View Controllers/LinkDeviceVCDelegate.swift @@ -0,0 +1,5 @@ + +protocol LinkDeviceVCDelegate { + + func requestDeviceLink(with hexEncodedPublicKey: String) +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 6b83090df..a250c864c 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -2753,3 +2753,7 @@ "Press the covered words to view your seed and secure your account" = "Press the covered words to view your seed and secure your account"; "Hold to reveal" = "Hold to reveal"; "Make sure to store your seed in a safe place" = "Make sure to store your seed in a safe place"; +"Link to an existing account" = "Link to an existing account"; +"Enter your public key" = "Enter your public key"; +"Link to your existing device by going into your in-app settings and clicking \"Linked Devices\"." = "Link to your existing device by going into your in-app settings and clicking \"Linked Devices\"."; +"Create a new account on your other device and click \"Link to an existing account\" to start the linking process" = "Create a new account on your other device and click \"Link to an existing account\" to start the linking process";