From 8a2d229196028539d039b86f11d32301dbd37b21 Mon Sep 17 00:00:00 2001 From: Ryan Lepinski Date: Tue, 10 Sep 2024 14:22:32 -0700 Subject: [PATCH] Add fallback to enable notifications (#3199) * Add fallback to enable notifications * Remove enable airship usage * Fix --- Airship Sample/Airship Sample/HomeView.swift | 6 +- Airship/Airship.xcodeproj/project.pbxproj | 4 - .../Source/EnableFeatureAction.swift | 4 +- .../Source/PermissionPrompter.swift | 68 +------ .../Source/PermissionsManager.swift | 169 +++++++++++++++--- .../Source/PromptPermissionAction.swift | 14 +- Airship/AirshipCore/Source/Push.swift | 17 +- Airship/AirshipCore/Source/PushProtocol.swift | 13 ++ .../AirshipCore/Tests/AirshipEventsTest.swift | 6 + Airship/AirshipCore/Tests/AnalyticsTest.swift | 3 +- .../Tests/EnableFeatureActionTest.swift | 10 +- .../Tests/PermissionsManagerTests.swift | 117 ++++++++++-- .../Tests/PromptPermissionActionTest.swift | 8 +- Airship/AirshipCore/Tests/PushTest.swift | 14 +- .../Tests/Support/TestAppStateTracker.swift | 6 + .../Tests/TestAirshipInstance.swift | 10 +- .../Tests/TestPermissionPrompter.swift | 6 +- Airship/AirshipCore/Tests/TestPush.swift | 4 + 18 files changed, 343 insertions(+), 136 deletions(-) diff --git a/Airship Sample/Airship Sample/HomeView.swift b/Airship Sample/Airship Sample/HomeView.swift index 20ee2f711..6c72352a6 100644 --- a/Airship Sample/Airship Sample/HomeView.swift +++ b/Airship Sample/Airship Sample/HomeView.swift @@ -203,9 +203,9 @@ struct HomeView: View { @MainActor func togglePushEnabled() { if (!pushEnabled) { - Airship.privacyManager.enableFeatures(.push) - Airship.push.userPushNotificationsEnabled = true - Airship.push.backgroundPushNotificationsEnabled = true + Task { + await Airship.push.enableUserPushNotifications(fallback: .systemSettings) + } } else { Airship.push.userPushNotificationsEnabled = false } diff --git a/Airship/Airship.xcodeproj/project.pbxproj b/Airship/Airship.xcodeproj/project.pbxproj index 65e4bb315..2734eb321 100644 --- a/Airship/Airship.xcodeproj/project.pbxproj +++ b/Airship/Airship.xcodeproj/project.pbxproj @@ -155,7 +155,6 @@ 3CC95B2B2696549B00FE2ACD /* AirshipPushableComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC95B2A2696549B00FE2ACD /* AirshipPushableComponent.swift */; }; 3CC95B2C2696549B00FE2ACD /* AirshipPushableComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC95B2A2696549B00FE2ACD /* AirshipPushableComponent.swift */; }; 45A8ADF023134B38004AD8CA /* testMCColorsCatalog.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 45A8ADD123133E51004AD8CA /* testMCColorsCatalog.xcassets */; }; - 54DE2901247B2AF059E46862 /* Pods_AirshipTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EBF83042659DAF0D42AD7A9E /* Pods_AirshipTests.framework */; }; 6014AD672C1B5F540072DCF0 /* ChallengeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */; }; 6014AD6C2C2032730072DCF0 /* ChallengeResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD6A2C2032360072DCF0 /* ChallengeResolverTest.swift */; }; 6014AD752C20410B0072DCF0 /* airship.der in Resources */ = {isa = PBXBuildFile; fileRef = 6014AD742C20410A0072DCF0 /* airship.der */; }; @@ -3045,7 +3044,6 @@ buildActionMask = 2147483647; files = ( CC64F0591D8B77E3009CEF27 /* AirshipCore.framework in Frameworks */, - 54DE2901247B2AF059E46862 /* Pods_AirshipTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9117,7 +9115,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 0F55D47F540C142B0F469570 /* Pods-AirshipTests.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -9158,7 +9155,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 62C67C80125440E0C4F9982D /* Pods-AirshipTests.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; diff --git a/Airship/AirshipCore/Source/EnableFeatureAction.swift b/Airship/AirshipCore/Source/EnableFeatureAction.swift index 3b46b136b..e3c0e3789 100644 --- a/Airship/AirshipCore/Source/EnableFeatureAction.swift +++ b/Airship/AirshipCore/Source/EnableFeatureAction.swift @@ -60,7 +60,7 @@ public final class EnableFeatureAction: AirshipAction { public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let permission = try parsePermission(arguments: arguments) - let (start, end) = await self.permissionPrompter() + let result = await self.permissionPrompter() .prompt( permission: permission, enableAirshipUsage: true, @@ -71,7 +71,7 @@ public final class EnableFeatureAction: AirshipAction { EnableFeatureAction.resultReceiverMetadataKey ] as? PermissionResultReceiver - await resultReceiver?(permission, start, end) + await resultReceiver?(permission, result.startStatus, result.endStatus) return nil } diff --git a/Airship/AirshipCore/Source/PermissionPrompter.swift b/Airship/AirshipCore/Source/PermissionPrompter.swift index 3d6c5c6cb..efd1fd149 100644 --- a/Airship/AirshipCore/Source/PermissionPrompter.swift +++ b/Airship/AirshipCore/Source/PermissionPrompter.swift @@ -13,21 +13,17 @@ protocol PermissionPrompter: Sendable { permission: AirshipPermission, enableAirshipUsage: Bool, fallbackSystemSettings: Bool - ) async -> (AirshipPermissionStatus, AirshipPermissionStatus) - + ) async -> AirshipPermissionResult } struct AirshipPermissionPrompter: PermissionPrompter { private let permissionsManager: AirshipPermissionsManager - private let notificationCenter: NotificationCenter init( - permissionsManager: AirshipPermissionsManager, - notificationCenter: NotificationCenter = NotificationCenter.default + permissionsManager: AirshipPermissionsManager ) { self.permissionsManager = permissionsManager - self.notificationCenter = notificationCenter } @MainActor @@ -35,59 +31,11 @@ struct AirshipPermissionPrompter: PermissionPrompter { permission: AirshipPermission, enableAirshipUsage: Bool, fallbackSystemSettings: Bool - ) async -> (AirshipPermissionStatus, AirshipPermissionStatus) { - - let startResult = await self.permissionsManager.checkPermissionStatus(permission) - if fallbackSystemSettings && startResult == .denied { - #if !os(watchOS) - let endResult = await self.requestSystemSettingsChange(permission: permission) - #else - let endResult = await self.permissionsManager.requestPermission( - permission, - enableAirshipUsageOnGrant: enableAirshipUsage - ) - #endif - return (startResult, endResult) - - } else { - let endResult = await self.permissionsManager.requestPermission( - permission, - enableAirshipUsageOnGrant: enableAirshipUsage - ) - - return (startResult, endResult) - } - } - - #if !os(watchOS) - - @MainActor - private func requestSystemSettingsChange( - permission: AirshipPermission - ) async -> AirshipPermissionStatus { - if let url = URL(string: UIApplication.openSettingsURLString) { - await UIApplication.shared.open(url, options: [:]) - await waitNextOpen() - } else { - AirshipLogger.error("Unable to navigate to system settings.") - } - - return await self.permissionsManager.checkPermissionStatus(permission) + ) async -> AirshipPermissionResult { + return await self.permissionsManager.requestPermission( + permission, + enableAirshipUsageOnGrant: enableAirshipUsage, + fallback: fallbackSystemSettings ? .systemSettings : .none + ) } - - - @MainActor - private func waitNextOpen() async { - var subscription: AnyCancellable? - await withCheckedContinuation { continuation in - subscription = self.notificationCenter.publisher(for: AppStateTracker.didBecomeActiveNotification) - .sink { _ in - continuation.resume() - } - } - - subscription?.cancel() - } - #endif - } diff --git a/Airship/AirshipCore/Source/PermissionsManager.swift b/Airship/AirshipCore/Source/PermissionsManager.swift index 14111815b..fe3f91f2f 100644 --- a/Airship/AirshipCore/Source/PermissionsManager.swift +++ b/Airship/AirshipCore/Source/PermissionsManager.swift @@ -1,7 +1,6 @@ /* Copyright Airship and Contributors */ import Foundation -import Combine /// Airship permissions manager. /// @@ -22,18 +21,26 @@ public final class AirshipPermissionsManager: NSObject, @unchecked Sendable { ] = [:] private let statusUpdates: AirshipAsyncChannel<(AirshipPermission, AirshipPermissionStatus)> = AirshipAsyncChannel() - private var notificationListener: AnyCancellable? - - init(notificationCenter: NotificationCenter = .default) { + private let appStateTracker: AppStateTrackerProtocol + private let systemSettingsNavigator: SystemSettingsNavigatorProtocol + + @MainActor + init( + appStateTracker: AppStateTrackerProtocol? = nil, + systemSettingsNavigator: SystemSettingsNavigatorProtocol = SystemSettingsNavigator() + ) { + self.appStateTracker = appStateTracker ?? AppStateTracker.shared + self.systemSettingsNavigator = systemSettingsNavigator super.init() - - notificationListener = notificationCenter - .publisher(for: AppStateTracker.didBecomeActiveNotification) - .sink(receiveValue: { _ in - Task { @MainActor [weak self] in + + Task { @MainActor [weak self] in + guard let updates = self?.appStateTracker.stateUpdates else { return } + for await update in updates { + if (update == .active) { await self?.refreshPermissionStatuses() } - }) + } + } } var configuredPermissions: Set { @@ -141,7 +148,7 @@ public final class AirshipPermissionsManager: NSObject, @unchecked Sendable { /// /// - Parameters: /// - permission: The permission. - /// - enableAirshipUsageOnGrant: `true` to allow any Airship features that need the permission to be enabled as well, e.g., enabling push privacy manager feature and user notifications if `.postNotifications` is granted. + /// - enableAirshipUsageOnGrant: `true` to allow any Airship features that need the permission to be enabled as well, e.g., enabling push privacy manager feature and user notifications if `.displayNotifications` is granted. /// - completionHandler: The completion handler. @objc @MainActor @@ -149,32 +156,76 @@ public final class AirshipPermissionsManager: NSObject, @unchecked Sendable { _ permission: AirshipPermission, enableAirshipUsageOnGrant: Bool ) async -> AirshipPermissionStatus { - let status: AirshipPermissionStatus? = try? await self.queue.run { @MainActor in + return await requestPermission( + permission, + enableAirshipUsageOnGrant: enableAirshipUsageOnGrant, + fallback: .none + ).endStatus + } + + /// Requests a permission. + /// + /// - Parameters: + /// - permission: The permission. + /// - enableAirshipUsageOnGrant: `true` to allow any Airship features that need the permission to be enabled as well, e.g., enabling push privacy manager feature and user notifications if `.displayNotifications` is granted. + /// - fallback: The fallback behavior if the permission is alreay denied. + /// - Returns: A `AirshipPermissionResult` with the starting and ending status If no permission delegate is + /// set for the given permission the status will be `.notDetermined` + @MainActor + public func requestPermission( + _ permission: AirshipPermission, + enableAirshipUsageOnGrant: Bool, + fallback: PromptPermissionFallback + ) async -> AirshipPermissionResult { + let status: AirshipPermissionResult? = try? await self.queue.run { @MainActor in guard let delegate = self.permissionDelegate(permission) else { - return .notDetermined + return AirshipPermissionResult.notDetermined } - let status = await delegate.requestPermission() + let startingStatus = await delegate.checkPermissionStatus() + var endStatus: AirshipPermissionStatus = .notDetermined - if status == .granted { + switch(startingStatus) { + case .granted: + endStatus = .granted + case .notDetermined: + endStatus = await delegate.requestPermission() + case .denied: + switch fallback { + case .none: + endStatus = .denied + case .systemSettings: + if await self.systemSettingsNavigator.open(for: permission) { + await self.appStateTracker.waitForActive() + endStatus = await delegate.checkPermissionStatus() + } else { + endStatus = .denied + } + case .callback(let callback): + await callback() + endStatus = await delegate.checkPermissionStatus() + } + } + + if endStatus == .granted { await self.onPermissionEnabled( permission, enableAirshipUsage: enableAirshipUsageOnGrant ) } - await self.onExtend(permission: permission, status: status) + await self.onExtend(permission: permission, status: endStatus) - return status + return AirshipPermissionResult(startStatus: startingStatus, endStatus: endStatus) } - - let result = status ?? .notDetermined - - await statusUpdates.send((permission, result)) + + let result = status ?? AirshipPermissionResult.notDetermined + + await statusUpdates.send((permission, result.endStatus)) return result } - + /// - Note: for internal use only. :nodoc: func addRequestExtender( permission: AirshipPermission, @@ -251,5 +302,79 @@ public final class AirshipPermissionsManager: NSObject, @unchecked Sendable { await extender(status) } } +} + +public struct AirshipPermissionResult: Sendable { + /// Starting status + public var startStatus: AirshipPermissionStatus + + /// Ending status + public var endStatus: AirshipPermissionStatus + + public init(startStatus: AirshipPermissionStatus, endStatus: AirshipPermissionStatus) { + self.startStatus = startStatus + self.endStatus = endStatus + } + + static var notDetermined: AirshipPermissionResult { + AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) + } +} + + + +/// Prompt permission fallback to be used if the requested permission is already denied. +public enum PromptPermissionFallback: Sendable { + /// No fallback + case none + /// Navigate to system settings + case systemSettings + // Custom callback + case callback(@MainActor @Sendable () async -> Void) +} + + + +protocol SystemSettingsNavigatorProtocol: Sendable { + @MainActor + func open(for: AirshipPermission) async -> Bool +} + +struct SystemSettingsNavigator: SystemSettingsNavigatorProtocol { +#if !os(watchOS) + @MainActor + func open(for permission: AirshipPermission) async -> Bool { + if let url = systemSettingURLForPermission(permission) { + return await UIApplication.shared.open(url, options: [:]) + } else { + return false + } + } + + @MainActor + private func systemSettingURLForPermission(_ permission: AirshipPermission) -> URL? { + let string = switch(permission) { + case .displayNotifications: + if #available(iOS 16.0, tvOS 16.0, macCatalyst 16.0, visionOS 1.0, *) { + UIApplication.openNotificationSettingsURLString + } else if #available(iOS 15.4, tvOS 15.4, macCatalyst 15.4, *) { + UIApplicationOpenNotificationSettingsURLString + } else { + UIApplication.openSettingsURLString + } + case .location: + UIApplication.openSettingsURLString + } + + return URL(string: string) + } + #else + + @MainActor + func open(for permission: AirshipPermission) async -> Bool { + return false + } + + #endif } diff --git a/Airship/AirshipCore/Source/PromptPermissionAction.swift b/Airship/AirshipCore/Source/PromptPermissionAction.swift index d993e129c..5e845b964 100644 --- a/Airship/AirshipCore/Source/PromptPermissionAction.swift +++ b/Airship/AirshipCore/Source/PromptPermissionAction.swift @@ -67,18 +67,17 @@ public final class PromptPermissionAction: AirshipAction { ) let args = try JSONDecoder().decode(Args.self, from: data) - let (start, end) = await self.permissionPrompter() - .prompt( - permission: args.permission, - enableAirshipUsage: args.enableAirshipUsage ?? false, - fallbackSystemSettings: args.fallbackSystemSettings ?? false - ) + let result = await self.permissionPrompter().prompt( + permission: args.permission, + enableAirshipUsage: args.enableAirshipUsage ?? false, + fallbackSystemSettings: args.fallbackSystemSettings ?? false + ) let resultReceiver = arguments.metadata[ PromptPermissionAction.resultReceiverMetadataKey ] as? PermissionResultReceiver - await resultReceiver?(args.permission, start, end) + await resultReceiver?(args.permission, result.startStatus, result.endStatus) return nil } @@ -97,3 +96,4 @@ public final class PromptPermissionAction: AirshipAction { } + diff --git a/Airship/AirshipCore/Source/Push.swift b/Airship/AirshipCore/Source/Push.swift index 1f2069468..b995ff035 100644 --- a/Airship/AirshipCore/Source/Push.swift +++ b/Airship/AirshipCore/Source/Push.swift @@ -563,13 +563,26 @@ final class AirshipPush: NSObject, AirshipPushProtocol, @unchecked Sendable { @objc public func enableUserPushNotifications() async -> Bool { + return await enableUserPushNotifications(fallback: .none) + } + + public func enableUserPushNotifications( + fallback: PromptPermissionFallback + ) async -> Bool { self.dataStore.setBool( true, forKey: AirshipPush.userPushNotificationsEnabledKey ) - return await self.permissionsManager.requestPermission(.displayNotifications) == .granted + + let result = await self.permissionsManager.requestPermission( + .displayNotifications, + enableAirshipUsageOnGrant: false, + fallback: fallback + ) + + return result.endStatus == .granted } - + @MainActor private func waitForDeviceTokenRegistration() async { guard self.waitForDeviceToken, diff --git a/Airship/AirshipCore/Source/PushProtocol.swift b/Airship/AirshipCore/Source/PushProtocol.swift index 30dc2303e..b87526720 100644 --- a/Airship/AirshipCore/Source/PushProtocol.swift +++ b/Airship/AirshipCore/Source/PushProtocol.swift @@ -168,6 +168,7 @@ public protocol AirshipBasePushProtocol: AnyObject, Sendable { ) } + /// Airship Push protocol. public protocol AirshipPushProtocol: AirshipBasePushProtocol { @@ -183,6 +184,18 @@ public protocol AirshipPushProtocol: AirshipBasePushProtocol { /// Gets the current notification status var notificationStatus: AirshipNotificationStatus { get async } + + /// Enables user notifications on this device through Airship. + /// + /// - Note: The result of this method does NOT represent the state of the userPushNotificationsEnabled flag, + /// which will be invariably set to `true` after the completion of this call. + /// + /// - Parameters: + /// - fallback: The prompt permission fallback if the display notifications permission is already denied. + /// + /// - Returns: `true` if user notifications are enabled at the system level, otherwise`false`. + @discardableResult + func enableUserPushNotifications(fallback: PromptPermissionFallback) async -> Bool } protocol InternalPushProtocol { diff --git a/Airship/AirshipCore/Tests/AirshipEventsTest.swift b/Airship/AirshipCore/Tests/AirshipEventsTest.swift index a26a9fcd2..f4fdde6ec 100644 --- a/Airship/AirshipCore/Tests/AirshipEventsTest.swift +++ b/Airship/AirshipCore/Tests/AirshipEventsTest.swift @@ -342,6 +342,7 @@ class AirshipEventsTest: XCTestCase { } private final class EventTestPush: AirshipPushProtocol, @unchecked Sendable { + var quietTime: QuietTimeSettings? @@ -349,6 +350,11 @@ private final class EventTestPush: AirshipPushProtocol, @unchecked Sendable { return true } + func enableUserPushNotifications(fallback: PromptPermissionFallback) async -> Bool { + return true + } + + func setBadgeNumber(_ newBadgeNumber: Int) async { } diff --git a/Airship/AirshipCore/Tests/AnalyticsTest.swift b/Airship/AirshipCore/Tests/AnalyticsTest.swift index 2d26106d4..40ff7a2a0 100644 --- a/Airship/AirshipCore/Tests/AnalyticsTest.swift +++ b/Airship/AirshipCore/Tests/AnalyticsTest.swift @@ -12,7 +12,7 @@ class AnalyticsTest: XCTestCase { private let config = AirshipConfig() private let channel = TestChannel() private let locale = TestLocaleManager() - private let permissionsManager = AirshipPermissionsManager() + private var permissionsManager: AirshipPermissionsManager! private let notificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) private let date = UATestDate() private let eventManager = TestEventManager() @@ -26,6 +26,7 @@ class AnalyticsTest: XCTestCase { @MainActor override func setUp() async throws { + self.permissionsManager = AirshipPermissionsManager() self.privacyManager = AirshipPrivacyManager( dataStore: self.dataStore, config: RuntimeConfig( diff --git a/Airship/AirshipCore/Tests/EnableFeatureActionTest.swift b/Airship/AirshipCore/Tests/EnableFeatureActionTest.swift index 109f0e7e7..2cfc5e146 100644 --- a/Airship/AirshipCore/Tests/EnableFeatureActionTest.swift +++ b/Airship/AirshipCore/Tests/EnableFeatureActionTest.swift @@ -63,7 +63,7 @@ class EnableFeatureActionTest: XCTestCase { XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() - return (.notDetermined, .notDetermined) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } @@ -86,7 +86,7 @@ class EnableFeatureActionTest: XCTestCase { XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() - return (.notDetermined, .notDetermined) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } _ = try await self.action.perform(arguments: arguments) @@ -108,7 +108,7 @@ class EnableFeatureActionTest: XCTestCase { XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() - return (.notDetermined, .notDetermined) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } _ = try await self.action.perform(arguments: arguments) @@ -126,7 +126,7 @@ class EnableFeatureActionTest: XCTestCase { enableAirshipUsage, fallbackSystemSetting in XCTFail() - return (.notDetermined, .notDetermined) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } do { @@ -163,7 +163,7 @@ class EnableFeatureActionTest: XCTestCase { permission, enableAirshipUsage, fallbackSystemSetting in - return (.notDetermined, .granted) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .granted) } _ = try await self.action.perform(arguments: arguments) diff --git a/Airship/AirshipCore/Tests/PermissionsManagerTests.swift b/Airship/AirshipCore/Tests/PermissionsManagerTests.swift index 648dd5e13..20e6e22d2 100644 --- a/Airship/AirshipCore/Tests/PermissionsManagerTests.swift +++ b/Airship/AirshipCore/Tests/PermissionsManagerTests.swift @@ -6,12 +6,17 @@ import XCTest class PermissionsManagerTests: XCTestCase { - let notificationCenter = NotificationCenter() + + var systemSettingsNavigator: TestSystemSettingsNavigator! var permissionsManager: AirshipPermissionsManager! let delegate = TestPermissionsDelegate() - + let appStateTracker = TestAppStateTracker() override func setUp() async throws { - permissionsManager = AirshipPermissionsManager(notificationCenter: notificationCenter) + self.systemSettingsNavigator = await TestSystemSettingsNavigator() + permissionsManager = await AirshipPermissionsManager( + appStateTracker: appStateTracker, + systemSettingsNavigator: systemSettingsNavigator + ) } func testCheckPermissionNotConfigured() async throws { let status = await self.permissionsManager.checkPermissionStatus(.displayNotifications) @@ -61,10 +66,8 @@ class PermissionsManagerTests: XCTestCase { XCTAssertEqual(AirshipPermissionStatus.denied, currentStatus) self.delegate.permissionStatus = .granted - - Task { @MainActor in - notificationCenter.post(name: AppStateTracker.didBecomeActiveNotification, object: nil) - } + + await self.appStateTracker.updateState(.active) currentStatus = await stream.next() XCTAssertEqual(AirshipPermissionStatus.granted, currentStatus) @@ -76,7 +79,21 @@ class PermissionsManagerTests: XCTestCase { XCTAssertEqual(AirshipPermissionStatus.notDetermined, status) } - func testRequestPermission() async throws { + func testRequestPermissionNotDetermined() async throws { + self.permissionsManager.setDelegate( + self.delegate, + permission: .location + ) + self.delegate.permissionStatus = .notDetermined + + let status = await self.permissionsManager.requestPermission(.location) + + XCTAssertEqual(AirshipPermissionStatus.notDetermined, status) + XCTAssertTrue(self.delegate.requestCalled) + XCTAssertTrue(self.delegate.checkCalled) + } + + func testRequestPermissionDenied() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location @@ -86,8 +103,75 @@ class PermissionsManagerTests: XCTestCase { let status = await self.permissionsManager.requestPermission(.location) XCTAssertEqual(AirshipPermissionStatus.denied, status) - XCTAssertTrue(self.delegate.requestCalled) - XCTAssertFalse(self.delegate.checkCalled) + XCTAssertFalse(self.delegate.requestCalled) + XCTAssertTrue(self.delegate.checkCalled) + } + + func testRequestPermissionGranted() async throws { + self.permissionsManager.setDelegate( + self.delegate, + permission: .location + ) + self.delegate.permissionStatus = .granted + + let status = await self.permissionsManager.requestPermission(.location) + + XCTAssertEqual(AirshipPermissionStatus.granted, status) + XCTAssertFalse(self.delegate.requestCalled) + XCTAssertTrue(self.delegate.checkCalled) + } + + @MainActor + func testRequestPermissionSystemSettingsFallback() async throws { + self.permissionsManager.setDelegate( + self.delegate, + permission: .location + ) + self.delegate.permissionStatus = .denied + + _ = await self.permissionsManager.requestPermission(.location, enableAirshipUsageOnGrant: false, fallback: .systemSettings) + + XCTAssertFalse(self.delegate.requestCalled) + XCTAssertTrue(self.delegate.checkCalled) + XCTAssertEqual(systemSettingsNavigator.permissionOpens, [.location]) + } + + @MainActor + func testRequestPermissionSystemSettingsFallbackFailsToOpen() async throws { + self.systemSettingsNavigator.permissionOpenResult = false + + self.permissionsManager.setDelegate( + self.delegate, + permission: .location + ) + self.delegate.permissionStatus = .denied + + _ = await self.permissionsManager.requestPermission(.location, enableAirshipUsageOnGrant: false, fallback: .systemSettings) + + XCTAssertFalse(self.delegate.requestCalled) + XCTAssertTrue(self.delegate.checkCalled) + XCTAssertEqual(systemSettingsNavigator.permissionOpens, [.location]) + } + + @MainActor + func testRequestPermissionCallbackFallback() async throws { + self.permissionsManager.setDelegate( + self.delegate, + permission: .location + ) + self.delegate.permissionStatus = .denied + + let status = await self.permissionsManager.requestPermission( + .location, + enableAirshipUsageOnGrant: false, + fallback: .callback({ + self.delegate.permissionStatus = .granted + }) + ) + + XCTAssertEqual(AirshipPermissionStatus.granted, status.endStatus) + XCTAssertFalse(self.delegate.requestCalled) + XCTAssertTrue(self.delegate.checkCalled) } func testConfiguredPermissionsEmpty() throws { @@ -175,3 +259,16 @@ open class TestPermissionsDelegate: NSObject, AirshipPermissionDelegate { return permissionStatus } } + + +@MainActor +public final class TestSystemSettingsNavigator: SystemSettingsNavigatorProtocol { + var permissionOpens: [AirshipPermission] = [] + var permissionOpenResult = false + public func open(for permission: AirshipPermission) async -> Bool { + permissionOpens.append(permission) + return permissionOpenResult + } + + +} diff --git a/Airship/AirshipCore/Tests/PromptPermissionActionTest.swift b/Airship/AirshipCore/Tests/PromptPermissionActionTest.swift index 7a76e9780..a622bf0db 100644 --- a/Airship/AirshipCore/Tests/PromptPermissionActionTest.swift +++ b/Airship/AirshipCore/Tests/PromptPermissionActionTest.swift @@ -64,7 +64,7 @@ class PromptPermissionActionTest: XCTestCase { XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() - return (.notDetermined, .notDetermined) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } @@ -91,7 +91,7 @@ class PromptPermissionActionTest: XCTestCase { XCTAssertFalse(enableAirshipUsage) XCTAssertFalse(fallbackSystemSetting) prompted.fulfill() - return (.notDetermined, .notDetermined) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } @@ -115,7 +115,7 @@ class PromptPermissionActionTest: XCTestCase { enableAirshipUsage, fallbackSystemSetting in XCTFail() - return (.notDetermined, .notDetermined) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } do { @@ -156,7 +156,7 @@ class PromptPermissionActionTest: XCTestCase { permission, enableAirshipUsage, fallbackSystemSetting in - return (.notDetermined, .granted) + return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .granted) } _ = try await self.action.perform(arguments: arguments) diff --git a/Airship/AirshipCore/Tests/PushTest.swift b/Airship/AirshipCore/Tests/PushTest.swift index 1af62742b..498bd97e8 100644 --- a/Airship/AirshipCore/Tests/PushTest.swift +++ b/Airship/AirshipCore/Tests/PushTest.swift @@ -12,7 +12,7 @@ class PushTest: XCTestCase { private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private let channel = TestChannel() private let analtyics = TestAnalytics() - private let permissionsManager = AirshipPermissionsManager() + private var permissionsManager: AirshipPermissionsManager! private let notificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) private let notificationRegistrar = TestNotificationRegistrar() @@ -27,6 +27,7 @@ class PushTest: XCTestCase { private var serialQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue(priority: .high) override func setUp() async throws { + self.permissionsManager = await AirshipPermissionsManager() self.privacyManager = await AirshipPrivacyManager( dataStore: self.dataStore, config: RuntimeConfig( @@ -290,15 +291,6 @@ class PushTest: XCTestCase { self.push.requestExplicitPermissionWhenEphemeral = false await self.serialQueue.waitForCurrentOperations() - let updated = self.expectation(description: "Registration updated") - self.notificationRegistrar.onUpdateRegistration = { - options, - skipIfEphemeral in - XCTAssertEqual([.alert, .badge], options) - XCTAssertTrue(skipIfEphemeral) - updated.fulfill() - } - self.notificationRegistrar.onCheckStatus = { return(.authorized, []) } @@ -306,7 +298,7 @@ class PushTest: XCTestCase { let success = await self.push.enableUserPushNotifications() XCTAssertTrue(success) - await self.fulfillmentCompat(of: [permissionsManagerCalled, updated], timeout: 10.0) + await self.fulfillmentCompat(of: [permissionsManagerCalled], timeout: 10.0) } func testEnableUserNotificationsDenied() async throws { diff --git a/Airship/AirshipCore/Tests/Support/TestAppStateTracker.swift b/Airship/AirshipCore/Tests/Support/TestAppStateTracker.swift index 5d61d6871..aa26aae50 100644 --- a/Airship/AirshipCore/Tests/Support/TestAppStateTracker.swift +++ b/Airship/AirshipCore/Tests/Support/TestAppStateTracker.swift @@ -38,4 +38,10 @@ public final class TestAppStateTracker: AppStateTrackerProtocol, @unchecked Send stateValue.set(currentState) } } + + + @MainActor + public func updateState(_ state: ApplicationState) async { + self.currentState = state + } } diff --git a/Airship/AirshipCore/Tests/TestAirshipInstance.swift b/Airship/AirshipCore/Tests/TestAirshipInstance.swift index 4d28fcb9b..649f59479 100644 --- a/Airship/AirshipCore/Tests/TestAirshipInstance.swift +++ b/Airship/AirshipCore/Tests/TestAirshipInstance.swift @@ -5,6 +5,14 @@ import Foundation @testable import AirshipCore class TestAirshipInstance: AirshipInstanceProtocol { + + + + var _permissionsManager: AirshipPermissionsManager? + var permissionsManager: AirshipPermissionsManager { + return _permissionsManager! + } + public let preferenceDataStore: AirshipCore.PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var _config: RuntimeConfig? @@ -18,8 +26,6 @@ class TestAirshipInstance: AirshipInstanceProtocol { } } - @objc - public var permissionsManager: AirshipPermissionsManager = AirshipPermissionsManager() private var _actionRegistry: ActionRegistry? public var actionRegistry: ActionRegistry { diff --git a/Airship/AirshipCore/Tests/TestPermissionPrompter.swift b/Airship/AirshipCore/Tests/TestPermissionPrompter.swift index 287907b16..f2467ae2b 100644 --- a/Airship/AirshipCore/Tests/TestPermissionPrompter.swift +++ b/Airship/AirshipCore/Tests/TestPermissionPrompter.swift @@ -11,7 +11,7 @@ final class TestPermissionPrompter: PermissionPrompter, @unchecked Sendable { ( AirshipPermission, Bool, Bool ) -> - (AirshipPermissionStatus, AirshipPermissionStatus) + AirshipPermissionResult )? init() {} @@ -19,7 +19,7 @@ final class TestPermissionPrompter: PermissionPrompter, @unchecked Sendable { func prompt( permission: AirshipPermission, enableAirshipUsage: Bool, - fallbackSystemSettings: Bool) async -> (AirshipPermissionStatus, AirshipPermissionStatus) { + fallbackSystemSettings: Bool) async -> AirshipPermissionResult { if let onPrompt = self.onPrompt { return onPrompt( @@ -28,7 +28,7 @@ final class TestPermissionPrompter: PermissionPrompter, @unchecked Sendable { fallbackSystemSettings ) } else { - return(.notDetermined, .notDetermined) + return AirshipPermissionResult.notDetermined } } } diff --git a/Airship/AirshipCore/Tests/TestPush.swift b/Airship/AirshipCore/Tests/TestPush.swift index 4492bf560..6b5e43b8c 100644 --- a/Airship/AirshipCore/Tests/TestPush.swift +++ b/Airship/AirshipCore/Tests/TestPush.swift @@ -7,6 +7,10 @@ import Foundation import Combine final class TestPush: NSObject, InternalPushProtocol, AirshipPushProtocol, AirshipComponent, @unchecked Sendable { + func enableUserPushNotifications(fallback: AirshipCore.PromptPermissionFallback) async -> Bool { + return true + } + override init() { (self.notificationStatusUpdates, self.statusUpdateContinuation) = AsyncStream.airshipMakeStreamWithContinuation()