summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2022-08-23 13:34:49 +0200
committerAndrej Mihajlov <and@mullvad.net>2022-08-23 13:34:49 +0200
commite7c9140c4c2555affdd4ed879cd1a3593bdea7cf (patch)
tree47bb1a13b3236e5e2ce95b81cc9eb4c72f0a62e7
parentea00c0c6c1fe38d01bee3ef0fe60d362749b64b0 (diff)
parentbf44a57198cf08468c8f38de31194c189e8f5416 (diff)
downloadmullvadvpn-e7c9140c4c2555affdd4ed879cd1a3593bdea7cf.tar.xz
mullvadvpn-e7c9140c4c2555affdd4ed879cd1a3593bdea7cf.zip
Merge branch 'add-shortcuts'
-rw-r--r--ios/CHANGELOG.md1
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj16
-rw-r--r--ios/MullvadVPN/SceneDelegate.swift1
-rw-r--r--ios/MullvadVPN/SettingsCell.swift14
-rw-r--r--ios/MullvadVPN/SettingsDataSource.swift20
-rw-r--r--ios/MullvadVPN/SettingsNavigationController.swift4
-rw-r--r--ios/MullvadVPN/SettingsViewController.swift2
-rw-r--r--ios/MullvadVPN/ShortcutsDataSource.swift234
-rw-r--r--ios/MullvadVPN/ShortcutsDataSourceDelegate.swift14
-rw-r--r--ios/MullvadVPN/ShortcutsManager.swift64
-rw-r--r--ios/MullvadVPN/ShortcutsViewController.swift116
11 files changed, 485 insertions, 1 deletions
diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md
index 8d297676f1..ab0a074415 100644
--- a/ios/CHANGELOG.md
+++ b/ios/CHANGELOG.md
@@ -32,6 +32,7 @@ Line wrap the file at 100 chars. Th
- Add ability to manage registered devices if too many devices detected during log-in.
- Add intents: start VPN, stop VPN, reconnect VPN (acts as start VPN when the tunnel is down,
otherwise picks new relay).
+- Add menu item to control shortcuts.
### Changed
- When logged into an account with no time left, a new view is shown instead of account settings,
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 8d67e122ac..eb9a155cbe 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -307,6 +307,10 @@
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 */; };
E1187ABC289BBB850024E748 /* OutOfTimeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */; };
E1187ABD289BBB850024E748 /* OutOfTimeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */; };
E1187ABF289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1187ABE289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift */; };
@@ -598,6 +602,10 @@
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>"; };
E1187ABA289BBB850024E748 /* OutOfTimeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeViewController.swift; sourceTree = "<group>"; };
E1187ABB289BBB850024E748 /* OutOfTimeContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutOfTimeContentView.swift; sourceTree = "<group>"; };
E1187ABE289BE76F0024E748 /* RESTCreateApplePaymentResponse+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RESTCreateApplePaymentResponse+Localization.swift"; sourceTree = "<group>"; };
@@ -959,6 +967,10 @@
584D26C5270C8741004EA533 /* SettingsDNSTextCell.swift */,
580F8B88281A79A7002E0998 /* SettingsManager */,
58E6771E24ADFE7800AA26E7 /* SettingsNavigationController.swift */,
+ 75FD0C2428B117D30021E33E /* ShortcutsViewController.swift */,
+ 75FD0C2228B109860021E33E /* ShortcutsDataSourceDelegate.swift */,
+ 75FD0C2028B108570021E33E /* ShortcutsDataSource.swift */,
+ 753D6C0B28B4BF3E0052D9E1 /* ShortcutsManager.swift */,
584D26C1270C8542004EA533 /* SettingsStaticTextFooterView.swift */,
58ACF64A26553C3F00ACE4B7 /* SettingsSwitchCell.swift */,
58CCA01122424D11004F3011 /* SettingsViewController.swift */,
@@ -1391,6 +1403,7 @@
582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */,
58554F7D280D6FE000013055 /* RESTURLSession.swift in Sources */,
5820EDAB288FF0D2006BF4E4 /* DeviceRowView.swift in Sources */,
+ 75FD0C2128B108570021E33E /* ShortcutsDataSource.swift in Sources */,
589D28822846306C00F9A7B3 /* GroupOperation.swift in Sources */,
5846227726E22A7C0035F7C2 /* AppStorePaymentManagerDelegate.swift in Sources */,
5871FB8325498CA20051A0A4 /* Swizzle.swift in Sources */,
@@ -1414,6 +1427,7 @@
58CC40EF24A601900019D96E /* ObserverList.swift in Sources */,
58CCA01822426713004F3011 /* AccountViewController.swift in Sources */,
5820674E26E6510200655B05 /* REST.swift in Sources */,
+ 75FD0C2528B117D30021E33E /* ShortcutsViewController.swift in Sources */,
5871FBA0254C26C00051A0A4 /* NSRegularExpression+IPAddress.swift in Sources */,
58F7CA882692E34000FC59FD /* WireguardKeysContentView.swift in Sources */,
58554F7B280B125F00013055 /* RESTAccountsProxy.swift in Sources */,
@@ -1458,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 */,
@@ -1479,6 +1494,7 @@
58B9EB152489139B00095626 /* DisplayChainedError.swift in Sources */,
587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */,
58554F79280B037400013055 /* RESTAccessTokenManager.swift in Sources */,
+ 75FD0C2328B109860021E33E /* ShortcutsDataSourceDelegate.swift in Sources */,
58421034282E4B1500F24E46 /* TunnelSettingsV2+REST.swift in Sources */,
58F2E144276A13F300A79513 /* StartTunnelOperation.swift in Sources */,
5868BD33261DCD2600E6027F /* CustomSplitViewController.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/SettingsDataSource.swift b/ios/MullvadVPN/SettingsDataSource.swift
index cb457b042a..44e4891570 100644
--- a/ios/MullvadVPN/SettingsDataSource.swift
+++ b/ios/MullvadVPN/SettingsDataSource.swift
@@ -43,6 +43,7 @@ class SettingsDataSource: NSObject, TunnelObserver, UITableViewDataSource, UITab
enum Item: String {
case account
case preferences
+ case shortcuts
case version
case problemReport
case faq
@@ -92,7 +93,7 @@ class SettingsDataSource: NSObject, TunnelObserver, UITableViewDataSource, UITab
if TunnelManager.shared.deviceState.isLoggedIn {
newSnapshot.appendSections([.main])
- newSnapshot.appendItems([.account, .preferences], in: .main)
+ newSnapshot.appendItems([.account, .preferences, .shortcuts], in: .main)
}
newSnapshot.appendSections([.version, .problemReport])
@@ -152,6 +153,23 @@ class SettingsDataSource: NSObject, TunnelObserver, UITableViewDataSource, UITab
return cell
+ case .shortcuts:
+ let cell = tableView.dequeueReusableCell(
+ withIdentifier: CellReuseIdentifiers.basicCell.rawValue,
+ for: indexPath
+ ) as! SettingsCell
+ cell.titleLabel.text = NSLocalizedString(
+ "SHORTCUTS_CELL_LABEL",
+ tableName: "Settings",
+ value: "Shortcuts",
+ comment: ""
+ )
+ cell.detailTitleLabel.text = nil
+ cell.accessibilityIdentifier = nil
+ cell.disclosureType = .chevron
+
+ return cell
+
case .version:
let cell = tableView.dequeueReusableCell(
withIdentifier: CellReuseIdentifiers.basicCell.rawValue,
diff --git a/ios/MullvadVPN/SettingsNavigationController.swift b/ios/MullvadVPN/SettingsNavigationController.swift
index f462f603c3..0476ac23fa 100644
--- a/ios/MullvadVPN/SettingsNavigationController.swift
+++ b/ios/MullvadVPN/SettingsNavigationController.swift
@@ -13,6 +13,7 @@ enum SettingsNavigationRoute {
case root
case account
case preferences
+ case shortcuts
case problemReport
}
@@ -119,6 +120,9 @@ class SettingsNavigationController: CustomNavigationController, SettingsViewCont
case .preferences:
return PreferencesViewController()
+ case .shortcuts:
+ return ShortcutsViewController()
+
case .problemReport:
return ProblemReportViewController()
}
diff --git a/ios/MullvadVPN/SettingsViewController.swift b/ios/MullvadVPN/SettingsViewController.swift
index 910e933319..0048c2eed3 100644
--- a/ios/MullvadVPN/SettingsViewController.swift
+++ b/ios/MullvadVPN/SettingsViewController.swift
@@ -102,6 +102,8 @@ extension SettingsDataSource.Item {
return .account
case .preferences:
return .preferences
+ case .shortcuts:
+ return .shortcuts
case .version:
return nil
case .problemReport:
diff --git a/ios/MullvadVPN/ShortcutsDataSource.swift b/ios/MullvadVPN/ShortcutsDataSource.swift
new file mode 100644
index 0000000000..429d196246
--- /dev/null
+++ b/ios/MullvadVPN/ShortcutsDataSource.swift
@@ -0,0 +1,234 @@
+//
+// ShortcutsDataSource.swift
+// MullvadVPN
+//
+// Created by Nikolay Davydov on 20.08.2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import IntentsUI
+import UIKit
+
+final class ShortcutsDataSource: NSObject,
+ UITableViewDataSource,
+ UITableViewDelegate,
+ ShortcutsManagerDelegate
+{
+ private enum CellReuseIdentifiers: String, CaseIterable {
+ case basicCell
+
+ var reusableViewClass: AnyClass {
+ switch self {
+ case .basicCell:
+ return SettingsCell.self
+ }
+ }
+ }
+
+ private enum HeaderFooterReuseIdentifier: String, CaseIterable {
+ case spacer
+
+ var reusableViewClass: AnyClass {
+ switch self {
+ case .spacer:
+ return EmptyTableViewHeaderFooterView.self
+ }
+ }
+ }
+
+ enum Section: String {
+ case shortcuts
+ }
+
+ 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?
+
+ 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(
+ cellIdentifier.reusableViewClass,
+ forCellReuseIdentifier: cellIdentifier.rawValue
+ )
+ }
+
+ HeaderFooterReuseIdentifier.allCases.forEach { reuseIdentifier in
+ tableView?.register(
+ reuseIdentifier.reusableViewClass,
+ forHeaderFooterViewReuseIdentifier: reuseIdentifier.rawValue
+ )
+ }
+ }
+
+ 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)
+ }
+
+ var newSnapshot = DataSourceSnapshot<Section, Item>()
+ newSnapshot.appendSections([.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 {
+ let sectionIdentifier = snapshot.section(at: section)!
+ return snapshot.numberOfItems(in: sectionIdentifier) ?? 0
+ }
+
+ func numberOfSections(in tableView: UITableView) -> Int {
+ return snapshot.numberOfSections()
+ }
+
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ let item = snapshot.itemForIndexPath(indexPath)!
+ 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
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ guard let item = snapshot.itemForIndexPath(indexPath) else { return }
+ delegate?.shortcutsDataSource(self, didSelectItem: item)
+ tableView.deselectRow(at: indexPath, animated: true)
+ }
+
+ func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
+ return tableView.dequeueReusableHeaderFooterView(
+ withIdentifier: HeaderFooterReuseIdentifier.spacer.rawValue
+ )
+ }
+
+ func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
+ return nil
+ }
+
+ func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
+ return UIMetrics.sectionSpacing
+ }
+
+ func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
+ return 0
+ }
+
+ // MARK: - ShortcutsManagerDelegate
+
+ func shortcutsManager(
+ _ shortcutsManager: ShortcutsManager,
+ didReceiveVoiceShortcuts voiceShortcuts: [INVoiceShortcut]
+ ) {
+ updateDataSnapshot(voiceShortcuts: voiceShortcuts)
+ tableView?.reloadData()
+ }
+}
+
+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: ""
+ )
+ }
+ }
+
+ 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/ShortcutsDataSourceDelegate.swift b/ios/MullvadVPN/ShortcutsDataSourceDelegate.swift
new file mode 100644
index 0000000000..2fde666721
--- /dev/null
+++ b/ios/MullvadVPN/ShortcutsDataSourceDelegate.swift
@@ -0,0 +1,14 @@
+//
+// ShortcutsDataSourceDelegate.swift
+// MullvadVPN
+//
+// Created by Nikolay Davydov on 20.08.2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+protocol ShortcutsDataSourceDelegate: AnyObject {
+ func shortcutsDataSource(
+ _ dataSource: ShortcutsDataSource,
+ didSelectItem item: ShortcutsDataSource.Item
+ )
+}
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
new file mode 100644
index 0000000000..953639dca3
--- /dev/null
+++ b/ios/MullvadVPN/ShortcutsViewController.swift
@@ -0,0 +1,116 @@
+//
+// ShortcutsViewController.swift
+// MullvadVPN
+//
+// Created by Nikolay Davydov on 20.08.2022.
+// Copyright © 2022 Mullvad VPN AB. All rights reserved.
+//
+
+import IntentsUI
+import UIKit
+
+final class ShortcutsViewController: UITableViewController,
+ ShortcutsDataSourceDelegate,
+ INUIAddVoiceShortcutViewControllerDelegate,
+ INUIEditVoiceShortcutViewControllerDelegate
+{
+ private let dataSource = ShortcutsDataSource()
+
+ override var preferredStatusBarStyle: UIStatusBarStyle {
+ return .lightContent
+ }
+
+ init() {
+ super.init(style: .grouped)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ tableView.backgroundColor = .secondaryColor
+ tableView.separatorColor = .secondaryColor
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.estimatedRowHeight = 60
+
+ dataSource.tableView = tableView
+ dataSource.delegate = self
+
+ navigationItem.title = NSLocalizedString(
+ "NAVIGATION_TITLE",
+ tableName: "Shortcuts",
+ value: "Shortcuts",
+ comment: ""
+ )
+ }
+
+ // MARK: - ShortcutsDataSourceDelegate
+
+ func shortcutsDataSource(
+ _ dataSource: ShortcutsDataSource,
+ didSelectItem item: ShortcutsDataSource.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
+
+ func addVoiceShortcutViewController(
+ _ controller: INUIAddVoiceShortcutViewController,
+ didFinishWith voiceShortcut: INVoiceShortcut?,
+ error: Error?
+ ) {
+ if let voiceShortcut = voiceShortcut {
+ ShortcutsManager.shared.addVoiceShortcut(voiceShortcut)
+ }
+ controller.dismiss(animated: true)
+ }
+
+ func addVoiceShortcutViewControllerDidCancel(_ controller: INUIAddVoiceShortcutViewController) {
+ controller.dismiss(animated: true)
+ }
+
+ // MARK: - INUIEditVoiceShortcutViewControllerDelegate
+
+ func editVoiceShortcutViewController(
+ _ controller: INUIEditVoiceShortcutViewController,
+ didUpdate voiceShortcut: INVoiceShortcut?,
+ error: Error?
+ ) {
+ controller.dismiss(animated: true)
+ }
+
+ func editVoiceShortcutViewController(
+ _ controller: INUIEditVoiceShortcutViewController,
+ didDeleteVoiceShortcutWithIdentifier deletedVoiceShortcutIdentifier: UUID
+ ) {
+ ShortcutsManager.shared.deleteVoiceShortcut(
+ withIdentifier: deletedVoiceShortcutIdentifier
+ )
+ controller.dismiss(animated: true)
+ }
+
+ func editVoiceShortcutViewControllerDidCancel(
+ _ controller: INUIEditVoiceShortcutViewController
+ ) {
+ controller.dismiss(animated: true)
+ }
+}