From ff096da69dd0298fc1cba1fdd7c87efad5c01872 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Tue, 31 Oct 2023 18:51:04 +0100 Subject: [PATCH] Add toggling of NetP Notifications to iOS (#2112) Co-authored-by: Sam Symons --- Core/UserDefaults+NetworkProtection.swift | 34 +++++++ DuckDuckGo.xcodeproj/project.pbxproj | 30 ++++++- .../xcshareddata/swiftpm/Package.resolved | 4 +- DuckDuckGo/DuckDuckGo.entitlements | 1 + ...orkProtectionConvenienceInitialisers.swift | 10 +++ DuckDuckGo/NetworkProtectionStatusView.swift | 9 +- .../NetworkProtectionStatusViewModel.swift | 6 ++ ...etworkProtectionVPNNotificationsView.swift | 90 +++++++++++++++++++ ...kProtectionVPNNotificationsViewModel.swift | 79 ++++++++++++++++ ...NotificationsAuthorizationController.swift | 87 ++++++++++++++++++ DuckDuckGo/UIApplicationExtension.swift | 41 +++++++++ DuckDuckGo/UserText.swift | 4 + DuckDuckGo/en.lproj/Localizable.strings | 12 +++ ...etworkProtectionPacketTunnelProvider.swift | 11 ++- .../PacketTunnelProvider.entitlements | 1 + 15 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 Core/UserDefaults+NetworkProtection.swift create mode 100644 DuckDuckGo/NetworkProtectionVPNNotificationsView.swift create mode 100644 DuckDuckGo/NetworkProtectionVPNNotificationsViewModel.swift create mode 100644 DuckDuckGo/NotificationsAuthorizationController.swift create mode 100644 DuckDuckGo/UIApplicationExtension.swift diff --git a/Core/UserDefaults+NetworkProtection.swift b/Core/UserDefaults+NetworkProtection.swift new file mode 100644 index 0000000000..fb9def1004 --- /dev/null +++ b/Core/UserDefaults+NetworkProtection.swift @@ -0,0 +1,34 @@ +// +// UserDefaults+NetworkProtection.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Foundation + +public extension UserDefaults { + static var networkProtectionGroupDefaults: UserDefaults { + let suiteName = "\(Global.groupIdPrefix).netp" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create netP UserDefaults") + } + return defaults + } +} + +#endif diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index a18515068d..fac3670a7d 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -765,6 +765,11 @@ EE8594992A44791C008A6D06 /* NetworkProtectionTunnelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE8594982A44791C008A6D06 /* NetworkProtectionTunnelController.swift */; }; EE8E568A2A56BCE400F11DCA /* NetworkProtection in Frameworks */ = {isa = PBXBuildFile; productRef = EE8E56892A56BCE400F11DCA /* NetworkProtection */; }; EE9D68D12AE00CF300B55EF4 /* NetworkProtectionVPNSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE9D68D02AE00CF300B55EF4 /* NetworkProtectionVPNSettingsView.swift */; }; + EE9D68D52AE1526600B55EF4 /* NetworkProtectionVPNNotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE9D68D42AE1526600B55EF4 /* NetworkProtectionVPNNotificationsView.swift */; }; + EE9D68D82AE15AD600B55EF4 /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE9D68D72AE15AD600B55EF4 /* UIApplicationExtension.swift */; }; + EE9D68DA2AE1659F00B55EF4 /* NetworkProtectionVPNNotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE9D68D92AE1659F00B55EF4 /* NetworkProtectionVPNNotificationsViewModel.swift */; }; + EE9D68DC2AE16AE100B55EF4 /* NotificationsAuthorizationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE9D68DB2AE16AE100B55EF4 /* NotificationsAuthorizationController.swift */; }; + EE9D68DE2AE2A65600B55EF4 /* UserDefaults+NetworkProtection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE9D68DD2AE2A65600B55EF4 /* UserDefaults+NetworkProtection.swift */; }; EEDFE2DA2AC6ED4F00F0E19C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = EEDFE2DC2AC6ED4F00F0E19C /* Localizable.strings */; }; EEEB80A32A421CE600386378 /* NetworkProtectionPacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEEB80A22A421CE600386378 /* NetworkProtectionPacketTunnelProvider.swift */; }; EEF0F8CC2ABC832300630031 /* NetworkProtectionDebugFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF0F8CB2ABC832200630031 /* NetworkProtectionDebugFeatures.swift */; }; @@ -2333,6 +2338,11 @@ EE7A92862AC6DE4700832A36 /* NetworkProtectionNotificationIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionNotificationIdentifier.swift; sourceTree = ""; }; EE8594982A44791C008A6D06 /* NetworkProtectionTunnelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionTunnelController.swift; sourceTree = ""; }; EE9D68D02AE00CF300B55EF4 /* NetworkProtectionVPNSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionVPNSettingsView.swift; sourceTree = ""; }; + EE9D68D42AE1526600B55EF4 /* NetworkProtectionVPNNotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionVPNNotificationsView.swift; sourceTree = ""; }; + EE9D68D72AE15AD600B55EF4 /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; + EE9D68D92AE1659F00B55EF4 /* NetworkProtectionVPNNotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionVPNNotificationsViewModel.swift; sourceTree = ""; }; + EE9D68DB2AE16AE100B55EF4 /* NotificationsAuthorizationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsAuthorizationController.swift; sourceTree = ""; }; + EE9D68DD2AE2A65600B55EF4 /* UserDefaults+NetworkProtection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+NetworkProtection.swift"; sourceTree = ""; }; EEB8FDB92A990AEE00EBEDCF /* Configuration-Alpha.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Configuration-Alpha.xcconfig"; path = "Configuration/Configuration-Alpha.xcconfig"; sourceTree = ""; }; EEDFE2DB2AC6ED4F00F0E19C /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; EEDFE2DD2AC6ED5B00F0E19C /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; @@ -4346,6 +4356,7 @@ children = ( EE0153E02A6EABE0002A8B26 /* NetworkProtectionConvenienceInitialisers.swift */, EE458D0C2AB1DA4600FC651A /* EventMapping+NetworkProtectionError.swift */, + EE9D68DB2AE16AE100B55EF4 /* NotificationsAuthorizationController.swift */, ); name = Helpers; sourceTree = ""; @@ -4409,6 +4420,7 @@ isa = PBXGroup; children = ( EE7A92862AC6DE4700832A36 /* NetworkProtectionNotificationIdentifier.swift */, + EE9D68DD2AE2A65600B55EF4 /* UserDefaults+NetworkProtection.swift */, ); name = NetworkProtection; sourceTree = ""; @@ -4421,9 +4433,19 @@ name = VPNSettings; sourceTree = ""; }; + EE9D68D62AE1527F00B55EF4 /* VPNNotifications */ = { + isa = PBXGroup; + children = ( + EE9D68D42AE1526600B55EF4 /* NetworkProtectionVPNNotificationsView.swift */, + EE9D68D92AE1659F00B55EF4 /* NetworkProtectionVPNNotificationsViewModel.swift */, + ); + name = VPNNotifications; + sourceTree = ""; + }; EECD94B22A28B8580085C66E /* NetworkProtection */ = { isa = PBXGroup; children = ( + EE9D68D62AE1527F00B55EF4 /* VPNNotifications */, EE9D68CF2AE00CE000B55EF4 /* VPNSettings */, EE458D122ABB651500FC651A /* Debug */, EE0153E22A6FE031002A8B26 /* Root */, @@ -5098,6 +5120,7 @@ F1DE78591E5CD2A70058895A /* UIViewExtension.swift */, F1F5337B1F26A9EF00D80D4F /* UserText.swift */, 986DA94924884B18004A7E39 /* WebViewTransition.swift */, + EE9D68D72AE15AD600B55EF4 /* UIApplicationExtension.swift */, ); name = UserInterface; sourceTree = ""; @@ -6104,6 +6127,7 @@ F1DE78581E5CAE350058895A /* TabViewGridCell.swift in Sources */, 984D035824ACCC6F0066CFB8 /* TabViewListCell.swift in Sources */, B6BA95C328891E33004ABA20 /* BrowsingMenuAnimator.swift in Sources */, + EE9D68DC2AE16AE100B55EF4 /* NotificationsAuthorizationController.swift in Sources */, AA3D854923DA1DFB00788410 /* AppIcon.swift in Sources */, 8590CB612684D0600089F6BF /* CookieDebugViewController.swift in Sources */, 319A37152829A55F0079FBCE /* AutofillListItemTableViewCell.swift in Sources */, @@ -6125,6 +6149,7 @@ 1E8AD1C727BE9B2900ABA377 /* DownloadsListDataSource.swift in Sources */, 3157B43527F497F50042D3D7 /* SaveLoginViewController.swift in Sources */, 853C5F6121C277C7001F7A05 /* global.swift in Sources */, + EE9D68D82AE15AD600B55EF4 /* UIApplicationExtension.swift in Sources */, F13B4BD31F1822C700814661 /* Tab.swift in Sources */, F1BE54581E69DE1000FCF649 /* TutorialSettings.swift in Sources */, 1EE52ABB28FB1D6300B750C1 /* UIImageExtension.swift in Sources */, @@ -6146,6 +6171,7 @@ C1B7B529289420830098FD6A /* RemoteMessaging.xcdatamodeld in Sources */, 986B16C425E92DF0007D23E8 /* BrowsingMenuViewController.swift in Sources */, 988AC355257E47C100793C64 /* RequeryLogic.swift in Sources */, + EE9D68D52AE1526600B55EF4 /* NetworkProtectionVPNNotificationsView.swift in Sources */, 1E4F4A5A297193DE00625985 /* MainViewController+CookiesManaged.swift in Sources */, 8586A10D24CBA7070049720E /* FindInPageActivity.swift in Sources */, 1E1626072968413B0004127F /* ViewExtension.swift in Sources */, @@ -6430,6 +6456,7 @@ 8586A11024CCCD040049720E /* TabsBarViewController.swift in Sources */, F1D796F41E7C2A410019D451 /* BookmarksDelegate.swift in Sources */, C1B7B52428941F2A0098FD6A /* RemoteMessageRequest.swift in Sources */, + EE9D68DA2AE1659F00B55EF4 /* NetworkProtectionVPNNotificationsViewModel.swift in Sources */, 1E8AD1D727C2E24E00ABA377 /* DownloadsListRowViewModel.swift in Sources */, C1B0F6422AB08BE9001EAF05 /* MockPrivacyConfiguration.swift in Sources */, 1E865AF0272042DB001C74F3 /* TextSizeSettingsViewController.swift in Sources */, @@ -6689,6 +6716,7 @@ CB2A7EF4285383B300885F67 /* AppLastCompiledRulesStore.swift in Sources */, 4B75EA9226A266CB00018634 /* PrintingUserScript.swift in Sources */, 37445F972A155F7C0029F789 /* SyncDataProviders.swift in Sources */, + EE9D68DE2AE2A65600B55EF4 /* UserDefaults+NetworkProtection.swift in Sources */, CB258D1F29A52B2500DEBA24 /* Configuration.swift in Sources */, 9847C00027A2DDBB00DB07AA /* AppPrivacyConfigurationDataProvider.swift in Sources */, F143C3281E4A9A0E00CFDE3A /* StringExtension.swift in Sources */, @@ -8968,7 +8996,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 82.0.2; + version = 82.1.0; }; }; C14882EB27F211A000D59F0C /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 525be5fff2..0836aa38a9 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/DuckDuckGo/BrowserServicesKit", "state": { "branch": null, - "revision": "8768193257dd1f461218ed2a8d7893156bde4bda", - "version": "82.0.2" + "revision": "71e916d070cedcba9ccb3ce9431797260bf5cbea", + "version": "82.1.0" } }, { diff --git a/DuckDuckGo/DuckDuckGo.entitlements b/DuckDuckGo/DuckDuckGo.entitlements index 3b229351b9..82bd4ed6cd 100644 --- a/DuckDuckGo/DuckDuckGo.entitlements +++ b/DuckDuckGo/DuckDuckGo.entitlements @@ -15,6 +15,7 @@ $(GROUP_ID_PREFIX).statistics $(GROUP_ID_PREFIX).database $(GROUP_ID_PREFIX).apptp + $(GROUP_ID_PREFIX).netp diff --git a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift index 248b52aa81..0587a405b8 100644 --- a/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift +++ b/DuckDuckGo/NetworkProtectionConvenienceInitialisers.swift @@ -58,4 +58,14 @@ extension NetworkProtectionCodeRedemptionCoordinator { } } +extension NetworkProtectionVPNNotificationsViewModel { + convenience init() { + let notificationsSettingsStore = NetworkProtectionNotificationsSettingsUserDefaultsStore(userDefaults: .networkProtectionGroupDefaults) + self.init( + notificationsAuthorization: NotificationsAuthorizationController(), + notificationsSettingsStore: notificationsSettingsStore + ) + } +} + #endif diff --git a/DuckDuckGo/NetworkProtectionStatusView.swift b/DuckDuckGo/NetworkProtectionStatusView.swift index c7b1e13dcf..de8c854726 100644 --- a/DuckDuckGo/NetworkProtectionStatusView.swift +++ b/DuckDuckGo/NetworkProtectionStatusView.swift @@ -40,9 +40,12 @@ struct NetworkProtectionStatusView: View { } settings() } - .animation(.default, value: statusModel.shouldShowError) .padding(.top, statusModel.error == nil ? 0 : -20) - .animation(.default, value: statusModel.shouldShowConnectionDetails) + .if(statusModel.animationsOn, transform: { + $0 + .animation(.default, value: statusModel.shouldShowConnectionDetails) + .animation(.default, value: statusModel.shouldShowError) + }) .applyInsetGroupedListStyle() .navigationTitle(UserText.netPNavTitle) } @@ -134,7 +137,7 @@ struct NetworkProtectionStatusView: View { NavigationLink(UserText.netPVPNSettingsTitle, destination: NetworkProtectionVPNSettingsView()) .font(.system(size: 16)) .foregroundColor(.textPrimary) - NavigationLink(UserText.netPVPNNotificationsTitle, destination: Text("Coming soon!")) + NavigationLink(UserText.netPVPNNotificationsTitle, destination: NetworkProtectionVPNNotificationsView()) .font(.system(size: 16)) .foregroundColor(.textPrimary) } header: { diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index 3ee41e6b67..dc8924c38b 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -65,6 +65,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { @Published public var location: String? @Published public var ipAddress: String? + @Published public var animationsOn: Bool = false + public init(tunnelController: TunnelController = NetworkProtectionTunnelController(), statusObserver: ConnectionStatusObserver = ConnectionStatusObserverThroughSession(), serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession(), @@ -166,7 +168,11 @@ final class NetworkProtectionStatusViewModel: ObservableObject { .store(in: &cancellables) } + @MainActor func didToggleNetP(to enabled: Bool) async { + // This is to prevent weird looking animations on navigating to the screen. + // It makes sense as animations should mostly only happen when a user has interacted. + animationsOn = true if enabled { await enableNetP() } else { diff --git a/DuckDuckGo/NetworkProtectionVPNNotificationsView.swift b/DuckDuckGo/NetworkProtectionVPNNotificationsView.swift new file mode 100644 index 0000000000..d5b0d22fd2 --- /dev/null +++ b/DuckDuckGo/NetworkProtectionVPNNotificationsView.swift @@ -0,0 +1,90 @@ +// +// NetworkProtectionVPNNotificationsView.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import SwiftUI +import UIKit +import NetworkProtection +import Core + +@available(iOS 15, *) +struct NetworkProtectionVPNNotificationsView: View { + @StateObject var model = NetworkProtectionVPNNotificationsViewModel() + + var body: some View { + List { + switch model.viewKind { + case .loading: + EmptyView() + case .unauthorized: + unauthorizedView + case .authorized: + authorizedView + } + } + .animation(.default, value: model.viewKind) + .applyInsetGroupedListStyle() + .navigationTitle(UserText.netPVPNNotificationsTitle).onAppear { + Task { + await model.onViewAppeared() + } + } + } + + @ViewBuilder + private var unauthorizedView: some View { + Section { + Button(UserText.netPTurnOnNotificationsButtonTitle) { + model.turnOnNotifications() + } + .foregroundColor(.controlColor) + } footer: { + Text(UserText.netPTurnOnNotificationsSectionFooter) + .foregroundColor(.textSecondary) + .font(.system(size: 13)) + .padding(.top, 6) + } + } + + @ViewBuilder + private var authorizedView: some View { + Section { + Toggle(UserText.netPVPNAlertsToggleTitle, isOn: Binding( + get: { model.alertsEnabled }, + set: model.didToggleAlerts(to:) + )) + .toggleStyle(SwitchToggleStyle(tint: .controlColor)) + } footer: { + Text(UserText.netPVPNAlertsToggleSectionFooter) + .foregroundColor(.textSecondary) + .font(.system(size: 13)) + .padding(.top, 6) + } + } +} + +private extension Color { + static let textPrimary = Color(designSystemColor: .textPrimary) + static let textSecondary = Color(designSystemColor: .textSecondary) + static let cellBackground = Color(designSystemColor: .surface) + static let controlColor = Color(designSystemColor: .accent) +} + +#endif diff --git a/DuckDuckGo/NetworkProtectionVPNNotificationsViewModel.swift b/DuckDuckGo/NetworkProtectionVPNNotificationsViewModel.swift new file mode 100644 index 0000000000..62833eb25a --- /dev/null +++ b/DuckDuckGo/NetworkProtectionVPNNotificationsViewModel.swift @@ -0,0 +1,79 @@ +// +// NetworkProtectionVPNNotificationsViewModel.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if NETWORK_PROTECTION + +import Combine +import UserNotifications +import NetworkProtection + +enum NetworkProtectionNotificationsViewKind: Equatable { + case loading + case unauthorized + case authorized +} + +final class NetworkProtectionVPNNotificationsViewModel: ObservableObject { + private var notificationsAuthorization: NotificationsAuthorizationControlling + private var notificationsSettingsStore: NetworkProtectionNotificationsSettingsStore + @Published var viewKind: NetworkProtectionNotificationsViewKind = .loading + var alertsEnabled: Bool { + self.notificationsSettingsStore.alertsEnabled + } + + init(notificationsAuthorization: NotificationsAuthorizationControlling, + notificationsSettingsStore: NetworkProtectionNotificationsSettingsStore) { + self.notificationsAuthorization = notificationsAuthorization + self.notificationsSettingsStore = notificationsSettingsStore + self.notificationsAuthorization.delegate = self + } + + @MainActor + func onViewAppeared() async { + let status = await notificationsAuthorization.authorizationStatus + updateViewKind(for: status) + } + + func turnOnNotifications() { + notificationsAuthorization.requestAlertAuthorization() + } + + func didToggleAlerts(to enabled: Bool) { + notificationsSettingsStore.alertsEnabled = enabled + } + + private func updateViewKind(for authorizationStatus: UNAuthorizationStatus) { + switch authorizationStatus { + case .notDetermined, .denied: + viewKind = .unauthorized + case .authorized, .ephemeral, .provisional: + viewKind = .authorized + @unknown default: + assertionFailure("Unhandled enum case") + } + } +} + +extension NetworkProtectionVPNNotificationsViewModel: NotificationsPermissionsControllerDelegate { + func authorizationStateDidChange(toStatus status: UNAuthorizationStatus) { + updateViewKind(for: status) + } +} + +#endif diff --git a/DuckDuckGo/NotificationsAuthorizationController.swift b/DuckDuckGo/NotificationsAuthorizationController.swift new file mode 100644 index 0000000000..a5524a4cc9 --- /dev/null +++ b/DuckDuckGo/NotificationsAuthorizationController.swift @@ -0,0 +1,87 @@ +// +// NotificationsAuthorizationController.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UserNotifications +import UIKit +import Combine + +protocol NotificationsAuthorizationControlling { + var authorizationStatus: UNAuthorizationStatus { get async } + var delegate: NotificationsPermissionsControllerDelegate? { get set } + + func requestAlertAuthorization() +} + +protocol NotificationsPermissionsControllerDelegate: AnyObject { + func authorizationStateDidChange(toStatus status: UNAuthorizationStatus) +} + +final class NotificationsAuthorizationController: NotificationsAuthorizationControlling { + + weak var delegate: NotificationsPermissionsControllerDelegate? + var notificationCancellable: AnyCancellable? + + var authorizationStatus: UNAuthorizationStatus { + get async { + let settings: UNNotificationSettings = await UNUserNotificationCenter.current().notificationSettings() + return settings.authorizationStatus + } + } + + init() { + // To handle navigating back from iOS Settings after changing the authorization + notificationCancellable = NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .sink { _ in + Task { [weak self] in + await self?.updateDelegateWithNewState() + } + } + } + + func requestAlertAuthorization() { + Task { + switch await authorizationStatus { + case .notDetermined: + await requestAuthorization(options: .alert) + case .denied: + _ = await UIApplication.shared.openAppNotificationSettings() + case .authorized, .provisional, .ephemeral: + break + @unknown default: + break + } + } + } + + private func requestAuthorization(options: UNAuthorizationOptions) async { + do { + let authorized = try await UNUserNotificationCenter.current().requestAuthorization(options: options) + if authorized { + await updateDelegateWithNewState() + } + } catch { } + } + + private func updateDelegateWithNewState() async { + let newState = await authorizationStatus + DispatchQueue.main.async { [weak self] in + self?.delegate?.authorizationStateDidChange(toStatus: newState) + } + } +} diff --git a/DuckDuckGo/UIApplicationExtension.swift b/DuckDuckGo/UIApplicationExtension.swift new file mode 100644 index 0000000000..c89cabd385 --- /dev/null +++ b/DuckDuckGo/UIApplicationExtension.swift @@ -0,0 +1,41 @@ +// +// UIApplicationExtension.swift +// DuckDuckGo +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +extension UIApplication { + private static let notificationSettingsURL: URL? = { + let settingsString: String + if #available(iOS 16, *) { + settingsString = UIApplication.openNotificationSettingsURLString + } else if #available(iOS 15.4, *) { + settingsString = UIApplicationOpenNotificationSettingsURLString + } else { + settingsString = UIApplication.openSettingsURLString + } + return URL(string: settingsString) + }() + + func openAppNotificationSettings() async -> Bool { + guard + let url = UIApplication.notificationSettingsURL, + self.canOpenURL(url) else { return false } + return await self.open(url) + } +} diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 8d2331337d..0447b01e09 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -656,6 +656,10 @@ In addition to the details entered into this form, your app issue report will co static let netPAlwaysOnSettingFooter = NSLocalizedString("network.protection.vpn.always.on.setting.footer", value: "Automatically restore a VPN connection after interruption.", comment: "Footer text for the Always on VPN setting item.") static let netPSecureDNSSettingTitle = NSLocalizedString("network.protection.vpn.secure.dns.setting.title", value: "Secure DNS", comment: "Title for the Always on VPN setting item.") static let netPSecureDNSSettingFooter = NSLocalizedString("network.protection.vpn.secure.dns.setting.footer", value: "Network Protection prevents DNS leaks to your Internet Service Provider by routing DNS queries though the VPN tunnel to our own resolver.", comment: "Footer text for the Always on VPN setting item.") + static let netPTurnOnNotificationsButtonTitle = NSLocalizedString("network.protection.turn.on.notifications.button.title", value: "Turn on Notifications", comment: "Title for the button to link to the iOS app settings and enable notifications app-wide.") + static let netPTurnOnNotificationsSectionFooter = NSLocalizedString("network.protection.turn.on.notifications.section.footer", value: "Allow DuckDuckGo to notify you if your connection drops or VPN status changes.", comment: "Footer text under the button to link to the iOS app settings and enable notifications app-wide.") + static let netPVPNAlertsToggleTitle = NSLocalizedString("network.protection.vpn.alerts.toggle.title", value: "VPN Alerts", comment: "Title for the toggle for VPN alerts.") + static let netPVPNAlertsToggleSectionFooter = NSLocalizedString("network.protection.vpn.alerts.toggle.section.footer", value: "Get notified if your connection drops or VPN status changes.", comment: "List section footer for the toggle for VPN alerts.") static let netPOpenVPNQuickAction = NSLocalizedString("network.protection.quick-action.open-vpn", value: "Open VPN", comment: "Title text for an iOS quick action that opens VPN settings") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index b966ba574d..bdcc498b88 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -1441,6 +1441,18 @@ https://duckduckgo.com/mac"; /* Title label text for the status view when netP is disconnected */ "network.protection.status.view.title" = "Network Protection"; +/* Title for the button to link to the iOS app settings and enable notifications app-wide. */ +"network.protection.turn.on.notifications.button.title" = "Turn on Notifications"; + +/* Footer text under the button to link to the iOS app settings and enable notifications app-wide. */ +"network.protection.turn.on.notifications.section.footer" = "Allow DuckDuckGo to notify you if your connection drops or VPN status changes."; + +/* List section footer for the toggle for VPN alerts. */ +"network.protection.vpn.alerts.toggle.section.footer" = "Get notified if your connection drops or VPN status changes."; + +/* Title for the toggle for VPN alerts. */ +"network.protection.vpn.alerts.toggle.title" = "VPN Alerts"; + /* Footer text for the Always on VPN setting item. */ "network.protection.vpn.always.on.setting.footer" = "Automatically restore a VPN connection after interruption."; diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index ccb15449ae..5db8d2c3c5 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -17,6 +17,8 @@ // limitations under the License. // +#if NETWORK_PROTECTION + import Foundation import NetworkProtection import Common @@ -167,8 +169,13 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { errorEvents: nil) let errorStore = NetworkProtectionTunnelErrorStore() let notificationsPresenter = NetworkProtectionUNNotificationPresenter() + let notificationsSettingsStore = NetworkProtectionNotificationsSettingsUserDefaultsStore(userDefaults: .networkProtectionGroupDefaults) + let nofificationsPresenterDecorator = NetworkProtectionNotificationsPresenterTogglableDecorator( + notificationSettingsStore: notificationsSettingsStore, + wrappee: notificationsPresenter + ) notificationsPresenter.requestAuthorization() - super.init(notificationsPresenter: notificationsPresenter, + super.init(notificationsPresenter: nofificationsPresenterDecorator, tunnelHealthStore: NetworkProtectionTunnelHealthStore(), controllerErrorStore: errorStore, keychainType: .dataProtection(.unspecified), @@ -203,3 +210,5 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { } } } + +#endif diff --git a/PacketTunnelProvider/PacketTunnelProvider.entitlements b/PacketTunnelProvider/PacketTunnelProvider.entitlements index 86c503b63c..5e171bb76b 100644 --- a/PacketTunnelProvider/PacketTunnelProvider.entitlements +++ b/PacketTunnelProvider/PacketTunnelProvider.entitlements @@ -9,6 +9,7 @@ com.apple.security.application-groups $(GROUP_ID_PREFIX).apptp + $(GROUP_ID_PREFIX).netp