BackupTransferViewController.swift 12 KB

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