BackupTransferViewController.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import Foundation
  2. import UIKit
  3. import DcCore
  4. import SDWebImageSVGKitPlugin
  5. class BackupTransferViewController: UIViewController {
  6. public enum TranferState {
  7. case unknown
  8. case error
  9. case success
  10. }
  11. private let dcContext: DcContext
  12. private let dcAccounts: DcAccounts
  13. private var dcBackupProvider: DcBackupProvider?
  14. private var imexObserver: NSObjectProtocol?
  15. private var transferState: TranferState = TranferState.unknown
  16. private var cancelButton: UIBarButtonItem {
  17. return UIBarButtonItem(title: String.localized("cancel"), style: .plain, target: self, action: #selector(cancelButtonPressed))
  18. }
  19. private lazy var statusLine: UILabel = {
  20. let label = UILabel(frame: CGRect(x: 10, y: 100, width: 400, height: 200))
  21. label.text = String.localized("preparing_account")
  22. label.textColor = DcColors.defaultTextColor
  23. label.textAlignment = .left
  24. label.numberOfLines = 0
  25. label.lineBreakMode = .byWordWrapping
  26. label.font = .preferredFont(forTextStyle: .headline)
  27. return label
  28. }()
  29. private lazy var qrContentView: UIImageView = {
  30. let view = UIImageView()
  31. view.contentMode = .scaleAspectFit
  32. view.translatesAutoresizingMaskIntoConstraints = false
  33. view.accessibilityHint = String.localized("qr_code")
  34. return view
  35. }()
  36. private lazy var progressContainer: UIView = {
  37. let view = UIView()
  38. view.translatesAutoresizingMaskIntoConstraints = false
  39. view.layer.cornerRadius = 20
  40. view.clipsToBounds = true
  41. view.layer.borderColor = DcColors.grey50.cgColor
  42. view.layer.borderWidth = 1
  43. view.backgroundColor = DcColors.defaultInverseColor.withAlphaComponent(0.5)
  44. return view
  45. }()
  46. private lazy var progress: UIActivityIndicatorView = {
  47. let progress = UIActivityIndicatorView(style: .white)
  48. progress.translatesAutoresizingMaskIntoConstraints = false
  49. return progress
  50. }()
  51. init(dcAccounts: DcAccounts) {
  52. self.dcAccounts = dcAccounts
  53. self.dcContext = dcAccounts.getSelected()
  54. super.init(nibName: nil, bundle: nil)
  55. hidesBottomBarWhenPushed = true
  56. setupSubviews()
  57. title = String.localized("multidevice_title")
  58. navigationItem.leftBarButtonItem = cancelButton
  59. }
  60. required init?(coder _: NSCoder) {
  61. fatalError("init(coder:) has not been implemented")
  62. }
  63. // MARK: - lifecycle
  64. override func viewDidLoad() {
  65. super.viewDidLoad()
  66. triggerLocalNetworkPrivacyAlert()
  67. DispatchQueue.global(qos: .userInitiated).async { [weak self] in
  68. guard let self = self else { return }
  69. self.dcAccounts.stopIo()
  70. self.dcBackupProvider = DcBackupProvider(self.dcContext)
  71. DispatchQueue.main.async {
  72. if !(self.dcBackupProvider?.isOk() ?? false) {
  73. self.transferState = TranferState.error
  74. self.showLastErrorAlert("Cannot create backup provider")
  75. return
  76. }
  77. let image = self.getQrImage(svg: self.dcBackupProvider?.getQrSvg())
  78. self.qrContentView.image = image
  79. self.progress.stopAnimating()
  80. self.progressContainer.isHidden = true
  81. self.statusLine.text = self.getInstructions()
  82. DispatchQueue.global(qos: .userInitiated).async { [weak self] in
  83. guard let self = self else { return }
  84. self.dcBackupProvider?.wait()
  85. }
  86. }
  87. }
  88. }
  89. override func didMove(toParent parent: UIViewController?) {
  90. let isRemoved = parent == nil
  91. if isRemoved {
  92. if let imexObserver = self.imexObserver {
  93. NotificationCenter.default.removeObserver(imexObserver)
  94. }
  95. if dcBackupProvider != nil {
  96. dcContext.stopOngoingProcess()
  97. dcBackupProvider?.unref()
  98. dcBackupProvider = nil
  99. }
  100. dcAccounts.startIo()
  101. UIApplication.shared.isIdleTimerDisabled = false
  102. } else {
  103. UIApplication.shared.isIdleTimerDisabled = true
  104. imexObserver = NotificationCenter.default.addObserver(forName: dcNotificationImexProgress, object: nil, queue: nil) { [weak self] notification in
  105. guard let self = self, let ui = notification.userInfo, let permille = ui["progress"] as? Int else { return }
  106. var statusLineText: String?
  107. var hideQrCode = false
  108. if permille == 0 {
  109. self.transferState = TranferState.error
  110. self.showLastErrorAlert("Error")
  111. hideQrCode = true
  112. } else if permille <= 350 {
  113. statusLineText = String.localized("preparing_account")
  114. } else if permille <= 400 {
  115. statusLineText = self.getInstructions()
  116. } else if permille <= 450 {
  117. statusLineText = String.localized("receiver_connected")
  118. hideQrCode = true
  119. } else if permille < 1000 {
  120. let percent = (permille-450)/5
  121. statusLineText = String.localized("transferring") + " \(percent)%" // TODO: use a scrollbar, show precide percentage only for receiver
  122. hideQrCode = true
  123. } else if permille == 1000 {
  124. self.transferState = TranferState.success
  125. self.navigationItem.leftBarButtonItem = nil // "Cancel" no longer fits as things are done
  126. statusLineText = String.localized("done")
  127. hideQrCode = true
  128. }
  129. if let statusLineText = statusLineText {
  130. self.statusLine.text = statusLineText
  131. }
  132. if hideQrCode && !self.qrContentView.isHidden {
  133. self.qrContentView.isHidden = true
  134. }
  135. }
  136. }
  137. }
  138. // MARK: - setup
  139. private func setupSubviews() {
  140. view.addSubview(statusLine)
  141. view.addSubview(qrContentView)
  142. view.addSubview(progressContainer)
  143. progressContainer.addSubview(progress)
  144. let qrDefaultWidth = qrContentView.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor, multiplier: 0.75)
  145. qrDefaultWidth.priority = UILayoutPriority(500)
  146. qrDefaultWidth.isActive = true
  147. let qrMinWidth = qrContentView.widthAnchor.constraint(lessThanOrEqualToConstant: 260)
  148. qrMinWidth.priority = UILayoutPriority(999)
  149. qrMinWidth.isActive = true
  150. view.addConstraints([
  151. qrContentView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 1.05),
  152. qrContentView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
  153. qrContentView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
  154. progressContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
  155. progressContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
  156. progressContainer.constraintHeightTo(100),
  157. progressContainer.constraintWidthTo(100),
  158. progress.constraintCenterXTo(progressContainer),
  159. progress.constraintCenterYTo(progressContainer)
  160. ])
  161. progressContainer.isHidden = false
  162. progress.startAnimating()
  163. view.backgroundColor = DcColors.defaultBackgroundColor
  164. }
  165. private func getQrImage(svg: String?) -> UIImage? {
  166. if let svg = svg {
  167. let svgData = svg.data(using: .utf8)
  168. return SDImageSVGKCoder.shared.decodedImage(with: svgData, options: [:])
  169. }
  170. return nil
  171. }
  172. private func showLastErrorAlert(_ errorContext: String) {
  173. var lastError = dcContext.lastErrorString
  174. if lastError.isEmpty {
  175. lastError = "<last error not set>"
  176. }
  177. let error = errorContext + " (" + lastError + ")"
  178. let alert = UIAlertController(title: String.localized("multidevice_title"), message: error, preferredStyle: .alert)
  179. alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: nil))
  180. navigationController?.present(alert, animated: true, completion: nil)
  181. }
  182. private func getInstructions() -> String {
  183. return "➊ " + String.localized("multidevice_same_network_hint")
  184. + "\n\n➋ " + String.localized("multidevice_install_dc_on_other_device")
  185. + "\n\n➌ " + String.localized("multidevice_tap_scan_on_other_device")
  186. }
  187. // MARK: - actions
  188. @objc private func cancelButtonPressed() {
  189. switch transferState {
  190. case .error, .success:
  191. self.navigationController?.popViewController(animated: true)
  192. case .unknown:
  193. let alert = UIAlertController(title: nil, message: String.localized("multidevice_abort"), preferredStyle: .alert)
  194. alert.addAction(UIAlertAction(title: String.localized("ok"), style: .default, handler: { _ in
  195. self.navigationController?.popViewController(animated: true)
  196. }))
  197. alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
  198. navigationController?.present(alert, animated: true, completion: nil)
  199. }
  200. }
  201. }
  202. /// Does a best effort attempt to trigger the local network privacy alert.
  203. ///
  204. /// It works by sending a UDP datagram to the discard service (port 9) of every
  205. /// IP address associated with a broadcast-capable interface. This should
  206. /// trigger the local network privacy alert, assuming the alert hasn’t already
  207. /// been displayed for this app.
  208. ///
  209. /// This code takes a ‘best effort’. It handles errors by ignoring them. As
  210. /// such, there’s guarantee that it’ll actually trigger the alert.
  211. ///
  212. /// - note: iOS devices don’t actually run the discard service. I’m using it
  213. /// here because I need a port to send the UDP datagram to and port 9 is
  214. /// always going to be safe (either the discard service is running, in which
  215. /// case it will discard the datagram, or it’s not, in which case the TCP/IP
  216. /// stack will discard it).
  217. ///
  218. /// There should be a proper API for this (r. 69157424).
  219. ///
  220. /// For more background on this, see [Triggering the Local Network Privacy Alert](https://developer.apple.com/forums/thread/663768).
  221. func triggerLocalNetworkPrivacyAlert() {
  222. let sock4 = socket(AF_INET, SOCK_DGRAM, 0)
  223. guard sock4 >= 0 else { return }
  224. defer { close(sock4) }
  225. let sock6 = socket(AF_INET6, SOCK_DGRAM, 0)
  226. guard sock6 >= 0 else { return }
  227. defer { close(sock6) }
  228. let addresses = addressesOfDiscardServiceOnBroadcastCapableInterfaces()
  229. var message = [UInt8]("!".utf8)
  230. for address in addresses {
  231. address.withUnsafeBytes { buf in
  232. let sa = buf.baseAddress!.assumingMemoryBound(to: sockaddr.self)
  233. let saLen = socklen_t(buf.count)
  234. let sock = sa.pointee.sa_family == AF_INET ? sock4 : sock6
  235. _ = sendto(sock, &message, message.count, MSG_DONTWAIT, sa, saLen)
  236. }
  237. }
  238. }
  239. /// Returns the addresses of the discard service (port 9) on every
  240. /// broadcast-capable interface.
  241. ///
  242. /// Each array entry is contains either a `sockaddr_in` or `sockaddr_in6`.
  243. private func addressesOfDiscardServiceOnBroadcastCapableInterfaces() -> [Data] {
  244. var addrList: UnsafeMutablePointer<ifaddrs>?
  245. let err = getifaddrs(&addrList)
  246. guard err == 0, let start = addrList else { return [] }
  247. defer { freeifaddrs(start) }
  248. return sequence(first: start, next: { $0.pointee.ifa_next })
  249. .compactMap { i -> Data? in
  250. guard
  251. (i.pointee.ifa_flags & UInt32(bitPattern: IFF_BROADCAST)) != 0,
  252. let sa = i.pointee.ifa_addr
  253. else { return nil }
  254. var result = Data(UnsafeRawBufferPointer(start: sa, count: Int(sa.pointee.sa_len)))
  255. switch CInt(sa.pointee.sa_family) {
  256. case AF_INET:
  257. result.withUnsafeMutableBytes { buf in
  258. let sin = buf.baseAddress!.assumingMemoryBound(to: sockaddr_in.self)
  259. sin.pointee.sin_port = UInt16(9).bigEndian
  260. }
  261. case AF_INET6:
  262. result.withUnsafeMutableBytes { buf in
  263. let sin6 = buf.baseAddress!.assumingMemoryBound(to: sockaddr_in6.self)
  264. sin6.pointee.sin6_port = UInt16(9).bigEndian
  265. }
  266. default:
  267. return nil
  268. }
  269. return result
  270. }
  271. }