diff --git a/Signal/src/Loki/Components/PathStatusView.swift b/Signal/src/Loki/Components/PathStatusView.swift index 4282c70e0..79603ad13 100644 --- a/Signal/src/Loki/Components/PathStatusView.swift +++ b/Signal/src/Loki/Components/PathStatusView.swift @@ -4,19 +4,44 @@ final class PathStatusView : UIView { override init(frame: CGRect) { super.init(frame: frame) setUpViewHierarchy() + registerObservers() } required init?(coder: NSCoder) { super.init(coder: coder) setUpViewHierarchy() + registerObservers() } private func setUpViewHierarchy() { - backgroundColor = Colors.accent + layer.cornerRadius = Values.pathStatusViewSize / 2 + layer.masksToBounds = false + let color = (OnionRequestAPI.paths.count >= OnionRequestAPI.pathCount) ? Colors.accent : Colors.destructive + setColor(to: color, isAnimated: false) + } + + private func registerObservers() { + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver(self, selector: #selector(handleBuildingPathsNotification), name: .buildingPaths, object: nil) + notificationCenter.addObserver(self, selector: #selector(handlePathsBuiltNotification), name: .pathsBuilt, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func setColor(to color: UIColor, isAnimated: Bool) { + backgroundColor = color let size = Values.pathStatusViewSize - layer.cornerRadius = size / 2 - let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: Colors.accent, isAnimated: false, radius: isLightMode ? 6 : 8) + let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: color, isAnimated: isAnimated, radius: isLightMode ? 6 : 8) setCircularGlow(with: glowConfiguration) - layer.masksToBounds = false + } + + @objc private func handleBuildingPathsNotification() { + setColor(to: Colors.destructive, isAnimated: true) + } + + @objc private func handlePathsBuiltNotification() { + setColor(to: Colors.accent, isAnimated: true) } } diff --git a/Signal/src/Loki/View Controllers/PathVC.swift b/Signal/src/Loki/View Controllers/PathVC.swift index 708a655b4..85e72e92f 100644 --- a/Signal/src/Loki/View Controllers/PathVC.swift +++ b/Signal/src/Loki/View Controllers/PathVC.swift @@ -1,14 +1,45 @@ +import NVActivityIndicatorView final class PathVC : BaseVC { - + + // MARK: Components + private lazy var pathStackView: UIStackView = { + let result = UIStackView() + result.axis = .vertical + return result + }() + + private lazy var spinner: NVActivityIndicatorView = { + let result = NVActivityIndicatorView(frame: CGRect.zero, type: .circleStrokeSpin, color: Colors.text, padding: nil) + result.set(.width, to: 64) + result.set(.height, to: 64) + return result + }() + + private lazy var rebuildPathButton: Button = { + let result = Button(style: .prominentOutline, size: .large) + result.setTitle(NSLocalizedString("Rebuild Path", comment: ""), for: UIControl.State.normal) + result.addTarget(self, action: #selector(rebuildPath), for: UIControl.Event.touchUpInside) + return result + }() + // MARK: Lifecycle override func viewDidLoad() { super.viewDidLoad() - // Set gradient background + setUpBackground() + setUpNavBar() + setUpViewHierarchy() + registerObservers() + } + + private func setUpBackground() { view.backgroundColor = .clear let gradient = Gradients.defaultLokiBackground view.setGradient(gradient) - // Set up navigation bar + } + + private func setUpNavBar() { + // Set up navigation bar style let navigationBar = navigationController!.navigationBar navigationBar.setBackgroundImage(UIImage(), for: UIBarMetrics.default) navigationBar.shadowImage = UIImage() @@ -24,6 +55,9 @@ final class PathVC : BaseVC { titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) navigationItem.titleView = titleLabel + } + + private func setUpViewHierarchy() { // Set up explanation label let explanationLabel = UILabel() explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity) @@ -32,49 +66,88 @@ final class PathVC : BaseVC { explanationLabel.numberOfLines = 0 explanationLabel.textAlignment = .center explanationLabel.lineBreakMode = .byWordWrapping - view.addSubview(explanationLabel) - explanationLabel.pin(.leading, to: .leading, of: view, withInset: Values.largeSpacing) - explanationLabel.pin(.top, to: .top, of: view, withInset: Values.mediumSpacing) - explanationLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.largeSpacing) // Set up path stack view - guard let mainPath = OnionRequestAPI.paths.first else { - return close() // TODO: Show path establishing UI - } - let dotAnimationRepeatInterval = (Double(mainPath.count) + 2) * 0.5 - let snodeRows = mainPath.enumerated().reversed().map { index, snode in - getPathRow(snode: snode, location: .middle, dotAnimationStartDelay: (Double(index) + 1) * 0.5, dotAnimationRepeatInterval: dotAnimationRepeatInterval) - } - let destinationRow = getPathRow(title: NSLocalizedString("Destination", comment: ""), subtitle: nil, location: .top, dotAnimationStartDelay: (Double(mainPath.count) + 1) * 0.5, dotAnimationRepeatInterval: dotAnimationRepeatInterval) - let youRow = getPathRow(title: NSLocalizedString("You", comment: ""), subtitle: nil, location: .bottom, dotAnimationStartDelay: 0, dotAnimationRepeatInterval: dotAnimationRepeatInterval) - let rows = [ destinationRow ] + snodeRows + [ youRow ] - let pathStackView = UIStackView(arrangedSubviews: rows) - pathStackView.axis = .vertical let pathStackViewContainer = UIView() pathStackViewContainer.addSubview(pathStackView) pathStackView.pin([ UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: pathStackViewContainer) pathStackView.center(in: pathStackViewContainer) pathStackView.leadingAnchor.constraint(greaterThanOrEqualTo: pathStackViewContainer.leadingAnchor).isActive = true pathStackViewContainer.trailingAnchor.constraint(greaterThanOrEqualTo: pathStackView.trailingAnchor).isActive = true + pathStackViewContainer.addSubview(spinner) + spinner.leadingAnchor.constraint(greaterThanOrEqualTo: pathStackViewContainer.leadingAnchor).isActive = true + spinner.topAnchor.constraint(greaterThanOrEqualTo: pathStackViewContainer.topAnchor).isActive = true + pathStackViewContainer.trailingAnchor.constraint(greaterThanOrEqualTo: spinner.trailingAnchor).isActive = true + pathStackViewContainer.bottomAnchor.constraint(greaterThanOrEqualTo: spinner.bottomAnchor).isActive = true + spinner.center(in: pathStackViewContainer) // Set up rebuild path button - let rebuildPathButton = Button(style: .prominentOutline, size: .large) - rebuildPathButton.setTitle(NSLocalizedString("Rebuild Path", comment: ""), for: UIControl.State.normal) - rebuildPathButton.addTarget(self, action: #selector(rebuildPath), for: UIControl.Event.touchUpInside) let rebuildPathButtonContainer = UIView() rebuildPathButtonContainer.addSubview(rebuildPathButton) rebuildPathButton.pin(.leading, to: .leading, of: rebuildPathButtonContainer, withInset: 80) rebuildPathButton.pin(.top, to: .top, of: rebuildPathButtonContainer) rebuildPathButtonContainer.pin(.trailing, to: .trailing, of: rebuildPathButton, withInset: 80) rebuildPathButtonContainer.pin(.bottom, to: .bottom, of: rebuildPathButton) + // Set up spacers + let topSpacer = UIView.vStretchingSpacer() + let bottomSpacer = UIView.vStretchingSpacer() // Set up main stack view - let mainStackView = UIStackView(arrangedSubviews: [ explanationLabel, UIView.spacer(withHeight: Values.mediumSpacing), pathStackViewContainer, UIView.vStretchingSpacer(), rebuildPathButtonContainer ]) + let mainStackView = UIStackView(arrangedSubviews: [ explanationLabel, topSpacer, pathStackViewContainer, bottomSpacer, rebuildPathButtonContainer ]) mainStackView.axis = .vertical mainStackView.alignment = .fill mainStackView.layoutMargins = UIEdgeInsets(top: Values.largeSpacing, left: Values.largeSpacing, bottom: Values.largeSpacing, right: Values.largeSpacing) mainStackView.isLayoutMarginsRelativeArrangement = true view.addSubview(mainStackView) mainStackView.pin(to: view) + // Set up spacer constraints + topSpacer.heightAnchor.constraint(equalTo: bottomSpacer.heightAnchor).isActive = true + // Perform initial update + update() + } + + private func registerObservers() { + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver(self, selector: #selector(handleBuildingPathsNotification), name: .buildingPaths, object: nil) + notificationCenter.addObserver(self, selector: #selector(handlePathsBuiltNotification), name: .pathsBuilt, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: Updating + @objc private func handleBuildingPathsNotification() { update() } + @objc private func handlePathsBuiltNotification() { update() } + + private func update() { + pathStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + if OnionRequestAPI.paths.count >= OnionRequestAPI.pathCount { + let pathToDisplay = OnionRequestAPI.paths.first! + let dotAnimationRepeatInterval = Double(pathToDisplay.count) + 2 + let snodeRows = pathToDisplay.enumerated().reversed().map { index, snode in + getPathRow(snode: snode, location: .middle, dotAnimationStartDelay: Double(index) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval) + } + let destinationRow = getPathRow(title: NSLocalizedString("Destination", comment: ""), subtitle: nil, location: .top, dotAnimationStartDelay: Double(pathToDisplay.count) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval) + let youRow = getPathRow(title: NSLocalizedString("You", comment: ""), subtitle: nil, location: .bottom, dotAnimationStartDelay: 1, dotAnimationRepeatInterval: dotAnimationRepeatInterval) + let rows = [ destinationRow ] + snodeRows + [ youRow ] + rows.forEach { pathStackView.addArrangedSubview($0) } + spinner.stopAnimating() + UIView.animate(withDuration: 0.25) { + self.spinner.alpha = 0 + self.rebuildPathButton.layer.borderColor = Colors.accent.cgColor + self.rebuildPathButton.setTitleColor(Colors.accent, for: UIControl.State.normal) + } + rebuildPathButton.isEnabled = true + } else { + spinner.startAnimating() + UIView.animate(withDuration: 0.25) { + self.spinner.alpha = 1 + self.rebuildPathButton.layer.borderColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity).cgColor + self.rebuildPathButton.setTitleColor(Colors.text.withAlphaComponent(Values.unimportantElementOpacity), for: UIControl.State.normal) + } + rebuildPathButton.isEnabled = false + } } + // MARK: General private func getPathRow(title: String, subtitle: String?, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double) -> UIStackView { let lineView = LineView(location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval) lineView.set(.width, to: Values.pathRowDotSize) @@ -116,7 +189,9 @@ final class PathVC : BaseVC { } @objc private func rebuildPath() { - // TODO: Implement + OnionRequestAPI.guardSnodes = [] + OnionRequestAPI.paths = [] + let _ = OnionRequestAPI.buildPaths() } } @@ -193,7 +268,7 @@ private final class LineView : UIView { private func animate() { expandDot() - Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in self?.collapseDot() } } @@ -214,7 +289,7 @@ private final class LineView : UIView { let frame = CGRect(center: dotView.center, size: CGSize(width: size, height: size)) dotViewWidthConstraint.constant = size dotViewHeightConstraint.constant = size - UIView.animate(withDuration: 0.25) { + UIView.animate(withDuration: 0.5) { self.layoutIfNeeded() self.dotView.frame = frame self.dotView.layer.cornerRadius = size / 2 diff --git a/SignalServiceKit/src/Loki/API/Onion Requests/OnionRequestAPI.swift b/SignalServiceKit/src/Loki/API/Onion Requests/OnionRequestAPI.swift index b403f0081..9fdbea52c 100644 --- a/SignalServiceKit/src/Loki/API/Onion Requests/OnionRequestAPI.swift +++ b/SignalServiceKit/src/Loki/API/Onion Requests/OnionRequestAPI.swift @@ -4,7 +4,7 @@ import PromiseKit /// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. public enum OnionRequestAPI { /// - Note: Must only be modified from `LokiAPI.workQueue`. - private static var guardSnodes: Set = [] + public static var guardSnodes: Set = [] /// - Note: Must only be modified from `LokiAPI.workQueue`. public static var paths: [Path] = [] @@ -14,9 +14,9 @@ public enum OnionRequestAPI { } // MARK: Settings - private static let pathCount: UInt = 2 /// The number of snodes (including the guard snode) in a path. private static let pathSize: UInt = 3 + public static let pathCount: UInt = 2 private static var guardSnodeCount: UInt { return pathCount } // One per path @@ -100,10 +100,13 @@ public enum OnionRequestAPI { /// Builds and returns `pathCount` paths. The returned promise errors out with `Error.insufficientSnodes` /// if not enough (reliable) snodes are available. - private static func buildPaths() -> Promise<[Path]> { + public static func buildPaths() -> Promise<[Path]> { print("[Loki] [Onion Request API] Building onion request paths.") + DispatchQueue.main.async { + NotificationCenter.default.post(name: .buildingPaths, object: nil) + } return LokiAPI.getRandomSnode().then(on: LokiAPI.workQueue) { _ -> Promise<[Path]> in // Just used to populate the snode pool - return getGuardSnodes().map(on: LokiAPI.workQueue) { guardSnodes in + return getGuardSnodes().map(on: LokiAPI.workQueue) { guardSnodes -> [Path] in var unusedSnodes = snodePool.subtracting(guardSnodes) let pathSnodeCount = guardSnodeCount * pathSize - guardSnodeCount guard unusedSnodes.count >= pathSnodeCount else { throw Error.insufficientSnodes } @@ -118,6 +121,12 @@ public enum OnionRequestAPI { print("[Loki] [Onion Request API] Built new onion request path: \(result.prettifiedDescription).") return result } + }.map(on: LokiAPI.workQueue) { paths in + OnionRequestAPI.paths = paths + DispatchQueue.main.async { + NotificationCenter.default.post(name: .pathsBuilt, object: nil) + } + return paths } } } @@ -134,9 +143,7 @@ public enum OnionRequestAPI { } } else { return buildPaths().map(on: LokiAPI.workQueue) { paths in - let path = paths.filter { !$0.contains(snode) }.randomElement()! - OnionRequestAPI.paths = paths - return path + return paths.filter { !$0.contains(snode) }.randomElement()! } } } diff --git a/SignalServiceKit/src/Loki/Utilities/Notification+Loki.swift b/SignalServiceKit/src/Loki/Utilities/Notification+Loki.swift index eb7c5bd75..27de5db22 100644 --- a/SignalServiceKit/src/Loki/Utilities/Notification+Loki.swift +++ b/SignalServiceKit/src/Loki/Utilities/Notification+Loki.swift @@ -20,6 +20,9 @@ public extension Notification.Name { public static let dataNukeRequested = Notification.Name("dataNukeRequested") // Device linking public static let unexpectedDeviceLinkRequestReceived = Notification.Name("unexpectedDeviceLinkRequestReceived") + // Onion requests + public static let buildingPaths = Notification.Name("buildingPaths") + public static let pathsBuilt = Notification.Name("pathsBuilt") } @objc public extension NSNotification { @@ -43,4 +46,7 @@ public extension Notification.Name { @objc public static let dataNukeRequested = Notification.Name.dataNukeRequested.rawValue as NSString // Device linking @objc public static let unexpectedDeviceLinkRequestReceived = Notification.Name.unexpectedDeviceLinkRequestReceived.rawValue as NSString + // Onion requests + @objc public static let buildingPaths = Notification.Name.buildingPaths.rawValue as NSString + @objc public static let pathsBuilt = Notification.Name.pathsBuilt.rawValue as NSString }