summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2021-06-07 17:43:29 +0200
committerAndrej Mihajlov <and@mullvad.net>2021-06-11 12:30:26 +0200
commit072999382be7305db4b7deaf0c61929cdf77aebb (patch)
tree4ff5186b917d12a087a0a6672f7fc35543ecd14a
parentbaa7c4f569e048f92690a7c557d10414b5db3698 (diff)
downloadmullvadvpn-072999382be7305db4b7deaf0c61929cdf77aebb.tar.xz
mullvadvpn-072999382be7305db4b7deaf0c61929cdf77aebb.zip
Add account expiry notification
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj34
-rw-r--r--ios/MullvadVPN/Account.swift13
-rw-r--r--ios/MullvadVPN/AppDelegate.swift62
-rw-r--r--ios/MullvadVPN/Assets.xcassets/Palette/Contents.json6
-rw-r--r--ios/MullvadVPN/Assets.xcassets/Palette/Warning.colorset/Contents.json20
-rw-r--r--ios/MullvadVPN/ConnectMainContentView.swift18
-rw-r--r--ios/MullvadVPN/ConnectViewController.swift25
-rw-r--r--ios/MullvadVPN/NotificationBannerView.swift133
-rw-r--r--ios/MullvadVPN/NotificationContainerView.swift22
-rw-r--r--ios/MullvadVPN/NotificationController.swift150
-rw-r--r--ios/MullvadVPN/NotificationManager.swift222
-rw-r--r--ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift123
-rw-r--r--ios/MullvadVPN/RootContainerViewController.swift2
-rw-r--r--ios/MullvadVPN/SettingsNavigationController.swift17
-rw-r--r--ios/MullvadVPN/UIColor+Palette.swift7
-rw-r--r--ios/MullvadVPN/UIMetrics.swift3
-rw-r--r--ios/MullvadVPN/en.lproj/Localizable.strings5
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.";