diff --git a/swift-sdk.xcodeproj/project.pbxproj b/swift-sdk.xcodeproj/project.pbxproj index 0d2b838f5..d3e1062fb 100644 --- a/swift-sdk.xcodeproj/project.pbxproj +++ b/swift-sdk.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 00B6FACE210E88ED007535CF /* prod-1.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = 00B6FACD210E874D007535CF /* prod-1.mobileprovision */; }; 00B6FAD1210E8D90007535CF /* dev-1.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = 00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */; }; 00CB31B621096129004ACDEC /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00CB31B4210960C4004ACDEC /* TestUtils.swift */; }; + 092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 092D01932D3038F600E3066A /* NotificationObserverTests.swift */; }; 1CBFFE1A2A97AEEF00ED57EE /* EmbeddedManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */; }; 1CBFFE1B2A97AEEF00ED57EE /* EmbeddedMessagingProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */; }; 1CBFFE1C2A97AEEF00ED57EE /* EmbeddedSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */; }; @@ -543,6 +544,7 @@ 00B6FACD210E874D007535CF /* prod-1.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "prod-1.mobileprovision"; sourceTree = ""; }; 00B6FAD0210E8D90007535CF /* dev-1.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "dev-1.mobileprovision"; sourceTree = ""; }; 00CB31B4210960C4004ACDEC /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; + 092D01932D3038F600E3066A /* NotificationObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationObserverTests.swift; sourceTree = ""; }; 1CBFFE162A97AEEE00ED57EE /* EmbeddedManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedManagerTests.swift; sourceTree = ""; }; 1CBFFE172A97AEEE00ED57EE /* EmbeddedMessagingProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedMessagingProcessorTests.swift; sourceTree = ""; }; 1CBFFE182A97AEEE00ED57EE /* EmbeddedSessionManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedSessionManagerTests.swift; sourceTree = ""; }; @@ -936,6 +938,7 @@ 552A0AA9280E249C00A80963 /* notification-tests */ = { isa = PBXGroup; children = ( + 092D01932D3038F600E3066A /* NotificationObserverTests.swift */, 55B37FC32297135F0042F13A /* NotificationMetadataTests.swift */, AC2C667F20D31B1F00D46CC9 /* NotificationResponseTests.swift */, ); @@ -2186,6 +2189,7 @@ 5588DFD128C0465E000697D7 /* MockAPNSTypeChecker.swift in Sources */, 00B6FACC210E8484007535CF /* APNSTypeCheckerTests.swift in Sources */, AC8F35A2239806B500302994 /* InboxViewControllerViewModelTests.swift in Sources */, + 092D01942D3038F600E3066A /* NotificationObserverTests.swift in Sources */, AC995F9A2166EEB50099A184 /* CommonMocks.swift in Sources */, 5588DFE128C046B7000697D7 /* MockLocalStorage.swift in Sources */, 1CBFFE1B2A97AEEF00ED57EE /* EmbeddedMessagingProcessorTests.swift in Sources */, diff --git a/swift-sdk/Core/Constants.swift b/swift-sdk/Core/Constants.swift index 36e7db983..9fd068404 100644 --- a/swift-sdk/Core/Constants.swift +++ b/swift-sdk/Core/Constants.swift @@ -57,7 +57,9 @@ enum Const { static let deviceId = "itbl_device_id" static let sdkVersion = "itbl_sdk_version" static let offlineMode = "itbl_offline_mode" - + static let isNotificationsEnabled = "itbl_isNotificationsEnabled" + static let hasStoredNotificationSetting = "itbl_hasStoredNotificationSetting" + static let attributionInfoExpiration = 24 } diff --git a/swift-sdk/Internal/InternalIterableAPI.swift b/swift-sdk/Internal/InternalIterableAPI.swift index c80e9d3cb..fc07929cd 100644 --- a/swift-sdk/Internal/InternalIterableAPI.swift +++ b/swift-sdk/Internal/InternalIterableAPI.swift @@ -176,7 +176,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { // MARK: - API Request Calls - func register(token: Data, + func register(token: String, onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) { guard let appName = pushIntegrationName else { @@ -187,8 +187,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { return } - hexToken = token.hexString() - let registerTokenInfo = RegisterTokenInfo(hexToken: token.hexString(), + hexToken = token + let registerTokenInfo = RegisterTokenInfo(hexToken: token, appName: appName, pushServicePlatform: config.pushPlatform, apnsType: dependencyContainer.apnsTypeChecker.apnsType, @@ -208,6 +208,12 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { ) } + func register(token: Data, + onSuccess: OnSuccessHandler? = nil, + onFailure: OnFailureHandler? = nil) { + register(token: token.hexString(), onSuccess: onSuccess, onFailure: onFailure) + } + @discardableResult func disableDeviceForCurrentUser(withOnSuccess onSuccess: OnSuccessHandler? = nil, onFailure: OnFailureHandler? = nil) -> Pending { @@ -216,12 +222,18 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { onFailure?(errorMessage, nil) return SendRequestError.createErroredFuture(reason: errorMessage) } + guard userId != nil || email != nil else { let errorMessage = "either userId or email must be present" onFailure?(errorMessage, nil) return SendRequestError.createErroredFuture(reason: errorMessage) } + // We need to call register token here so that we can trigger the device registration + // with the updated notification settings + + register(token: hexToken) + return requestHandler.disableDeviceForCurrentUser(hexToken: hexToken, withOnSuccess: onSuccess, onFailure: onFailure) } @@ -500,6 +512,8 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { private var _userId: String? private var _successCallback: OnSuccessHandler? = nil private var _failureCallback: OnFailureHandler? = nil + + private let notificationCenter: NotificationCenterProtocol /// the hex representation of this device token @@ -666,6 +680,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { localStorage = dependencyContainer.localStorage inAppDisplayer = dependencyContainer.inAppDisplayer urlOpener = dependencyContainer.urlOpener + notificationCenter = dependencyContainer.notificationCenter deepLinkManager = DeepLinkManager(redirectNetworkSessionProvider: dependencyContainer) } @@ -698,10 +713,44 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { requestHandler.start() checkRemoteConfiguration() + + addForegroundObservers() return inAppManager.start() } + private func addForegroundObservers() { + notificationCenter.addObserver(self, + selector: #selector(onAppDidBecomeActiveNotification(notification:)), + name: UIApplication.didBecomeActiveNotification, + object: nil) + } + + @objc private func onAppDidBecomeActiveNotification(notification: Notification) { + guard config.autoPushRegistration else { return } + + notificationStateProvider.isNotificationsEnabled { [weak self] systemEnabled in + guard let self = self else { return } + + let storedEnabled = self.localStorage.isNotificationsEnabled + let hasStoredPermission = self.localStorage.hasStoredNotificationSetting + + if self.isEitherUserIdOrEmailSet() { + if hasStoredPermission && (storedEnabled != systemEnabled) { + if !systemEnabled { + self.disableDeviceForCurrentUser() + } else { + self.notificationStateProvider.registerForRemoteNotifications() + } + } + + // Always store the current state + self.localStorage.isNotificationsEnabled = systemEnabled + self.localStorage.hasStoredNotificationSetting = true + } + } + } + private func handle(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { guard let launchOptions = launchOptions else { return @@ -772,6 +821,7 @@ final class InternalIterableAPI: NSObject, PushTrackerProtocol, AuthProvider { deinit { ITBInfo() + notificationCenter.removeObserver(self) requestHandler.stop() } } diff --git a/swift-sdk/Internal/IterableUserDefaults.swift b/swift-sdk/Internal/IterableUserDefaults.swift index 5b5fdaade..5c11ec791 100644 --- a/swift-sdk/Internal/IterableUserDefaults.swift +++ b/swift-sdk/Internal/IterableUserDefaults.swift @@ -64,12 +64,28 @@ class IterableUserDefaults { var offlineMode: Bool { get { - return bool(withKey: .offlineMode) + bool(withKey: .offlineMode) } set { save(bool: newValue, withKey: .offlineMode) } } + var isNotificationsEnabled: Bool { + get { + bool(withKey: .isNotificationsEnabled) + } set { + save(bool: newValue, withKey: .isNotificationsEnabled) + } + } + + var hasStoredNotificationSetting: Bool { + get { + bool(withKey: .hasStoredNotificationSetting) + } set { + save(bool: newValue, withKey: .hasStoredNotificationSetting) + } + } + func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { (try? codable(withKey: .attributionInfo, currentDate: currentDate)) ?? nil } @@ -196,6 +212,8 @@ class IterableUserDefaults { static let deviceId = UserDefaultsKey(value: Const.UserDefault.deviceId) static let sdkVersion = UserDefaultsKey(value: Const.UserDefault.sdkVersion) static let offlineMode = UserDefaultsKey(value: Const.UserDefault.offlineMode) + static let isNotificationsEnabled = UserDefaultsKey(value: Const.UserDefault.isNotificationsEnabled) + static let hasStoredNotificationSetting = UserDefaultsKey(value: Const.UserDefault.hasStoredNotificationSetting) } private struct Envelope: Codable { diff --git a/swift-sdk/Internal/Utilities/LocalStorage.swift b/swift-sdk/Internal/Utilities/LocalStorage.swift index 9e4a6fcc9..a6e049ec6 100644 --- a/swift-sdk/Internal/Utilities/LocalStorage.swift +++ b/swift-sdk/Internal/Utilities/LocalStorage.swift @@ -67,6 +67,22 @@ struct LocalStorage: LocalStorageProtocol { } } + var isNotificationsEnabled: Bool { + get { + iterableUserDefaults.isNotificationsEnabled + } set { + iterableUserDefaults.isNotificationsEnabled = newValue + } + } + + var hasStoredNotificationSetting: Bool { + get { + iterableUserDefaults.hasStoredNotificationSetting + } set { + iterableUserDefaults.hasStoredNotificationSetting = newValue + } + } + func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { iterableUserDefaults.getAttributionInfo(currentDate: currentDate) } diff --git a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift index 254ce3291..420d076db 100644 --- a/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift +++ b/swift-sdk/Internal/Utilities/LocalStorageProtocol.swift @@ -19,6 +19,10 @@ protocol LocalStorageProtocol { var offlineMode: Bool { get set } + var isNotificationsEnabled: Bool { get set } + + var hasStoredNotificationSetting: Bool { get set } + func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? func save(attributionInfo: IterableAttributionInfo?, withExpiration expiration: Date?) diff --git a/tests/common/MockLocalStorage.swift b/tests/common/MockLocalStorage.swift index ab148e719..56aaed4f0 100644 --- a/tests/common/MockLocalStorage.swift +++ b/tests/common/MockLocalStorage.swift @@ -21,6 +21,10 @@ class MockLocalStorage: LocalStorageProtocol { var offlineMode: Bool = false + var isNotificationsEnabled: Bool = false + + var hasStoredNotificationSetting: Bool = false + func getAttributionInfo(currentDate: Date) -> IterableAttributionInfo? { guard !MockLocalStorage.isExpired(expiration: attributionInfoExpiration, currentDate: currentDate) else { return nil diff --git a/tests/unit-tests/AutoRegistrationTests.swift b/tests/unit-tests/AutoRegistrationTests.swift index 47d0e0ab2..b2441d362 100644 --- a/tests/unit-tests/AutoRegistrationTests.swift +++ b/tests/unit-tests/AutoRegistrationTests.swift @@ -20,6 +20,7 @@ class AutoRegistrationTests: XCTestCase { func testCallDisableAndEnable() { let expectation1 = expectation(description: "call register device API") + expectation1.expectedFulfillmentCount = 2 let expectation2 = expectation(description: "call registerForRemoteNotifications twice") expectation2.expectedFulfillmentCount = 2 let expectation3 = expectation(description: "call disable on user1@example.com") diff --git a/tests/unit-tests/Mocks.swift b/tests/unit-tests/Mocks.swift index a76afc556..8e254ab59 100644 --- a/tests/unit-tests/Mocks.swift +++ b/tests/unit-tests/Mocks.swift @@ -10,19 +10,19 @@ import XCTest // Note: This is used only by swift tests. So can't put this in Common class MockNotificationStateProvider: NotificationStateProviderProtocol { - func isNotificationsEnabled(withCallback callback: @escaping (Bool) -> Void) { - callback(enabled) - } - - func registerForRemoteNotifications() { - expectation?.fulfill() - } + var enabled: Bool + private let expectation: XCTestExpectation? init(enabled: Bool, expectation: XCTestExpectation? = nil) { self.enabled = enabled self.expectation = expectation } - private let enabled: Bool - private let expectation: XCTestExpectation? + func isNotificationsEnabled(withCallback callback: @escaping (Bool) -> Void) { + callback(enabled) + } + + func registerForRemoteNotifications() { + expectation?.fulfill() + } } diff --git a/tests/unit-tests/NotificationObserverTests.swift b/tests/unit-tests/NotificationObserverTests.swift new file mode 100644 index 000000000..3e410b17b --- /dev/null +++ b/tests/unit-tests/NotificationObserverTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import IterableSDK + +class NotificationObserverTests: XCTestCase { + private var internalAPI: InternalIterableAPI! + private var mockNotificationStateProvider: MockNotificationStateProvider! + private var mockLocalStorage: MockLocalStorage! + private var mockNotificationCenter: MockNotificationCenter! + + override func setUp() { + super.setUp() + + mockNotificationStateProvider = MockNotificationStateProvider(enabled: false) + mockLocalStorage = MockLocalStorage() + mockNotificationCenter = MockNotificationCenter() + + let config = IterableConfig() + internalAPI = InternalIterableAPI.initializeForTesting( + config: config, + notificationStateProvider: mockNotificationStateProvider, + localStorage: mockLocalStorage, + notificationCenter: mockNotificationCenter + ) + } + + func testNotificationStateChangeUpdatesStorage() { + // Arrange + internalAPI.email = "johnappleseed@iterable.com" + + mockLocalStorage.isNotificationsEnabled = false + mockNotificationStateProvider.enabled = true + + // Act + mockNotificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil, userInfo: nil) + + // Small delay to allow async operation to complete + let expectation = XCTestExpectation(description: "Wait for state update") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Assert + XCTAssertTrue(mockLocalStorage.isNotificationsEnabled) + } +}