diff options
| author | Nikolay Davydov <nik.davidov@gmail.com> | 2022-08-23 12:02:09 +0400 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2022-08-23 13:34:27 +0200 |
| commit | bf44a57198cf08468c8f38de31194c189e8f5416 (patch) | |
| tree | 47bb1a13b3236e5e2ce95b81cc9eb4c72f0a62e7 | |
| parent | 066614cfbe34fc9d05f3156405214d1ec3523480 (diff) | |
| download | mullvadvpn-bf44a57198cf08468c8f38de31194c189e8f5416.tar.xz mullvadvpn-bf44a57198cf08468c8f38de31194c189e8f5416.zip | |
Add shortcuts manager
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 4 | ||||
| -rw-r--r-- | ios/MullvadVPN/SceneDelegate.swift | 1 | ||||
| -rw-r--r-- | ios/MullvadVPN/SettingsCell.swift | 14 | ||||
| -rw-r--r-- | ios/MullvadVPN/ShortcutsDataSource.swift | 202 | ||||
| -rw-r--r-- | ios/MullvadVPN/ShortcutsManager.swift | 64 | ||||
| -rw-r--r-- | ios/MullvadVPN/ShortcutsViewController.swift | 51 |
6 files changed, 229 insertions, 107 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 0c7c5a77d5..eb9a155cbe 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -307,6 +307,7 @@ 58FEEB46260A028D00A621A8 /* GeoJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB45260A028D00A621A8 /* GeoJSON.swift */; }; 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */; }; 58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58FF2C02281BDE02009EF542 /* SettingsManager.swift */; }; + 753D6C0C28B4BF3E0052D9E1 /* ShortcutsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 753D6C0B28B4BF3E0052D9E1 /* ShortcutsManager.swift */; }; 75FD0C2128B108570021E33E /* ShortcutsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FD0C2028B108570021E33E /* ShortcutsDataSource.swift */; }; 75FD0C2328B109860021E33E /* ShortcutsDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FD0C2228B109860021E33E /* ShortcutsDataSourceDelegate.swift */; }; 75FD0C2528B117D30021E33E /* ShortcutsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75FD0C2428B117D30021E33E /* ShortcutsViewController.swift */; }; @@ -601,6 +602,7 @@ 58FEEB45260A028D00A621A8 /* GeoJSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeoJSON.swift; sourceTree = "<group>"; }; 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyboardResponder.swift; sourceTree = "<group>"; }; 58FF2C02281BDE02009EF542 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; }; + 753D6C0B28B4BF3E0052D9E1 /* ShortcutsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsManager.swift; sourceTree = "<group>"; }; 75FD0C2028B108570021E33E /* ShortcutsDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsDataSource.swift; sourceTree = "<group>"; }; 75FD0C2228B109860021E33E /* ShortcutsDataSourceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsDataSourceDelegate.swift; sourceTree = "<group>"; }; 75FD0C2428B117D30021E33E /* ShortcutsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsViewController.swift; sourceTree = "<group>"; }; @@ -968,6 +970,7 @@ 75FD0C2428B117D30021E33E /* ShortcutsViewController.swift */, 75FD0C2228B109860021E33E /* ShortcutsDataSourceDelegate.swift */, 75FD0C2028B108570021E33E /* ShortcutsDataSource.swift */, + 753D6C0B28B4BF3E0052D9E1 /* ShortcutsManager.swift */, 584D26C1270C8542004EA533 /* SettingsStaticTextFooterView.swift */, 58ACF64A26553C3F00ACE4B7 /* SettingsSwitchCell.swift */, 58CCA01122424D11004F3011 /* SettingsViewController.swift */, @@ -1469,6 +1472,7 @@ 5815039724D6ECAE00C9C50E /* CustomFormatLogHandler.swift in Sources */, 5820675E26E6839900655B05 /* PresentAlertOperation.swift in Sources */, 5815039D24D6ECE600C9C50E /* TextFileOutputStream.swift in Sources */, + 753D6C0C28B4BF3E0052D9E1 /* ShortcutsManager.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, 5872D6E8286304DE00DB5F4E /* TermsOfService.swift in Sources */, 58E0A98827C8F46300FE6BDD /* Tunnel.swift in Sources */, diff --git a/ios/MullvadVPN/SceneDelegate.swift b/ios/MullvadVPN/SceneDelegate.swift index e9ba018d5b..246e348c48 100644 --- a/ios/MullvadVPN/SceneDelegate.swift +++ b/ios/MullvadVPN/SceneDelegate.swift @@ -147,6 +147,7 @@ class SceneDelegate: UIResponder { RelayCache.Tracker.shared.startPeriodicUpdates() TunnelManager.shared.startPeriodicPrivateKeyRotation() AddressCache.Tracker.shared.startPeriodicUpdates() + ShortcutsManager.shared.updateVoiceShortcuts() setShowsPrivacyOverlay(false) } diff --git a/ios/MullvadVPN/SettingsCell.swift b/ios/MullvadVPN/SettingsCell.swift index 3aaf1ee67b..16f48a1628 100644 --- a/ios/MullvadVPN/SettingsCell.swift +++ b/ios/MullvadVPN/SettingsCell.swift @@ -12,6 +12,7 @@ enum SettingsDisclosureType { case none case chevron case externalLink + case tick var image: UIImage? { switch self { @@ -21,6 +22,8 @@ enum SettingsDisclosureType { return UIImage(named: "IconChevron") case .externalLink: return UIImage(named: "IconExtlink") + case .tick: + return .iconTickSmall } } } @@ -166,3 +169,14 @@ class SettingsCell: UITableViewCell { contentView.frame = contentView.frame.inset(by: contentInset) } } + +private extension UIImage { + static let iconTickSmall: UIImage? = { + guard let image = UIImage(named: "IconTick") else { return nil } + let size = CGSize(width: 16, height: 16) + return UIGraphicsImageRenderer(size: size).image { context in + let rect = CGRect(origin: .zero, size: size) + image.draw(in: rect) + } + }() +} diff --git a/ios/MullvadVPN/ShortcutsDataSource.swift b/ios/MullvadVPN/ShortcutsDataSource.swift index 1719af12c1..429d196246 100644 --- a/ios/MullvadVPN/ShortcutsDataSource.swift +++ b/ios/MullvadVPN/ShortcutsDataSource.swift @@ -9,7 +9,11 @@ import IntentsUI import UIKit -final class ShortcutsDataSource: NSObject, UITableViewDataSource, UITableViewDelegate { +final class ShortcutsDataSource: NSObject, + UITableViewDataSource, + UITableViewDelegate, + ShortcutsManagerDelegate +{ private enum CellReuseIdentifiers: String, CaseIterable { case basicCell @@ -36,45 +40,86 @@ final class ShortcutsDataSource: NSObject, UITableViewDataSource, UITableViewDel case shortcuts } - enum Item: String, CaseIterable { - case start - case reconnect - case stop + struct Item: Hashable { + let title: String + let shortcut: INShortcut + let voiceShortcut: INVoiceShortcut? + + var isAdded: Bool { + return voiceShortcut != nil + } } private var snapshot = DataSourceSnapshot<Section, Item>() weak var delegate: ShortcutsDataSourceDelegate? - func configure(_ tableView: UITableView) { + weak var tableView: UITableView? { + didSet { + tableView?.delegate = self + tableView?.dataSource = self + + registerClasses() + } + } + + override init() { + super.init() + updateDataSnapshot(voiceShortcuts: []) + ShortcutsManager.shared.delegate = self + ShortcutsManager.shared.updateVoiceShortcuts() + } + + private func registerClasses() { CellReuseIdentifiers.allCases.forEach { cellIdentifier in - tableView.register( + tableView?.register( cellIdentifier.reusableViewClass, forCellReuseIdentifier: cellIdentifier.rawValue ) } + HeaderFooterReuseIdentifier.allCases.forEach { reuseIdentifier in - tableView.register( + tableView?.register( reuseIdentifier.reusableViewClass, forHeaderFooterViewReuseIdentifier: reuseIdentifier.rawValue ) } - tableView.dataSource = self - tableView.delegate = self } - override init() { - super.init() - updateDataSnapshot() - } + private func updateDataSnapshot(voiceShortcuts: [INVoiceShortcut]) { + var items = [Item]() + + for data in ShortcutData.allCases { + guard let shortcut = data.shortcut else { continue } + let voiceShortcut = voiceShortcuts.first(where: { voiceShortcut in + isVoiceShortcut(voiceShortcut, invokes: shortcut) + }) + let item = Item( + title: data.title, + shortcut: shortcut, + voiceShortcut: voiceShortcut + ) + items.append(item) + } - private func updateDataSnapshot() { var newSnapshot = DataSourceSnapshot<Section, Item>() newSnapshot.appendSections([.shortcuts]) - newSnapshot.appendItems(Item.allCases, in: .shortcuts) + newSnapshot.appendItems(items, in: .shortcuts) + snapshot = newSnapshot } + /// Returns whether the voice shortcut performs the same action as the specified shortcut. + private func isVoiceShortcut( + _ voiceShortcut: INVoiceShortcut, + invokes shortcut: INShortcut + ) -> Bool { + if let a = voiceShortcut.shortcut.intent, let b = shortcut.intent { + return type(of: a) == type(of: b) + } + return false + } + // MARK: - UITableViewDataSource func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -88,17 +133,15 @@ final class ShortcutsDataSource: NSObject, UITableViewDataSource, UITableViewDel func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let item = snapshot.itemForIndexPath(indexPath)! - switch item { - case .start, .reconnect, .stop: - let cell = tableView.dequeueReusableCell( - withIdentifier: CellReuseIdentifiers.basicCell.rawValue, - for: indexPath - ) - if let cell = cell as? SettingsCell { - cell.titleLabel.text = item.title - } - return cell + let cell = tableView.dequeueReusableCell( + withIdentifier: CellReuseIdentifiers.basicCell.rawValue, + for: indexPath + ) + if let cell = cell as? SettingsCell { + cell.titleLabel.text = item.title + cell.disclosureType = item.isAdded ? .tick : .none } + return cell } // MARK: - UITableViewDelegate @@ -126,63 +169,66 @@ final class ShortcutsDataSource: NSObject, UITableViewDataSource, UITableViewDel func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { return 0 } -} -extension ShortcutsDataSource.Item { - var title: String { - switch self { - case .start: - return NSLocalizedString( - "SHORTCUTS_NAME_START_VPN", - tableName: "Shortcuts", - value: "Start VPN", - comment: "" - ) - case .reconnect: - return NSLocalizedString( - "SHORTCUTS_NAME_RECONNECT_VPN", - tableName: "Shortcuts", - value: "Reconnect VPN", - comment: "" - ) - case .stop: - return NSLocalizedString( - "SHORTCUTS_NAME_STOP_VPN", - tableName: "Shortcuts", - value: "Stop VPN", - comment: "" - ) - } + // MARK: - ShortcutsManagerDelegate + + func shortcutsManager( + _ shortcutsManager: ShortcutsManager, + didReceiveVoiceShortcuts voiceShortcuts: [INVoiceShortcut] + ) { + updateDataSnapshot(voiceShortcuts: voiceShortcuts) + tableView?.reloadData() } +} - var shortcut: INShortcut? { - let intent: INIntent - switch self { - case .start: - intent = StartVPNIntent() - case .reconnect: - intent = ReconnectVPNIntent() - case .stop: - intent = StopVPNIntent() - } - intent.suggestedInvocationPhrase = title - guard let shortcut = INShortcut(intent: intent) else { - assertionFailure("The shortcut has an invalid intent.") - return nil +private extension ShortcutsDataSource { + enum ShortcutData: CaseIterable { + case start + case reconnect + case stop + + var title: String { + switch self { + case .start: + return NSLocalizedString( + "SHORTCUTS_NAME_START_VPN", + tableName: "Shortcuts", + value: "Start VPN", + comment: "" + ) + case .reconnect: + return NSLocalizedString( + "SHORTCUTS_NAME_RECONNECT_VPN", + tableName: "Shortcuts", + value: "Reconnect VPN", + comment: "" + ) + case .stop: + return NSLocalizedString( + "SHORTCUTS_NAME_STOP_VPN", + tableName: "Shortcuts", + value: "Stop VPN", + comment: "" + ) + } } - return shortcut - } - init?(voiceShortcut: INVoiceShortcut) { - switch voiceShortcut.shortcut.intent { - case is StartVPNIntent: - self = .start - case is ReconnectVPNIntent: - self = .reconnect - case is StopVPNIntent: - self = .stop - default: - return nil + var shortcut: INShortcut? { + let intent: INIntent + switch self { + case .start: + intent = StartVPNIntent() + case .reconnect: + intent = ReconnectVPNIntent() + case .stop: + intent = StopVPNIntent() + } + intent.suggestedInvocationPhrase = title + guard let shortcut = INShortcut(intent: intent) else { + assertionFailure("The shortcut has an invalid intent.") + return nil + } + return shortcut } } } diff --git a/ios/MullvadVPN/ShortcutsManager.swift b/ios/MullvadVPN/ShortcutsManager.swift new file mode 100644 index 0000000000..4a6309429c --- /dev/null +++ b/ios/MullvadVPN/ShortcutsManager.swift @@ -0,0 +1,64 @@ +// +// ShortcutsManager.swift +// MullvadVPN +// +// Created by Nikolay Davydov on 23.08.2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import IntentsUI +import Logging + +protocol ShortcutsManagerDelegate: AnyObject { + func shortcutsManager( + _ shortcutsManager: ShortcutsManager, + didReceiveVoiceShortcuts voiceShortcuts: [INVoiceShortcut] + ) +} + +final class ShortcutsManager { + static let shared = ShortcutsManager() + + private init() {} + + private let logger = Logger(label: "ShortcutsManager") + + private var voiceShortcutsByID = [UUID: INVoiceShortcut]() { + didSet { + let voiceShortcuts = voiceShortcutsByID.map { $0.value } + delegate?.shortcutsManager(self, didReceiveVoiceShortcuts: voiceShortcuts) + } + } + + weak var delegate: ShortcutsManagerDelegate? + + func updateVoiceShortcuts() { + guard delegate != nil else { return } + INVoiceShortcutCenter.shared.getAllVoiceShortcuts { [weak self] voiceShortcuts, error in + guard let self = self else { return } + if let error = error { + self.logger.error( + chainedError: AnyChainedError(error), + message: "Failed to fetch voice shortcuts." + ) + return + } + let voiceShortcuts = voiceShortcuts ?? [] + let voiceShortcutsByID = voiceShortcuts + .reduce(into: [UUID: INVoiceShortcut]()) { result, voiceShortcut in + result[voiceShortcut.identifier] = voiceShortcut + } + DispatchQueue.main.async { + self.voiceShortcutsByID = voiceShortcutsByID + } + } + } + + func addVoiceShortcut(_ voiceShortcut: INVoiceShortcut) { + voiceShortcutsByID[voiceShortcut.identifier] = voiceShortcut + } + + func deleteVoiceShortcut(withIdentifier identifier: UUID) { + voiceShortcutsByID[identifier] = nil + } +} diff --git a/ios/MullvadVPN/ShortcutsViewController.swift b/ios/MullvadVPN/ShortcutsViewController.swift index 94b6abba0b..953639dca3 100644 --- a/ios/MullvadVPN/ShortcutsViewController.swift +++ b/ios/MullvadVPN/ShortcutsViewController.swift @@ -36,7 +36,7 @@ final class ShortcutsViewController: UITableViewController, tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 60 - dataSource.configure(tableView) + dataSource.tableView = tableView dataSource.delegate = self navigationItem.title = NSLocalizedString( @@ -47,41 +47,28 @@ final class ShortcutsViewController: UITableViewController, ) } - private func handleSelectShortcut(_ shortcut: INShortcut, item: ShortcutsDataSource.Item) { - INVoiceShortcutCenter.shared.getAllVoiceShortcuts { [weak self] shortcuts, error in - DispatchQueue.main.async { - guard let self = self else { return } - let controller: UIViewController - if let voiceShortcut = shortcuts?.first(where: { voiceShortcut in - ShortcutsDataSource.Item(voiceShortcut: voiceShortcut) == item - }) { - let editShortcutController = INUIEditVoiceShortcutViewController( - voiceShortcut: voiceShortcut - ) - editShortcutController.delegate = self - controller = editShortcutController - } else { - let addShortcutController = INUIAddVoiceShortcutViewController( - shortcut: shortcut - ) - addShortcutController.delegate = self - controller = addShortcutController - } - controller.modalPresentationStyle = .formSheet - self.present(controller, animated: true) - } - } - } - // MARK: - ShortcutsDataSourceDelegate func shortcutsDataSource( _ dataSource: ShortcutsDataSource, didSelectItem item: ShortcutsDataSource.Item ) { - if let shortcut = item.shortcut { - handleSelectShortcut(shortcut, item: item) + let controller: UIViewController + if let voiceShortcut = item.voiceShortcut { + let editShortcutController = INUIEditVoiceShortcutViewController( + voiceShortcut: voiceShortcut + ) + editShortcutController.delegate = self + controller = editShortcutController + } else { + let addShortcutController = INUIAddVoiceShortcutViewController( + shortcut: item.shortcut + ) + addShortcutController.delegate = self + controller = addShortcutController } + controller.modalPresentationStyle = .formSheet + present(controller, animated: true) } // MARK: - INUIAddVoiceShortcutViewControllerDelegate @@ -91,6 +78,9 @@ final class ShortcutsViewController: UITableViewController, didFinishWith voiceShortcut: INVoiceShortcut?, error: Error? ) { + if let voiceShortcut = voiceShortcut { + ShortcutsManager.shared.addVoiceShortcut(voiceShortcut) + } controller.dismiss(animated: true) } @@ -112,6 +102,9 @@ final class ShortcutsViewController: UITableViewController, _ controller: INUIEditVoiceShortcutViewController, didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID ) { + ShortcutsManager.shared.deleteVoiceShortcut( + withIdentifier: deletedVoiceShortcutIdentifier + ) controller.dismiss(animated: true) } |
