diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2021-06-07 17:43:29 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-06-11 12:30:26 +0200 |
| commit | 072999382be7305db4b7deaf0c61929cdf77aebb (patch) | |
| tree | 4ff5186b917d12a087a0a6672f7fc35543ecd14a | |
| parent | baa7c4f569e048f92690a7c557d10414b5db3698 (diff) | |
| download | mullvadvpn-072999382be7305db4b7deaf0c61929cdf77aebb.tar.xz mullvadvpn-072999382be7305db4b7deaf0c61929cdf77aebb.zip | |
Add account expiry notification
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 34 | ||||
| -rw-r--r-- | ios/MullvadVPN/Account.swift | 13 | ||||
| -rw-r--r-- | ios/MullvadVPN/AppDelegate.swift | 62 | ||||
| -rw-r--r-- | ios/MullvadVPN/Assets.xcassets/Palette/Contents.json | 6 | ||||
| -rw-r--r-- | ios/MullvadVPN/Assets.xcassets/Palette/Warning.colorset/Contents.json | 20 | ||||
| -rw-r--r-- | ios/MullvadVPN/ConnectMainContentView.swift | 18 | ||||
| -rw-r--r-- | ios/MullvadVPN/ConnectViewController.swift | 25 | ||||
| -rw-r--r-- | ios/MullvadVPN/NotificationBannerView.swift | 133 | ||||
| -rw-r--r-- | ios/MullvadVPN/NotificationContainerView.swift | 22 | ||||
| -rw-r--r-- | ios/MullvadVPN/NotificationController.swift | 150 | ||||
| -rw-r--r-- | ios/MullvadVPN/NotificationManager.swift | 222 | ||||
| -rw-r--r-- | ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift | 123 | ||||
| -rw-r--r-- | ios/MullvadVPN/RootContainerViewController.swift | 2 | ||||
| -rw-r--r-- | ios/MullvadVPN/SettingsNavigationController.swift | 17 | ||||
| -rw-r--r-- | ios/MullvadVPN/UIColor+Palette.swift | 7 | ||||
| -rw-r--r-- | ios/MullvadVPN/UIMetrics.swift | 3 | ||||
| -rw-r--r-- | ios/MullvadVPN/en.lproj/Localizable.strings | 5 |
17 files changed, 830 insertions, 32 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 81ce229871..b544d9f8ce 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -129,8 +129,13 @@ 587AD7C623421D7000E93A53 /* TunnelSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587AD7C523421D7000E93A53 /* TunnelSettings.swift */; }; 587AD7C723421D8600E93A53 /* TunnelSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587AD7C523421D7000E93A53 /* TunnelSettings.swift */; }; 587AD7CA2342283900E93A53 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587AD7C92342283900E93A53 /* Account.swift */; }; - 587B7545266922BF00DEF7E9 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 587B7543266922BF00DEF7E9 /* Localizable.strings */; }; + 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B7535266528A200DEF7E9 /* NotificationManager.swift */; }; + 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B753A2666467500DEF7E9 /* NotificationBannerView.swift */; }; + 587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B753C2666468F00DEF7E9 /* NotificationController.swift */; }; + 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B753E2668E5A700DEF7E9 /* NotificationContainerView.swift */; }; + 587B75412668FD7800DEF7E9 /* AccountExpiryNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587B75402668FD7700DEF7E9 /* AccountExpiryNotificationProvider.swift */; }; 587CBFE322807F530028DED3 /* UIColor+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */; }; + 5883A09E266A5AF7003EFFCB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 587B7543266922BF00DEF7E9 /* Localizable.strings */; }; 588534BF246193D90018B744 /* AutomaticKeyRotationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588534BD246193C00018B744 /* AutomaticKeyRotationManager.swift */; }; 58871D1825D5359B002297FA /* MullvadRest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */; }; 58871D1E25D535A3002297FA /* WireGuardKit in Frameworks */ = {isa = PBXBuildFile; productRef = 58871D1D25D535A3002297FA /* WireGuardKit */; }; @@ -358,6 +363,11 @@ 587A01FB23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorTunnelProviderHost.swift; sourceTree = "<group>"; }; 587AD7C523421D7000E93A53 /* TunnelSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelSettings.swift; sourceTree = "<group>"; }; 587AD7C92342283900E93A53 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; }; + 587B7535266528A200DEF7E9 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; }; + 587B753A2666467500DEF7E9 /* NotificationBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBannerView.swift; sourceTree = "<group>"; }; + 587B753C2666468F00DEF7E9 /* NotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationController.swift; sourceTree = "<group>"; }; + 587B753E2668E5A700DEF7E9 /* NotificationContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContainerView.swift; sourceTree = "<group>"; }; + 587B75402668FD7700DEF7E9 /* AccountExpiryNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountExpiryNotificationProvider.swift; sourceTree = "<group>"; }; 587B7544266922BF00DEF7E9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; 587CBFE222807F530028DED3 /* UIColor+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Helpers.swift"; sourceTree = "<group>"; }; 588534BD246193C00018B744 /* AutomaticKeyRotationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticKeyRotationManager.swift; sourceTree = "<group>"; }; @@ -429,6 +439,7 @@ 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = "<group>"; }; 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBarView.swift; sourceTree = "<group>"; }; 58F3C0A524A50155003E76BE /* relays.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = relays.json; sourceTree = "<group>"; }; + 58F7A23D2665179C00524A8B /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; 58F7D30E250FA12E0097BE4E /* AnyIPEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyIPEndpoint.swift; sourceTree = "<group>"; }; 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainItemRevision.swift; sourceTree = "<group>"; }; 58F840B12464491D0044E708 /* ChainedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainedError.swift; sourceTree = "<group>"; }; @@ -489,6 +500,7 @@ 0E15C74FDCF763609B367486 /* Frameworks */ = { isa = PBXGroup; children = ( + 58F7A23D2665179C00524A8B /* UserNotifications.framework */, 5894E725236B2801008A2793 /* SwiftUI.framework */, 586BD68222B7BBD800BB7F9F /* NetworkExtension.framework */, ); @@ -541,6 +553,14 @@ path = GeoJSON; sourceTree = "<group>"; }; + 587B75422669034500DEF7E9 /* Notifications */ = { + isa = PBXGroup; + children = ( + 587B75402668FD7700DEF7E9 /* AccountExpiryNotificationProvider.swift */, + ); + path = Notifications; + sourceTree = "<group>"; + }; 58B0A2A1238EE67E00BC001D /* MullvadVPNTests */ = { isa = PBXGroup; children = ( @@ -621,6 +641,9 @@ 5892A45D265FABFF00890742 /* EmptyTableViewHeaderFooterView.swift */, 58FEEB45260A028D00A621A8 /* GeoJSON.swift */, 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */, + 587B753A2666467500DEF7E9 /* NotificationBannerView.swift */, + 587B753E2668E5A700DEF7E9 /* NotificationContainerView.swift */, + 587B753C2666468F00DEF7E9 /* NotificationController.swift */, 58FD5BF32428C67600112C88 /* InAppPurchaseButton.swift */, 58CE5E6F224146210008646E /* Info.plist */, 5840250022B1124600E4CFEC /* IPAddress+Codable.swift */, @@ -646,6 +669,8 @@ 5866F39B2243B82D00168AE5 /* MullvadVPN.entitlements */, 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, + 587B7535266528A200DEF7E9 /* NotificationManager.swift */, + 587B75422669034500DEF7E9 /* Notifications */, 5871FB9F254C26BF0051A0A4 /* NSRegularExpression+IPAddress.swift */, 58CC40EE24A601900019D96E /* ObserverList.swift */, 580EE1FF24B3218800F9D8A1 /* Operations */, @@ -925,7 +950,7 @@ 58727283265D173C00F315B2 /* LaunchScreen.storyboard in Resources */, 586ADD4723FC13F400CE9E87 /* countries.geo.json in Resources */, 58CE5E6B224146210008646E /* Assets.xcassets in Resources */, - 587B7545266922BF00DEF7E9 /* Localizable.strings in Resources */, + 5883A09E266A5AF7003EFFCB /* Localizable.strings in Resources */, 584789B8264D4A2A000E45FB /* old_le_root_cert.cer in Resources */, 584789BE264D4A2A000E45FB /* new_le_root_cert.cer in Resources */, 58E5BC2624FEB6DB00A53A76 /* AccountViewController.xib in Resources */, @@ -1002,12 +1027,14 @@ files = ( 58BFA5CC22A7CE1F00A6173D /* ApplicationConfiguration.swift in Sources */, 5891BF5125E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift in Sources */, + 587B75412668FD7800DEF7E9 /* AccountExpiryNotificationProvider.swift in Sources */, 58BA692E23E99EFF009DC256 /* Locking.swift in Sources */, 580EE21E24B3237F00F9D8A1 /* OutputOperation.swift in Sources */, 5840250122B1124600E4CFEC /* IPAddress+Codable.swift in Sources */, 5857F24724C882D700CF6F47 /* SelectLocationNavigationController.swift in Sources */, 580EE21224B322FC00F9D8A1 /* ResultOperation.swift in Sources */, 58BA693123EADA6A009DC256 /* SimulatorTunnelProvider.swift in Sources */, + 587B753B2666467500DEF7E9 /* NotificationBannerView.swift in Sources */, 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */, 58E6771F24ADFE7800AA26E7 /* SettingsNavigationController.swift in Sources */, 58A1AA8C23F5584C009F7EA6 /* ConnectionPanelView.swift in Sources */, @@ -1022,6 +1049,7 @@ 58ACF6492655365700ACE4B7 /* PreferencesViewController.swift in Sources */, 588D2FE3248AC27F00E313F7 /* AsyncOperation.swift in Sources */, 5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */, + 587B7536266528A200DEF7E9 /* NotificationManager.swift in Sources */, 58ACF64D26567A5000ACE4B7 /* CustomSwitch.swift in Sources */, 5850367F25A481D800A43E93 /* IPAddressRange+Codable.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, @@ -1097,6 +1125,7 @@ 58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */, 589AB4F7227B64450039131E /* BasicTableViewCell.swift in Sources */, 58B9EB152489139B00095626 /* DisplayChainedError.swift in Sources */, + 587B753F2668E5A700DEF7E9 /* NotificationContainerView.swift in Sources */, 5868BD33261DCD2600E6027F /* CustomSplitViewController.swift in Sources */, 58CCA01E2242787B004F3011 /* AccountTextField.swift in Sources */, 584592612639B4A200EF967F /* ConsentContentView.swift in Sources */, @@ -1122,6 +1151,7 @@ 58FAEDFF24533A7000CB0F5B /* KeychainReturn.swift in Sources */, 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */, 580EE20C24B3225F00F9D8A1 /* DelayOperation.swift in Sources */, + 587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/MullvadVPN/Account.swift b/ios/MullvadVPN/Account.swift index ec5f88108b..0115bcc7b3 100644 --- a/ios/MullvadVPN/Account.swift +++ b/ios/MullvadVPN/Account.swift @@ -198,6 +198,9 @@ class Account { let operation = AsyncBlockOperation { (finish) in DispatchQueue.main.async { self.removeFromPreferences() + self.observerList.forEach { (observer) in + observer.accountDidLogout(self) + } finish() } } @@ -223,9 +226,11 @@ class Account { sendRequest.addDidFinishBlockObserver(queue: .main) { (operation, result) in switch result { case .success(let response): - self.expiry = response.expires - self.observerList.forEach { (observer) in - observer.account(self, didUpdateExpiry: response.expires) + if self.expiry != response.expires { + self.expiry = response.expires + self.observerList.forEach { (observer) in + observer.account(self, didUpdateExpiry: response.expires) + } } case .failure(let error): @@ -287,7 +292,7 @@ extension Account: AppStorePaymentObserver { let operation = AsyncBlockOperation { (finish) in DispatchQueue.main.async { // Make sure that payment corresponds to the active account token - if self.token == accountToken { + if self.token == accountToken, self.expiry != newExpiry { self.expiry = newExpiry self.observerList.forEach { (observer) in observer.account(self, didUpdateExpiry: newExpiry) diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 50a57c4d97..0591fa0d79 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -8,6 +8,7 @@ import UIKit import StoreKit +import UserNotifications import Logging @UIApplicationMain @@ -29,6 +30,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private var splitViewController: CustomSplitViewController? private var selectLocationViewController: SelectLocationViewController? private var connectController: ConnectViewController? + private weak var settingsNavController: SettingsNavigationController? private var cachedRelays: CachedRelays? { didSet { @@ -40,6 +42,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private var relayConstraints: RelayConstraints? private let alertPresenter = AlertPresenter() + private let notificationManager = NotificationManager() + // MARK: - Application lifecycle func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -60,6 +64,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { SimulatorTunnelProvider.shared.delegate = simulatorTunnelProvider #endif + // Assign user notification center delegate + UNUserNotificationCenter.current().delegate = self + // Create an app window self.window = UIWindow(frame: UIScreen.main.bounds) @@ -149,6 +156,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { fatalError() } + notificationManager.delegate = connectController?.notificationController + notificationManager.notificationProviders = [ + AccountExpiryNotificationProvider() + ] + notificationManager.updateNotifications() + startPaymentQueueHandling() } @@ -336,14 +349,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate: RootContainerViewControllerDelegate { func rootContainerViewControllerShouldShowSettings(_ controller: RootContainerViewController, navigateTo route: SettingsNavigationRoute?, animated: Bool) { - let navController = makeSettingsNavigationController(route: route) - - // On iPad the login controller can be presented modally above the root container. - // in that case we have to use the presented controller to present the next modal. - if let presentedController = controller.presentedViewController { - presentedController.present(navController, animated: true) + // Check if settings controller is already presented. + if let settingsNavController = self.settingsNavController { + if let route = route { + settingsNavController.navigate(to: route, animated: animated) + } else { + settingsNavController.popToRootViewController(animated: animated) + } } else { - controller.present(navController, animated: true) + let navController = makeSettingsNavigationController(route: route) + + // On iPad the login controller can be presented modally above the root container. + // in that case we have to use the presented controller to present the next modal. + if let presentedController = controller.presentedViewController { + presentedController.present(navController, animated: true) + } else { + controller.present(navController, animated: true) + } + + // Save the reference for later. + self.settingsNavController = navController } } @@ -690,3 +715,26 @@ extension AppDelegate: UISplitViewControllerDelegate { } } + +// MARK: - UNUserNotificationCenterDelegate + +extension AppDelegate: UNUserNotificationCenterDelegate { + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + if response.notification.request.identifier == kAccountExpiryNotificationIdentifier, + response.actionIdentifier == UNNotificationDefaultActionIdentifier { + rootContainer?.showSettings(navigateTo: .account, animated: true) + } + + completionHandler() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + if #available(iOS 14.0, *) { + completionHandler([.list]) + } else { + completionHandler([]) + } + } + +} diff --git a/ios/MullvadVPN/Assets.xcassets/Palette/Contents.json b/ios/MullvadVPN/Assets.xcassets/Palette/Contents.json index da4a164c91..73c00596a7 100644 --- a/ios/MullvadVPN/Assets.xcassets/Palette/Contents.json +++ b/ios/MullvadVPN/Assets.xcassets/Palette/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -}
\ No newline at end of file +} diff --git a/ios/MullvadVPN/Assets.xcassets/Palette/Warning.colorset/Contents.json b/ios/MullvadVPN/Assets.xcassets/Palette/Warning.colorset/Contents.json new file mode 100644 index 0000000000..b47d81baa2 --- /dev/null +++ b/ios/MullvadVPN/Assets.xcassets/Palette/Warning.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.141", + "green" : "0.835", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/MullvadVPN/ConnectMainContentView.swift b/ios/MullvadVPN/ConnectMainContentView.swift index 7d9a08b5b7..1ddab593b2 100644 --- a/ios/MullvadVPN/ConnectMainContentView.swift +++ b/ios/MullvadVPN/ConnectMainContentView.swift @@ -29,8 +29,8 @@ class ConnectMainContentView: UIView { }() let secureLabel = makeBoldTextLabel(ofSize: 20) - let countryLabel = makeBoldTextLabel(ofSize: 34) let cityLabel = makeBoldTextLabel(ofSize: 34) + let countryLabel = makeBoldTextLabel(ofSize: 34) lazy var connectionPanel: ConnectionPanelView = { let view = ConnectionPanelView() @@ -108,9 +108,9 @@ class ConnectMainContentView: UIView { private func addSubviews() { mapView.frame = self.bounds addSubview(mapView) - addSubview(containerView) - [secureLabel, countryLabel, cityLabel, connectionPanel, buttonsStackView].forEach { containerView.addSubview($0) } + + [secureLabel, cityLabel, countryLabel, connectionPanel, buttonsStackView].forEach { containerView.addSubview($0) } NSLayoutConstraint.activate([ containerView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), @@ -121,15 +121,15 @@ class ConnectMainContentView: UIView { secureLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), secureLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - countryLabel.topAnchor.constraint(equalTo: secureLabel.bottomAnchor, constant: 8), - countryLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - countryLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - - cityLabel.topAnchor.constraint(equalTo: countryLabel.bottomAnchor, constant: 8), + cityLabel.topAnchor.constraint(equalTo: secureLabel.bottomAnchor, constant: 8), cityLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), cityLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - connectionPanel.topAnchor.constraint(equalTo: cityLabel.bottomAnchor, constant: 8), + countryLabel.topAnchor.constraint(equalTo: cityLabel.bottomAnchor, constant: 8), + countryLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + countryLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + + connectionPanel.topAnchor.constraint(equalTo: countryLabel.bottomAnchor, constant: 8), connectionPanel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), connectionPanel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), diff --git a/ios/MullvadVPN/ConnectViewController.swift b/ios/MullvadVPN/ConnectViewController.swift index df9dfded02..bceb172b34 100644 --- a/ios/MullvadVPN/ConnectViewController.swift +++ b/ios/MullvadVPN/ConnectViewController.swift @@ -25,10 +25,12 @@ protocol ConnectViewControllerDelegate: AnyObject { func connectViewControllerShouldReconnectTunnel(_ controller: ConnectViewController) } -class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainment, TunnelObserver, AccountObserver -{ +class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainment, TunnelObserver, AccountObserver { + weak var delegate: ConnectViewControllerDelegate? + let notificationController = NotificationController() + private let mainContentView: ConnectMainContentView = { let view = ConnectMainContentView(frame: UIScreen.main.bounds) view.translatesAutoresizingMaskIntoConstraints = false @@ -91,6 +93,7 @@ class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainmen addSubviews() setupMapView() updateLocation(animated: false) + addNotificationController() Account.shared.addObserver(self) } @@ -193,8 +196,8 @@ class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainmen mainContentView.connectionPanel.collapseButton.setTitle(connectionInfo.hostname, for: .normal) case .connecting, .disconnected, .disconnecting: - mainContentView.cityLabel.attributedText = attributedStringForLocation(string: " ") mainContentView.countryLabel.attributedText = attributedStringForLocation(string: " ") + mainContentView.cityLabel.attributedText = attributedStringForLocation(string: " ") mainContentView.connectionPanel.dataSource = nil mainContentView.connectionPanel.isHidden = true } @@ -268,6 +271,22 @@ class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainmen } } + private func addNotificationController() { + let notificationView = notificationController.view! + notificationView.translatesAutoresizingMaskIntoConstraints = false + + addChild(notificationController) + view.addSubview(notificationView) + notificationController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + notificationView.topAnchor.constraint(equalTo: view.topAnchor), + notificationView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + notificationView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + notificationView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + // MARK: - Actions @objc func handleConnectionPanelButton(_ sender: Any) { diff --git a/ios/MullvadVPN/NotificationBannerView.swift b/ios/MullvadVPN/NotificationBannerView.swift new file mode 100644 index 0000000000..3e857df849 --- /dev/null +++ b/ios/MullvadVPN/NotificationBannerView.swift @@ -0,0 +1,133 @@ +// +// NotificationBannerView.swift +// MullvadVPN +// +// Created by pronebird on 01/06/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +enum NotificationBannerStyle { + case success, warning, error + + fileprivate var color: UIColor { + switch self { + case .success: + return UIColor.InAppNotificationBanner.successIndicatorColor + case .warning: + return UIColor.InAppNotificationBanner.warningIndicatorColor + case .error: + return UIColor.InAppNotificationBanner.errorIndicatorColor + } + } +} + +class NotificationBannerView: UIView { + + private static let indicatorViewSize = CGSize(width: 12, height: 12) + + private let backgroundView: UIVisualEffectView = { + let effect = UIBlurEffect(style: .dark) + let visualEffectView = UIVisualEffectView(effect: effect) + visualEffectView.translatesAutoresizingMaskIntoConstraints = false + return visualEffectView + }() + + private let titleLabel: UILabel = { + let textLabel = UILabel() + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.font = UIFont.systemFont(ofSize: 17, weight: .bold) + textLabel.textColor = .white + textLabel.numberOfLines = 0 + textLabel.lineBreakMode = .byWordWrapping + return textLabel + }() + + private let bodyLabel: UILabel = { + let textLabel = UILabel() + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.font = UIFont.systemFont(ofSize: 17) + textLabel.textColor = UIColor(white: 1, alpha: 0.6) + textLabel.numberOfLines = 0 + textLabel.lineBreakMode = .byWordWrapping + return textLabel + }() + + private let indicatorView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .dangerColor + view.layer.cornerRadius = NotificationBannerView.indicatorViewSize.width * 0.5 + if #available(iOS 13.0, *) { + view.layer.cornerCurve = .circular + } + return view + }() + + private let wrapperView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.layoutMargins = UIMetrics.inAppBannerNotificationLayoutMargins + return view + }() + + var title: String? { + didSet { + titleLabel.text = title + } + } + + var body: String? { + didSet { + bodyLabel.text = body + } + } + + var style: NotificationBannerStyle = .error { + didSet { + indicatorView.backgroundColor = style.color + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + for subview in [titleLabel, bodyLabel, indicatorView] { + wrapperView.addSubview(subview) + } + + backgroundView.contentView.addSubview(wrapperView) + addSubview(backgroundView) + + NSLayoutConstraint.activate([ + backgroundView.topAnchor.constraint(equalTo: topAnchor), + backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + + wrapperView.topAnchor.constraint(equalTo: backgroundView.contentView.topAnchor), + wrapperView.leadingAnchor.constraint(equalTo: backgroundView.contentView.leadingAnchor), + wrapperView.trailingAnchor.constraint(equalTo: backgroundView.contentView.trailingAnchor), + wrapperView.bottomAnchor.constraint(equalTo: backgroundView.contentView.bottomAnchor), + + indicatorView.bottomAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor), + indicatorView.leadingAnchor.constraint(equalTo: wrapperView.layoutMarginsGuide.leadingAnchor), + indicatorView.widthAnchor.constraint(equalToConstant: Self.indicatorViewSize.width), + indicatorView.heightAnchor.constraint(equalToConstant: Self.indicatorViewSize.height), + + titleLabel.topAnchor.constraint(equalTo: wrapperView.layoutMarginsGuide.topAnchor), + titleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: indicatorView.trailingAnchor, multiplier: 1), + titleLabel.trailingAnchor.constraint(equalTo: wrapperView.layoutMarginsGuide.trailingAnchor), + + bodyLabel.topAnchor.constraint(equalToSystemSpacingBelow: titleLabel.bottomAnchor, multiplier: 1), + bodyLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + bodyLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + bodyLabel.bottomAnchor.constraint(equalTo: wrapperView.layoutMarginsGuide.bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/ios/MullvadVPN/NotificationContainerView.swift b/ios/MullvadVPN/NotificationContainerView.swift new file mode 100644 index 0000000000..1a4ee8a5d5 --- /dev/null +++ b/ios/MullvadVPN/NotificationContainerView.swift @@ -0,0 +1,22 @@ +// +// NotificationContainerView.swift +// MullvadVPN +// +// Created by pronebird on 03/06/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class NotificationContainerView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hitView = super.hitTest(point, with: event) + + // Pass through taps if no subview was touched + if hitView == self { + return nil + } else { + return hitView + } + } +} diff --git a/ios/MullvadVPN/NotificationController.swift b/ios/MullvadVPN/NotificationController.swift new file mode 100644 index 0000000000..6bb875a8f8 --- /dev/null +++ b/ios/MullvadVPN/NotificationController.swift @@ -0,0 +1,150 @@ +// +// NotificationController.swift +// MullvadVPN +// +// Created by pronebird on 01/06/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +struct InAppNotificationDescriptor: Equatable { + var identifier: String + var style: NotificationBannerStyle + var title: String + var body: String +} + +class NotificationController: UIViewController, NotificationManagerDelegate { + let bannerView: NotificationBannerView = { + let bannerView = NotificationBannerView() + bannerView.translatesAutoresizingMaskIntoConstraints = false + bannerView.isHidden = true + bannerView.isAccessibilityElement = true + return bannerView + }() + + private var showBannerConstraint: NSLayoutConstraint? + private var hideBannerConstraint: NSLayoutConstraint? + + private(set) var showsBanner = false + private var lastNotification: InAppNotificationDescriptor? + + override func loadView() { + view = NotificationContainerView(frame: UIScreen.main.bounds) + } + + override func viewDidLoad() { + super.viewDidLoad() + + showBannerConstraint = bannerView.topAnchor.constraint(equalTo: view.topAnchor) + hideBannerConstraint = bannerView.bottomAnchor.constraint(equalTo: view.topAnchor) + + view.addSubview(bannerView) + + let verticalConstraint = showsBanner ? showBannerConstraint : hideBannerConstraint + NSLayoutConstraint.activate([ + verticalConstraint!, + bannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updateAccessibilityFrame() + } + + func toggleBanner(show: Bool, animated: Bool, completion: (() -> Void)? = nil) { + guard showsBanner != show else { + completion?() + return + } + + showsBanner = show + + if show { + // Make sure to lay out the banner before animating its appearance to avoid undesired horizontal expansion + // animation. + view.layoutIfNeeded() + + bannerView.isHidden = false + hideBannerConstraint?.isActive = false + showBannerConstraint?.isActive = true + } else { + showBannerConstraint?.isActive = false + hideBannerConstraint?.isActive = true + } + + let finish = { + if !show { + self.bannerView.isHidden = true + } + completion?() + } + + if animated { + let timing = UISpringTimingParameters(dampingRatio: 0.7, initialVelocity: CGVector(dx: 0, dy: 1)) + let animator = UIViewPropertyAnimator(duration: 0.8, timingParameters: timing) + animator.isInterruptible = false + animator.addAnimations { + self.view.layoutIfNeeded() + } + animator.addCompletion { _ in + finish() + } + animator.startAnimation() + } else { + view.layoutIfNeeded() + finish() + } + } + + func setNotification(_ notification: InAppNotificationDescriptor, animated: Bool) { + guard lastNotification != notification else { return } + + lastNotification = notification + + bannerView.title = notification.title + bannerView.body = notification.body + bannerView.style = notification.style + bannerView.accessibilityLabel = "\(notification.title)\n\(notification.body)" + + if animated { + let animator = UIViewPropertyAnimator(duration: 0.25, timingParameters: UICubicTimingParameters(animationCurve: .easeOut)) + animator.addAnimations { + self.view.layoutIfNeeded() + } + animator.startAnimation() + } + + // Do not emit the .layoutChanged unless the banner is focused to avoid capturing the voice over focus. + if bannerView.accessibilityElementIsFocused() { + UIAccessibility.post(notification: .layoutChanged, argument: bannerView) + } + } + + private func updateAccessibilityFrame() { + let layoutFrame = bannerView.layoutMarginsGuide.layoutFrame + bannerView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(layoutFrame, in: view) + } + + private func setNotifications(_ notifications: [InAppNotificationDescriptor], animated: Bool) { + let nextNotification = notifications.first + + if let notification = nextNotification { + setNotification(notification, animated: showsBanner) + toggleBanner(show: true, animated: true) + } else { + toggleBanner(show: false, animated: animated) + } + } + + // MARK: - NotificationManagerDelegate + + func notificationManagerDidUpdateInAppNotifications(_ manager: NotificationManager, notifications: [InAppNotificationDescriptor]) { + setNotifications(notifications, animated: true) + } + +} diff --git a/ios/MullvadVPN/NotificationManager.swift b/ios/MullvadVPN/NotificationManager.swift new file mode 100644 index 0000000000..cf5f2c1450 --- /dev/null +++ b/ios/MullvadVPN/NotificationManager.swift @@ -0,0 +1,222 @@ +// +// NotificationManager.swift +// MullvadVPN +// +// Created by pronebird on 31/05/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UserNotifications +import Logging +import UIKit + +protocol NotificationManagerDelegate: AnyObject { + func notificationManagerDidUpdateInAppNotifications(_ manager: NotificationManager, notifications: [InAppNotificationDescriptor]) +} + +fileprivate protocol NotificationProviderDelegate: AnyObject { + func notificationProviderDidInvalidate(_ notificationProvider: NotificationProvider) +} + +class NotificationProvider { + fileprivate weak var delegate: NotificationProviderDelegate? + + var identifier: String { + return "default" + } + + func invalidate() { + let executor = { () -> Void in + self.delegate?.notificationProviderDidInvalidate(self) + } + + if Thread.isMainThread { + executor() + } else { + DispatchQueue.main.async(execute: executor) + } + } +} + +protocol SystemNotificationProvider { + /// Unique provider identifier used to identify requests + var identifier: String { get } + + /// Notification request if available, otherwise `nil` + var notificationRequest: UNNotificationRequest? { get } + + /// Whether any pending requests should be removed + var shouldRemovePendingRequests: Bool { get } + + /// Whether any delivered requests should be removed + var shouldRemoveDeliveredRequests: Bool { get } +} + +protocol InAppNotificationProvider { + /// Unique provider identifier used to identify notifications + var identifier: String { get } + + /// In-app notification descriptor + var notificationDescriptor: InAppNotificationDescriptor? { get } +} + +class NotificationManager: NotificationProviderDelegate { + + private lazy var logger = Logger(label: "NotificationManager") + + var notificationProviders: [NotificationProvider] = [] { + willSet { + assert(Thread.isMainThread) + } + + didSet { + for notificationProvider in notificationProviders { + notificationProvider.delegate = self + } + } + } + + private var inAppNotificationDescriptors: [InAppNotificationDescriptor] = [] + + weak var delegate: NotificationManagerDelegate? { + willSet { + assert(Thread.isMainThread) + } + } + + func updateNotifications() { + assert(Thread.isMainThread) + + var newSystemNotificationRequests = [UNNotificationRequest]() + var newInAppNotificationDescriptors = [InAppNotificationDescriptor]() + var pendingRequestIdentifiersToRemove = [String]() + var deliveredRequestIdentifiersToRemove = [String]() + + for notificationProvider in notificationProviders { + if let notificationProvider = notificationProvider as? SystemNotificationProvider { + if notificationProvider.shouldRemovePendingRequests { + pendingRequestIdentifiersToRemove.append(notificationProvider.identifier) + } + + if notificationProvider.shouldRemoveDeliveredRequests { + deliveredRequestIdentifiersToRemove.append(notificationProvider.identifier) + } + + if let request = notificationProvider.notificationRequest { + newSystemNotificationRequests.append(request) + } + } + + if let notificationProvider = notificationProvider as? InAppNotificationProvider { + if let descriptor = notificationProvider.notificationDescriptor { + newInAppNotificationDescriptors.append(descriptor) + } + } + } + + let notificationCenter = UNUserNotificationCenter.current() + notificationCenter.removePendingNotificationRequests(withIdentifiers: pendingRequestIdentifiersToRemove) + notificationCenter.removeDeliveredNotifications(withIdentifiers: deliveredRequestIdentifiersToRemove) + + requestNotificationPermissions { (granted) in + guard granted else { return } + + for newRequest in newSystemNotificationRequests { + notificationCenter.add(newRequest) { (error) in + if let error = error { + self.logger.error("Failed to add notification request with identifier \(newRequest.identifier). Error: \(error.localizedDescription)") + } + } + } + } + + inAppNotificationDescriptors = newInAppNotificationDescriptors + + delegate?.notificationManagerDidUpdateInAppNotifications(self, notifications: newInAppNotificationDescriptors) + } + + // MARK: - Private + + private func requestNotificationPermissions(completion: @escaping (Bool) -> Void) { + let authorizationOptions: UNAuthorizationOptions = [.alert, .sound, .provisional] + let userNotificationCenter = UNUserNotificationCenter.current() + + userNotificationCenter.getNotificationSettings { (notificationSettings) in + switch notificationSettings.authorizationStatus { + case .notDetermined: + userNotificationCenter.requestAuthorization(options: authorizationOptions) { (granted, error) in + if let error = error { + self.logger.error("Failed to obtain user notifications authorization: \(error.localizedDescription)") + } + completion(granted) + } + + case .authorized, .provisional: + completion(true) + + case .denied, .ephemeral: + fallthrough + + @unknown default: + completion(false) + } + } + } + + // MARK: - NotificationProviderDelegate + + func notificationProviderDidInvalidate(_ notificationProvider: NotificationProvider) { + assert(Thread.isMainThread) + + // Invalidate system notification + if let notificationProvider = notificationProvider as? SystemNotificationProvider { + let notificationCenter = UNUserNotificationCenter.current() + + if notificationProvider.shouldRemovePendingRequests { + notificationCenter.removePendingNotificationRequests(withIdentifiers: [notificationProvider.identifier]) + } + + if notificationProvider.shouldRemoveDeliveredRequests { + notificationCenter.removeDeliveredNotifications(withIdentifiers: [notificationProvider.identifier]) + } + + if let request = notificationProvider.notificationRequest { + requestNotificationPermissions { (granted) in + guard granted else { return } + + notificationCenter.add(request) { (error) in + if let error = error { + self.logger.error("Failed to add notification request with identifier \(request.identifier). Error: \(error.localizedDescription)") + } + } + } + } + } + + // Invalidate in-app notification + if let notificationProvider = notificationProvider as? InAppNotificationProvider { + var newNotificationDescriptors = inAppNotificationDescriptors + + if let replaceNotificationDescriptor = notificationProvider.notificationDescriptor { + newNotificationDescriptors = notificationProviders.compactMap { (notificationProvider) -> InAppNotificationDescriptor? in + if replaceNotificationDescriptor.identifier == notificationProvider.identifier { + return replaceNotificationDescriptor + } else { + return inAppNotificationDescriptors.first { (descriptor) in + return descriptor.identifier == notificationProvider.identifier + } + } + } + } else { + newNotificationDescriptors.removeAll { (descriptor) in + return descriptor.identifier == notificationProvider.identifier + } + } + + inAppNotificationDescriptors = newNotificationDescriptors + + delegate?.notificationManagerDidUpdateInAppNotifications(self, notifications: inAppNotificationDescriptors) + } + } +} diff --git a/ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift b/ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift new file mode 100644 index 0000000000..bcf6a0c954 --- /dev/null +++ b/ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift @@ -0,0 +1,123 @@ +// +// AccountExpiryNotificationProvider.swift +// MullvadVPN +// +// Created by pronebird on 03/06/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UserNotifications + +let kAccountExpiryNotificationIdentifier = "net.mullvad.MullvadVPN.AccountExpiryNotification" +let kAccountExpiryDefaultTriggerInterval = 3 + +class AccountExpiryNotificationProvider: NotificationProvider, SystemNotificationProvider, InAppNotificationProvider, AccountObserver { + private var accountExpiry: Date? + + /// Interval prior to expiry used to calculate when to trigger notifications. + private let triggerInterval: Int + + override var identifier: String { + return kAccountExpiryNotificationIdentifier + } + + init(triggerInterval: Int = kAccountExpiryDefaultTriggerInterval) { + self.triggerInterval = triggerInterval + + super.init() + + accountExpiry = Account.shared.expiry + Account.shared.addObserver(self) + } + + private var trigger: UNNotificationTrigger? { + guard let accountExpiry = accountExpiry else { return nil } + + // Subtract 3 days from expiry date + guard let triggerDate = Calendar.current.date(byAdding: .day, value: -triggerInterval, to: accountExpiry) else { return nil } + + // Do not produce notification if less than 3 days left till expiry + guard triggerDate > Date() else { return nil } + + // Create date components for calendar trigger + let dateComponents = Calendar.current.dateComponents([.second, .minute, .hour, .day, .month, .year], from: triggerDate) + + return UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false) + } + + private var shouldRemovePendingOrDeliveredRequests: Bool { + return accountExpiry == nil + } + + // MARK: - SystemNotificationProvider + + var notificationRequest: UNNotificationRequest? { + guard let trigger = trigger else { return nil } + + let content = UNMutableNotificationContent() + content.title = NSString.localizedUserNotificationString(forKey: "ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_TITLE", arguments: nil) + content.body = NSString.localizedUserNotificationString(forKey: "ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY", arguments: nil) + content.sound = UNNotificationSound.default + + return UNNotificationRequest( + identifier: kAccountExpiryNotificationIdentifier, + content: content, + trigger: trigger + ) + } + + var shouldRemovePendingRequests: Bool { + // Remove pending notifications when account expiry is not set (user logged out) + return shouldRemovePendingOrDeliveredRequests + } + + var shouldRemoveDeliveredRequests: Bool { + // Remove delivered notifications when account expiry is not set (user logged out) + return shouldRemovePendingOrDeliveredRequests + } + + // MARK: - InAppNotificationProvider + + var notificationDescriptor: InAppNotificationDescriptor? { + guard let accountExpiry = accountExpiry else { return nil } + + // Subtract 3 days from expiry date + guard let triggerDate = Calendar.current.date(byAdding: .day, value: -triggerInterval, to: accountExpiry) else { return nil } + + // Only produce in-app notification within the last 3 days till expiry + let now = Date() + guard triggerDate < now && now < accountExpiry else { return nil } + + // Format the remaining duration + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.allowedUnits = [.minute, .hour, .day] + formatter.maximumUnitCount = 1 + + guard let duration = formatter.string(from: now, to: accountExpiry) else { return nil } + + return InAppNotificationDescriptor( + identifier: self.identifier, + style: .warning, + title: NSLocalizedString("ACCOUNT_EXPIRY_INAPP_NOTIFICATION_TITLE", comment: ""), + body: String(format: NSLocalizedString("ACCOUNT_EXPIRY_INAPP_NOTIFICATION_BODY", comment: ""), duration) + ) + } + + func account(_ account: Account, didUpdateExpiry expiry: Date) { + self.accountExpiry = expiry + invalidate() + } + + func account(_ account: Account, didLoginWithToken token: String, expiry: Date) { + self.accountExpiry = expiry + invalidate() + } + + func accountDidLogout(_ account: Account) { + self.accountExpiry = nil + invalidate() + } + +} diff --git a/ios/MullvadVPN/RootContainerViewController.swift b/ios/MullvadVPN/RootContainerViewController.swift index bfb8e5843c..dc2cae1025 100644 --- a/ios/MullvadVPN/RootContainerViewController.swift +++ b/ios/MullvadVPN/RootContainerViewController.swift @@ -103,6 +103,8 @@ class RootContainerViewController: UIViewController { addTransitionView() addHeaderBarView() updateHeaderBarBackground() + + accessibilityElements = [headerBarView, transitionContainer] } override func viewDidLayoutSubviews() { diff --git a/ios/MullvadVPN/SettingsNavigationController.swift b/ios/MullvadVPN/SettingsNavigationController.swift index 0bb586f8b6..f8959c927d 100644 --- a/ios/MullvadVPN/SettingsNavigationController.swift +++ b/ios/MullvadVPN/SettingsNavigationController.swift @@ -74,20 +74,29 @@ class SettingsNavigationController: CustomNavigationController, SettingsViewCont // MARK: - Navigation func navigate(to route: SettingsNavigationRoute, animated: Bool) { + let nextViewController = makeViewController(for: route) + if let rootController = self.viewControllers.first, viewControllers.count > 1 { + setViewControllers([rootController, nextViewController], animated: animated) + } else { + pushViewController(nextViewController, animated: animated) + } + } + + private func makeViewController(for route: SettingsNavigationRoute) -> UIViewController { switch route { case .account: let controller = AccountViewController() controller.delegate = self - pushViewController(controller, animated: animated) + return controller case .preferences: - pushViewController(PreferencesViewController(), animated: animated) + return PreferencesViewController() case .wireguardKeys: - pushViewController(WireguardKeysViewController(), animated: animated) + return WireguardKeysViewController() case .problemReport: - pushViewController(ProblemReportViewController(), animated: animated) + return ProblemReportViewController() } } diff --git a/ios/MullvadVPN/UIColor+Palette.swift b/ios/MullvadVPN/UIColor+Palette.swift index edcc13d2b7..8b442c32ca 100644 --- a/ios/MullvadVPN/UIColor+Palette.swift +++ b/ios/MullvadVPN/UIColor+Palette.swift @@ -84,10 +84,17 @@ extension UIColor { static let dividerColor = secondaryColor } + enum InAppNotificationBanner { + static let errorIndicatorColor = dangerColor + static let successIndicatorColor = successColor + static let warningIndicatorColor = warningColor + } + // Common colors static let primaryColor = namedColor("Primary") static let secondaryColor = namedColor("Secondary") static let dangerColor = namedColor("Danger") + static let warningColor = namedColor("Warning") static let successColor = namedColor("Success") } diff --git a/ios/MullvadVPN/UIMetrics.swift b/ios/MullvadVPN/UIMetrics.swift index 016495ad27..fd7b80728b 100644 --- a/ios/MullvadVPN/UIMetrics.swift +++ b/ios/MullvadVPN/UIMetrics.swift @@ -15,6 +15,9 @@ extension UIMetrics { /// Common layout margins for content presentation static var contentLayoutMargins = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) + /// Layout margins for in-app notification banner presentation + static var inAppBannerNotificationLayoutMargins = UIEdgeInsets(top: 16, left: 24, bottom: 16, right: 24) + /// Maximum width of the split view content container on iPad static var maximumSplitViewContentContainerWidth: CGFloat = 810 * 0.7 diff --git a/ios/MullvadVPN/en.lproj/Localizable.strings b/ios/MullvadVPN/en.lproj/Localizable.strings index df0fac65e3..0e83a02148 100644 --- a/ios/MullvadVPN/en.lproj/Localizable.strings +++ b/ios/MullvadVPN/en.lproj/Localizable.strings @@ -11,3 +11,8 @@ "SELECT_LOCATION_COLLAPSE_ACCESSIBILITY_ACTION" = "Collapse location"; "SELECT_LOCATION_EXPAND_ACCESSIBILITY_ACTION" = "Expand location"; + +"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_TITLE" = "Account credit expires soon"; +"ACCOUNT_EXPIRY_SYSTEM_NOTIFICATION_BODY" = "Account credit expires in 3 days. Buy more credit."; +"ACCOUNT_EXPIRY_INAPP_NOTIFICATION_TITLE" = "ACCOUNT CREDIT EXPIRES SOON"; +"ACCOUNT_EXPIRY_INAPP_NOTIFICATION_BODY" = "%@ left. Buy more credit."; |
