session-ios/Signal/src/Loki/View Controllers/PathVC.swift

324 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import NVActivityIndicatorView
import SwiftCSV
final class PathVC : BaseVC {
static let ipv4Table = try! CSV(name: "GeoLite2-Country-Blocks-IPv4", extension: "csv", bundle: .main, delimiter: ",", encoding: .utf8, loadColumns: true)!
static let countryNamesTable = try! CSV(name: "GeoLite2-Country-Locations-English", extension: "csv", bundle: .main, delimiter: ",", encoding: .utf8, loadColumns: true)!
static var countryNamesCache: [String:String] = [:]
// 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()
setUpGradientBackground()
setUpNavBar()
setUpViewHierarchy()
registerObservers()
}
private func setUpNavBar() {
setUpNavBarStyle()
setNavBarTitle(NSLocalizedString("Path", comment: ""))
// Set up close button
let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close))
closeButton.tintColor = Colors.text
navigationItem.leftBarButtonItem = closeButton
let learnMoreButton = UIBarButtonItem(image: #imageLiteral(resourceName: "QuestionMark").scaled(to: CGSize(width: 24, height: 24)), style: .plain, target: self, action: #selector(learnMore))
learnMoreButton.tintColor = Colors.text
navigationItem.rightBarButtonItem = learnMoreButton
}
private func setUpViewHierarchy() {
// Set up explanation label
let explanationLabel = UILabel()
explanationLabel.textColor = Colors.text.withAlphaComponent(Values.unimportantElementOpacity)
explanationLabel.font = .systemFont(ofSize: Values.smallFontSize)
explanationLabel.text = NSLocalizedString("Session hides your IP by bouncing your messages through several Service Nodes in Sessions decentralized network. These are the Service Nodes currently being used by your device:", comment: "")
explanationLabel.numberOfLines = 0
explanationLabel.textAlignment = .center
explanationLabel.lineBreakMode = .byWordWrapping
// Set up path stack view
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 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, 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: [UIStackView] = pathToDisplay.enumerated().map { index, snode in
let isGuardSnode = (snode == pathToDisplay.first!)
return getPathRow(snode: snode, location: .middle, dotAnimationStartDelay: Double(index) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval, isGuardSnode: isGuardSnode)
}
let youRow = getPathRow(title: NSLocalizedString("You", comment: ""), subtitle: nil, location: .top, dotAnimationStartDelay: 1, dotAnimationRepeatInterval: dotAnimationRepeatInterval)
let destinationRow = getPathRow(title: NSLocalizedString("Destination", comment: ""), subtitle: nil, location: .bottom, dotAnimationStartDelay: Double(pathToDisplay.count) + 2, dotAnimationRepeatInterval: dotAnimationRepeatInterval)
let rows = [ youRow ] + snodeRows + [ destinationRow ]
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)
lineView.set(.height, to: Values.pathRowHeight)
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
titleLabel.text = title
titleLabel.lineBreakMode = .byTruncatingTail
let titleStackView = UIStackView(arrangedSubviews: [ titleLabel ])
titleStackView.axis = .vertical
if let subtitle = subtitle {
let subtitleLabel = UILabel()
subtitleLabel.textColor = Colors.text
subtitleLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
subtitleLabel.text = subtitle
subtitleLabel.lineBreakMode = .byTruncatingTail
titleStackView.addArrangedSubview(subtitleLabel)
}
let stackView = UIStackView(arrangedSubviews: [ lineView, titleStackView ])
stackView.axis = .horizontal
stackView.spacing = Values.largeSpacing
stackView.alignment = .center
return stackView
}
private func getPathRow(snode: LokiAPITarget, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView {
var snodeIP = snode.ip
func getCountry() -> String {
if let country = PathVC.countryNamesCache[snode.address] { return country }
if let ipv4TableIndex = PathVC.ipv4Table.namedColumns["network"]!.firstIndex(where: { $0.starts(with: snodeIP) }) {
let countryID = PathVC.ipv4Table.namedColumns["registered_country_geoname_id"]![ipv4TableIndex]
if let countryNamesTableIndex = PathVC.countryNamesTable.namedColumns["geoname_id"]!.firstIndex(of: countryID) {
let country = PathVC.countryNamesTable.namedColumns["country_name"]![countryNamesTableIndex]
PathVC.countryNamesCache[snode.address] = country
return country
}
}
if snodeIP.contains(".") && !snodeIP.hasSuffix(".") { // The fuzziest we want to go is xxx.x
snodeIP.removeLast()
if snodeIP.hasSuffix(".") { snodeIP.removeLast() }
return getCountry()
} else {
return "Unknown Country"
}
}
let title = isGuardSnode ? NSLocalizedString("Guard Node", comment: "") : NSLocalizedString("Service Node", comment: "")
return getPathRow(title: title, subtitle: getCountry(), location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval)
}
// MARK: Interaction
@objc private func close() {
dismiss(animated: true, completion: nil)
}
@objc private func learnMore() {
let urlAsString = "https://getsession.org/faq/#onion-routing"
let url = URL(string: urlAsString)!
UIApplication.shared.open(url)
}
@objc private func rebuildPath() {
// Dispatch async on the main queue to avoid nested write transactions
DispatchQueue.main.async {
let storage = OWSPrimaryStorage.shared()
storage.dbReadWriteConnection.readWrite { transaction in
storage.clearOnionRequestPaths(in: transaction)
}
}
OnionRequestAPI.guardSnodes = []
OnionRequestAPI.paths = []
let _ = OnionRequestAPI.buildPaths()
}
}
// MARK: Line View
private final class LineView : UIView {
private let location: Location
private let dotAnimationStartDelay: Double
private let dotAnimationRepeatInterval: Double
private var dotViewWidthConstraint: NSLayoutConstraint!
private var dotViewHeightConstraint: NSLayoutConstraint!
private var dotViewAnimationTimer: Timer!
enum Location {
case top, middle, bottom
}
private lazy var dotView: UIView = {
let result = UIView()
result.layer.cornerRadius = Values.pathRowDotSize / 2
let glowConfiguration = UIView.CircularGlowConfiguration(size: Values.pathRowDotSize, color: Colors.accent, isAnimated: true, radius: isLightMode ? 2 : 4)
result.setCircularGlow(with: glowConfiguration)
result.backgroundColor = Colors.accent
return result
}()
init(location: Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double) {
self.location = location
self.dotAnimationStartDelay = dotAnimationStartDelay
self.dotAnimationRepeatInterval = dotAnimationRepeatInterval
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(location:dotAnimationStartDelay:dotAnimationRepeatInterval:) instead.")
}
private func setUpViewHierarchy() {
let lineView = UIView()
lineView.set(.width, to: Values.pathRowLineThickness)
lineView.backgroundColor = Colors.text
addSubview(lineView)
lineView.center(.horizontal, in: self)
switch location {
case .top: lineView.topAnchor.constraint(equalTo: centerYAnchor).isActive = true
case .middle, .bottom: lineView.pin(.top, to: .top, of: self)
}
switch location {
case .top, .middle: lineView.pin(.bottom, to: .bottom, of: self)
case .bottom: lineView.bottomAnchor.constraint(equalTo: centerYAnchor).isActive = true
}
let dotSize = Values.pathRowDotSize
dotViewWidthConstraint = dotView.set(.width, to: dotSize)
dotViewHeightConstraint = dotView.set(.height, to: dotSize)
addSubview(dotView)
dotView.center(in: self)
Timer.scheduledTimer(withTimeInterval: dotAnimationStartDelay, repeats: false) { [weak self] _ in
guard let strongSelf = self else { return }
strongSelf.animate()
strongSelf.dotViewAnimationTimer = Timer.scheduledTimer(withTimeInterval: strongSelf.dotAnimationRepeatInterval, repeats: true) { _ in
guard let strongSelf = self else { return }
strongSelf.animate()
}
}
}
deinit {
dotViewAnimationTimer?.invalidate()
}
private func animate() {
expandDot()
Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in
self?.collapseDot()
}
}
private func expandDot() {
let newSize = Values.pathRowExpandedDotSize
let newGlowRadius: CGFloat = isLightMode ? 6 : 8
updateDotView(size: newSize, glowRadius: newGlowRadius)
}
private func collapseDot() {
let newSize = Values.pathRowDotSize
let newGlowRadius: CGFloat = isLightMode ? 2 : 4
updateDotView(size: newSize, glowRadius: newGlowRadius)
}
private func updateDotView(size: CGFloat, glowRadius: CGFloat) {
let frame = CGRect(center: dotView.center, size: CGSize(width: size, height: size))
dotViewWidthConstraint.constant = size
dotViewHeightConstraint.constant = size
UIView.animate(withDuration: 0.5) {
self.layoutIfNeeded()
self.dotView.frame = frame
self.dotView.layer.cornerRadius = size / 2
let glowColor = Colors.accent
let glowConfiguration = UIView.CircularGlowConfiguration(size: size, color: glowColor, isAnimated: true, radius: glowRadius)
self.dotView.setCircularGlow(with: glowConfiguration)
self.dotView.backgroundColor = Colors.accent
}
}
}