diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 1b8d0e5b3..afef173c7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -514,28 +514,7 @@ extension OSUserExecutor { } } - // TODO: Determine how to hydrate the push subscription, which is still faulty. - // Hydrate by token if sub_id exists? - // Problem: a user can have multiple iOS push subscription, and perhaps missing token - // Ideally we only get push subscription for this device in the response, not others - - // Hydrate the push subscription if we don't already have a subscription ID AND token matches the original request - if OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId == nil, - let subscriptionObject = parseSubscriptionObjectResponse(response) - { - for subModel in subscriptionObject { - if subModel["type"] as? String == "iOSPush", - // response may have "" token or no token - areTokensEqual(tokenA: originalPushToken, tokenB: subModel["token"] as? String) - { - OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.hydrate(subModel) - if addNewRecords, let subId = subModel["id"] as? String { - newRecordsState.add(subId) - } - break - } - } - } + hydratePushSubscription(response: response, originalPushToken: originalPushToken, addNewRecords: addNewRecords) // Hydrate onto the user this response is for // If user has changed, don't hydrate, except for push subscription above @@ -576,6 +555,44 @@ extension OSUserExecutor { } } + /// Hydrates the push subscription from a fetch or create response: the whole object before a + /// subscription ID exists, only the server's remote disable state once one does. + // TODO: Determine how to hydrate the push subscription, which is still faulty. + // Hydrate by token if sub_id exists? + // Problem: a user can have multiple iOS push subscription, and perhaps missing token + // Ideally we only get push subscription for this device in the response, not others + private func hydratePushSubscription(response: [AnyHashable: Any], originalPushToken: String?, addNewRecords: Bool) { + guard let subscriptionObject = parseSubscriptionObjectResponse(response) else { + return + } + // The response's subscription ID is recorded as a new record even when the model is absent. + let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel + + // Hydrate the push subscription if we don't already have a subscription ID AND token matches the original request + guard let subscriptionId = pushSubscriptionModel?.subscriptionId else { + for subModel in subscriptionObject { + if subModel["type"] as? String == "iOSPush", + // response may have "" token or no token + areTokensEqual(tokenA: originalPushToken, tokenB: subModel["token"] as? String) + { + pushSubscriptionModel?.hydrate(subModel) + if addNewRecords, let subId = subModel["id"] as? String { + newRecordsState.add(subId) + } + break + } + } + return + } + + // Only the remote disable state hydrates onto an existing push subscription; the device + // owns the rest. Skipping it lets the next subscription payload re-enable a suppressed device. + for subModel in subscriptionObject where subModel["id"] as? String == subscriptionId { + pushSubscriptionModel?.hydrateRemoteDisableState(from: subModel) + break + } + } + /** Returns if 2 tokens are equal. This is needed as a nil token is equal to the empty string "". */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index cc9dd2211..345c7d310 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -94,6 +94,30 @@ enum OSSubscriptionType: String { case sms = "SMS" } +/** + The `notification_types` codes the app owner sets remotely, meaning the server turned the + subscription off rather than the device failing to register. The SDK treats them identically but + records them separately, so outgoing payloads echo back the code the server sent. Every other + negative code is a device or delivery error the device recovers from by re-asserting its own state. + */ +enum OSRemoteDisable { + /// Unsubscribed by hand from the dashboard. + static let manuallyUnsubscribed = -22 + + /// Disabled through the REST API. + static let restApiDisabled = -31 + + private static let allCodes: Set = [manuallyUnsubscribed, restApiDisabled] + + /// True when `notificationTypes` is one of the codes the app owner sets remotely. + static func matches(_ notificationTypes: Int?) -> Bool { + guard let notificationTypes else { + return false + } + return allCodes.contains(notificationTypes) + } +} + /** Internal subscription model. */ @@ -114,6 +138,10 @@ class OSSubscriptionModel: OSModel { var deviceModel: String? var appVersion: String? var netType: Int? + var remoteDisabledReason: Int? + // Not persisted; an optIn() clear outranks stale hydration until the server reports + // the subscription in another state. + var remoteDisableClearedByUser = false } /** @@ -182,6 +210,11 @@ class OSSubscriptionModel: OSModel { return } + // The disable code describes a specific server record; the record is gone when the ID resets. + if newValue == nil { + remoteDisabledReason = nil + } + // Cache the subscriptionId to UserDefaults for routine reads, and the OSResilientStorage mirror OneSignalUserDefaults.initShared().saveString(forKey: OSUD_PUSH_SUBSCRIPTION_ID, withValue: newValue) OSResilientStorage.setString(newValue ?? "", forKey: OSResilientStorage.keySubscriptionId) @@ -194,15 +227,24 @@ class OSSubscriptionModel: OSModel { var enabled: Bool { // Does not consider subscription_id in the calculation get { let state = snapshot() - return calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) + return calculateIsEnabled( + address: state.address, + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) } } var optedIn: Bool { - // optedIn = permission + userPreference + // optedIn = permission + userPreference + not suppressed by the app owner get { let state = snapshot() - return calculateIsOptedIn(reachable: state.reachable, isDisabled: state.isDisabled) + return calculateIsOptedIn( + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) } } @@ -261,6 +303,26 @@ class OSSubscriptionModel: OSModel { } } + /** + The server's remote disable code, either -22 (unsubscribed by hand from the dashboard) or -31 + (disabled through the REST API), or nil when the server has not disabled this subscription. The + two codes are kept apart so payloads echo back the one the server sent rather than collapsing + them. Hydrated from responses, never derived from device state, and echoed back in payloads so + routine updates and logins don't re-enable a suppressed subscription. Cleared when a response + reports any other state, when the subscription ID resets, or by `optIn()`. + */ + var remoteDisabledReason: Int? { + get { stateLock.withLock { state.remoteDisabledReason } } + set { + let oldValue = swapValue(\.remoteDisabledReason, to: newValue) + guard newValue != oldValue else { + return + } + // Mirrors server state rather than a local change, so persist without generating a delta. + self.set(property: "remoteDisabledReason", newValue: newValue, preventServerUpdate: true) + } + } + // Properties for push subscription var testType: Int? { get { stateLock.withLock { state.testType } } @@ -371,7 +433,9 @@ class OSSubscriptionModel: OSModel { sdk: ONESIGNAL_VERSION, deviceModel: OSDeviceUtils.getDeviceVariant(), appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, - netType: OSNetworkingUtils.getNetType() as? Int + netType: OSNetworkingUtils.getNetType() as? Int, + remoteDisabledReason: nil, + remoteDisableClearedByUser: false ) super.init(changeNotifier: changeNotifier) @@ -393,6 +457,7 @@ class OSSubscriptionModel: OSModel { coder.encode(state.deviceModel, forKey: "deviceModel") coder.encode(state.appVersion, forKey: "appVersion") coder.encode(state.netType, forKey: "netType") + coder.encode(state.remoteDisabledReason, forKey: "remoteDisabledReason") } required init?(coder: NSCoder) { @@ -415,7 +480,9 @@ class OSSubscriptionModel: OSModel { sdk: coder.decodeObject(forKey: "sdk") as? String ?? ONESIGNAL_VERSION, deviceModel: coder.decodeObject(forKey: "deviceModel") as? String, appVersion: coder.decodeObject(forKey: "appVersion") as? String, - netType: coder.decodeObject(forKey: "netType") as? Int + netType: coder.decodeObject(forKey: "netType") as? Int, + remoteDisabledReason: coder.decodeObject(forKey: "remoteDisabledReason") as? Int, + remoteDisableClearedByUser: false ) super.init(coder: coder) @@ -436,13 +503,11 @@ class OSSubscriptionModel: OSModel { // self.address = property.value as? String case "enabled": if let enabled = property.value as? Bool { - if self.enabled != enabled { // TODO: Is this right? - _isDisabled = !enabled - } + hydrateEnabled(enabled, response: response) } case "notification_types": if let notificationTypes = property.value as? Int { - self.notificationTypes = notificationTypes + hydrateNotificationTypes(notificationTypes) } default: OneSignalLog.onesignalLog(.LL_DEBUG, message: "Unused property on subscription model") @@ -450,6 +515,33 @@ class OSSubscriptionModel: OSModel { } } + /// Applies a hydrated `enabled`. A remote disable is server-owned, not a user opt-out, + /// so it must not flip `_isDisabled`; the notification_types hydration records it instead. + private func hydrateEnabled(_ enabled: Bool, response: [String: Any]) { + guard !isRemoteDisable(response) else { + return + } + if self.enabled != enabled { // TODO: Is this right? + _isDisabled = !enabled + } + } + + /// Routes a hydrated notification_types: -22 and -31 record the server's disable verbatim, so + /// the recorded code stays distinguishable; any other value clears it and becomes the device value. + private func hydrateNotificationTypes(_ value: Int) { + if OSRemoteDisable.matches(value) { + recordRemoteDisable(value) + } else { + acceptServerNonDisabledState() + self.notificationTypes = value + } + } + + /// True when the response's notification_types carries a remote disable code. + private func isRemoteDisable(_ response: [String: Any]) -> Bool { + return OSRemoteDisable.matches(response["notification_types"] as? Int) + } + // Using snake_case so we can use this in request bodies public func jsonRepresentation() -> [String: Any] { let state = snapshot() @@ -457,19 +549,22 @@ class OSSubscriptionModel: OSModel { json["id"] = state.subscriptionId json["type"] = state.type.rawValue json["token"] = state.address - json["enabled"] = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) + json["enabled"] = calculateIsEnabled( + address: state.address, + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) json["test_type"] = state.testType json["device_os"] = state.deviceOs json["sdk"] = state.sdk json["device_model"] = state.deviceModel json["app_version"] = state.appVersion json["net_type"] = state.netType - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if state.notificationTypes != -1 { - json["notification_types"] = state.notificationTypes - } + json["notification_types"] = outboundNotificationTypes(state) return json } + } // Push Subscription related @@ -479,26 +574,141 @@ extension OSSubscriptionModel { let state = snapshot() return OSPushSubscriptionState(id: state.subscriptionId, token: state.address, - optedIn: calculateIsOptedIn(reachable: state.reachable, isDisabled: state.isDisabled) + optedIn: calculateIsOptedIn( + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) ) } // Calculates if the device is opted in to push notification. - // Must have permission and not be opted out. - func calculateIsOptedIn(reachable: Bool, isDisabled: Bool) -> Bool { - return reachable && !isDisabled + // Must have permission, not be opted out, and not be disabled remotely by the app owner. A + // remote disable suppresses delivery just as surely as a missing permission or an opt-out, and + // it is the only one of the three the app cannot see any other way. + func calculateIsOptedIn(reachable: Bool, isDisabled: Bool, remoteDisabledReason: Int?) -> Bool { + return reachable && !isDisabled && remoteDisabledReason == nil } // Calculates if push notifications are enabled on the device. // Does not consider the existence of the subscription_id, as we send this in the request to create a push subscription. - func calculateIsEnabled(address: String?, reachable: Bool, isDisabled: Bool) -> Bool { - return address != nil && reachable && !isDisabled + func calculateIsEnabled(address: String?, reachable: Bool, isDisabled: Bool, remoteDisabledReason: Int?) -> Bool { + return address != nil && reachable && !isDisabled && remoteDisabledReason == nil } func updateNotificationTypes() { notificationTypes = Int(OSNotificationsManager.getNotificationTypes(_isDisabled)) } + /// Records the server's disable code verbatim, so -22 and -31 stay distinguishable, unless + /// `optIn()` cleared one and the server has not yet reported the subscription in another state; + /// the user's explicit intent wins that race. + private func recordRemoteDisable(_ code: Int) { + let (changed, previousReason) = stateLock.withLock { () -> (Bool, Int?) in + guard !state.remoteDisableClearedByUser, state.remoteDisabledReason != code else { + return (false, nil) + } + let previousReason = state.remoteDisabledReason + state.remoteDisabledReason = code + return (true, previousReason) + } + guard changed else { + return + } + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSSubscriptionModel: recording remote disable \(code) for push subscription \(subscriptionId ?? "nil")" + ) + self.set(property: "remoteDisabledReason", newValue: code, preventServerUpdate: true) + // The disable takes `optedIn` to false, which observers need to hear about. + firePushSubscriptionChanged(.remoteDisabledReason(previousReason), generateEnabledDelta: false) + } + + /// Clears `remoteDisabledReason` and re-arms recording once the server reports a non-disabled state. + private func acceptServerNonDisabledState() { + let clearedReason: Int? = stateLock.withLock { + state.remoteDisableClearedByUser = false + let previous = state.remoteDisabledReason + state.remoteDisabledReason = nil + return previous + } + guard let clearedReason else { + return + } + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSSubscriptionModel: clearing remote disable \(clearedReason) for push subscription \(subscriptionId ?? "nil")" + ) + self.set(property: "remoteDisabledReason", newValue: nil as Int?, preventServerUpdate: true) + // Clearing the disable takes `optedIn` back to true, which observers need to hear about. + firePushSubscriptionChanged(.remoteDisabledReason(clearedReason), generateEnabledDelta: false) + } + + /// notification_types for outgoing payloads: the recorded disable code (the positive device + /// value would re-enable it), else the device value, nil for the -1 default. + private func outboundNotificationTypes(_ state: State) -> Int? { + if let remoteDisabledReason = state.remoteDisabledReason { + return remoteDisabledReason + } + return state.notificationTypes != -1 ? state.notificationTypes : nil + } + + /// The PATCH body for a subscription update, built from one snapshot so a concurrent hydration + /// or `optIn()` can't tear `enabled` away from `notification_types`. + func updateParams() -> [String: Any] { + let state = snapshot() + var params: [String: Any] = [:] + params["token"] = state.address + params["device_os"] = state.deviceOs + params["sdk"] = state.sdk + params["app_version"] = state.appVersion + params["notification_types"] = outboundNotificationTypes(state) + params["enabled"] = calculateIsEnabled( + address: state.address, + reachable: state.reachable, + isDisabled: state.isDisabled, + remoteDisabledReason: state.remoteDisabledReason + ) + return params + } + + /** + Mirrors the server's remote disable state from a fetched subscription object: -22 and -31 are + recorded verbatim, any other reported value clears it. The device stays the source of truth for + the rest of an existing subscription's state, so nothing else is read. + */ + func hydrateRemoteDisableState(from serverSubscription: [String: Any]) { + guard type == .push, let serverTypes = serverSubscription["notification_types"] as? Int else { + return + } + if OSRemoteDisable.matches(serverTypes) { + recordRemoteDisable(serverTypes) + } else { + acceptServerNonDisabledState() + } + } + + /** + Clears a remote disable and enqueues an enabled-change delta so the server re-enables the + subscription. Called from `optIn()`, where a deliberate user action overrides the suppression. + */ + func clearRemoteDisable() { + let oldValue: Int? = stateLock.withLock { + // The flag arms on every opt-in, not only when a disable was already recorded, because + // a fetch issued before this opt-in can still land the customer's first disable and + // undo it. Nothing is recorded locally on that first cycle, which is the common case. + state.remoteDisableClearedByUser = true + let recorded = state.remoteDisabledReason + state.remoteDisabledReason = nil + return recorded + } + guard let oldValue else { + return + } + self.set(property: "remoteDisabledReason", newValue: nil as Int?, preventServerUpdate: true) + firePushSubscriptionChanged(.remoteDisabledReason(oldValue)) + } + func updateTestType() { let releaseMode: OSUIApplicationReleaseMode = OneSignalMobileProvision.releaseMode() // Workaround to unsure how to extract the Int value in 1 step... @@ -532,40 +742,62 @@ extension OSSubscriptionModel { case reachable(Bool) case isDisabled(Bool) case address(String?) - } - - func firePushSubscriptionChanged(_ changedProperty: OSPushPropertyChanged) { - var prevIsOptedIn = true - var prevIsEnabled = true - var prevSubscriptionState = OSPushSubscriptionState(id: "", token: "", optedIn: true) + case remoteDisabledReason(Int?) + } + + /// Notifies push subscription observers of the state after `changedProperty` changed. + /// + /// Pass `generateEnabledDelta: false` when the change came from a server response: the server + /// already holds that state, so enqueueing an `enabled` delta for it would send a request that + /// tells the server what it just told us. + func firePushSubscriptionChanged(_ changedProperty: OSPushPropertyChanged, generateEnabledDelta: Bool = true) { + // The previous state is the current state with only the changed property's old value substituted. + var prevId = subscriptionId + var prevAddress = address + var prevReachable = _reachable + var prevIsDisabled = _isDisabled + var prevRemoteDisabledReason = remoteDisabledReason switch changedProperty { case .subscriptionId(let oldValue): - prevIsEnabled = calculateIsEnabled(address: address, reachable: _reachable, isDisabled: _isDisabled) - prevIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: _isDisabled) - prevSubscriptionState = OSPushSubscriptionState(id: oldValue, token: address, optedIn: prevIsOptedIn) - + prevId = oldValue case .reachable(let oldValue): - prevIsEnabled = calculateIsEnabled(address: address, reachable: oldValue, isDisabled: _isDisabled) - prevIsOptedIn = calculateIsOptedIn(reachable: oldValue, isDisabled: _isDisabled) - prevSubscriptionState = OSPushSubscriptionState(id: subscriptionId, token: address, optedIn: prevIsOptedIn) - + prevReachable = oldValue case .isDisabled(let oldValue): - prevIsEnabled = calculateIsEnabled(address: address, reachable: _reachable, isDisabled: oldValue) - prevIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: oldValue) - prevSubscriptionState = OSPushSubscriptionState(id: subscriptionId, token: address, optedIn: prevIsOptedIn) - + prevIsDisabled = oldValue case .address(let oldValue): - prevIsEnabled = calculateIsEnabled(address: oldValue, reachable: _reachable, isDisabled: _isDisabled) - prevIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: _isDisabled) - prevSubscriptionState = OSPushSubscriptionState(id: subscriptionId, token: oldValue, optedIn: prevIsOptedIn) + prevAddress = oldValue + case .remoteDisabledReason(let oldValue): + prevRemoteDisabledReason = oldValue } - let newIsOptedIn = calculateIsOptedIn(reachable: _reachable, isDisabled: _isDisabled) + let prevIsEnabled = calculateIsEnabled( + address: prevAddress, + reachable: prevReachable, + isDisabled: prevIsDisabled, + remoteDisabledReason: prevRemoteDisabledReason + ) + let prevIsOptedIn = calculateIsOptedIn( + reachable: prevReachable, + isDisabled: prevIsDisabled, + remoteDisabledReason: prevRemoteDisabledReason + ) + let prevSubscriptionState = OSPushSubscriptionState(id: prevId, token: prevAddress, optedIn: prevIsOptedIn) - let newIsEnabled = calculateIsEnabled(address: address, reachable: _reachable, isDisabled: _isDisabled) + let newIsOptedIn = calculateIsOptedIn( + reachable: _reachable, + isDisabled: _isDisabled, + remoteDisabledReason: remoteDisabledReason + ) + + let newIsEnabled = calculateIsEnabled( + address: address, + reachable: _reachable, + isDisabled: _isDisabled, + remoteDisabledReason: remoteDisabledReason + ) - if prevIsEnabled != newIsEnabled { + if generateEnabledDelta && prevIsEnabled != newIsEnabled { self.set(property: "enabled", newValue: newIsEnabled) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index d9850691f..8385981a3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -96,6 +96,9 @@ import OneSignalNotifications @objc public protocol OSPushSubscription { var id: String? { get } var token: String? { get } + /// The user's preference combined with OS permission. This is false while the app owner has the + /// subscription disabled remotely, from the dashboard or the REST API; `optIn()` clears that + /// suppression. var optedIn: Bool { get } func optIn() @@ -932,7 +935,9 @@ extension OneSignalUserManagerImpl { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { return } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false + let model = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) + model?._isDisabled = false + model?.clearRemoteDisable() OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift index 3f211fad5..bce0be310 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift @@ -57,17 +57,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { /// Rebuild the PATCH body from the current subscription model. func refreshParametersFromLiveModel() { - var subscriptionParams: [String: Any] = [:] - subscriptionParams["token"] = subscriptionModel.address - subscriptionParams["device_os"] = subscriptionModel.deviceOs - subscriptionParams["sdk"] = subscriptionModel.sdk - subscriptionParams["app_version"] = subscriptionModel.appVersion - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if subscriptionModel.notificationTypes != -1 { - subscriptionParams["notification_types"] = subscriptionModel.notificationTypes - } - subscriptionParams["enabled"] = subscriptionModel.enabled - self.parameters = ["subscription": subscriptionParams] + self.parameters = ["subscription": subscriptionModel.updateParams()] } init(subscriptionModel: OSSubscriptionModel) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index e130af24b..d901bd2b5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -324,4 +324,378 @@ final class OneSignalUserTests: XCTestCase { XCTAssertNil(manager._user) XCTAssertNil(manager.currentUser(matching: identityModel.modelId)) } + + // MARK: - Remotely disabled push subscriptions + + /// The notification_types codes the app owner sets remotely: -22 unsubscribes the subscription + /// by hand from the dashboard, -31 disables it through the REST API. Both mean "the server + /// turned this off" and are treated identically, but each is recorded as itself so outgoing + /// payloads echo back the code the server actually sent. + private static let remoteDisableCodes = [-22, -31] + + /// A push model hydrated with the server's remote disable state for `code`. + private func pushModelWithRemoteDisable(_ code: Int = -31) -> OSSubscriptionModel { + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + return model + } + + func testRemoteDisable_recognizesOnlyTheTwoServerOwnedCodes() { + for code in Self.remoteDisableCodes { + XCTAssertTrue( + OSRemoteDisable.matches(code), + "\(code) is a code the app owner sets remotely and must suppress outgoing payloads" + ) + } + // Every other code describes device or delivery state the device recovers from by + // re-asserting its own truth. Treating one as a remote disable would permanently suppress + // a subscription the device could have re-enabled, so the boundary matters more than the + // list: -21/-23/-24 sit inside the range reserved for other platforms, and -30/-32 + // bracket the REST API code. + for code in [1, 0, -2, -3, -13, -21, -23, -24, -25, -30, -32] { + XCTAssertFalse( + OSRemoteDisable.matches(code), + "\(code) is device-recoverable and must not be recorded as a remote disable" + ) + } + XCTAssertFalse(OSRemoteDisable.matches(nil)) + } + + func testRemoteDisable_overridesOutgoingSubscriptionPayloads() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + XCTAssertEqual(model.remoteDisabledReason, code) + + // The recorded code round-trips rather than collapsing to the other one. + let json = model.jsonRepresentation() + XCTAssertEqual(json["enabled"] as? Bool, false) + XCTAssertEqual(json["notification_types"] as? Int, code) + + let updateRequest = OSRequestUpdateSubscription(subscriptionModel: model) + let params = updateRequest.parameters?["subscription"] as? [String: Any] + XCTAssertEqual(params?["enabled"] as? Bool, false) + XCTAssertEqual(params?["notification_types"] as? Int, code) + } + } + + func testRemoteDisable_survivesDeviceStateRefresh() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + // Device-driven recomputes must not clear server-owned disable state + model.updateNotificationTypes() + model.update() + + XCTAssertEqual(model.remoteDisabledReason, code) + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, false) + } + } + + func testRemoteDisable_survivesArchiving() throws { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + let restored = try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSSubscriptionModel) + + XCTAssertEqual(restored.remoteDisabledReason, code) + XCTAssertEqual(restored.jsonRepresentation()["enabled"] as? Bool, false) + } + } + + func testRemoteDisable_clearedByOptIn() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + model.clearRemoteDisable() + + XCTAssertNil(model.remoteDisabledReason) + let json = model.jsonRepresentation() + XCTAssertEqual(json["enabled"] as? Bool, true) + XCTAssertNotEqual(json["notification_types"] as? Int, code) + } + } + + func testRemoteDisable_mirrorsServerField() { + // Only -22 and -31 are ever recorded; any other reported value is not, and clears an + // existing disable. Each code is recorded as itself, including replacing the other one. + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": -2]) + XCTAssertNil(model.remoteDisabledReason) + + for code in Self.remoteDisableCodes { + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertEqual(model.remoteDisabledReason, code) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": -2]) + XCTAssertNil(model.remoteDisabledReason) + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, true) + } + } + + func testRemoteDisable_switchingBetweenTheTwoCodesRecordsTheLatest() { + // The dashboard and the REST API can both act on the same subscription, so a second + // disable arriving under the other code must replace the recorded one rather than be + // ignored as "already disabled". + let model = pushModelWithRemoteDisable(-22) + XCTAssertEqual(model.remoteDisabledReason, -22) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": -31]) + + XCTAssertEqual(model.remoteDisabledReason, -31) + XCTAssertEqual(model.jsonRepresentation()["notification_types"] as? Int, -31) + } + + func testRemoteDisable_clearedWhenSubscriptionIdResets() { + // The disable code describes a specific server record; it must die with the record. + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + model.subscriptionId = nil + + XCTAssertNil(model.remoteDisabledReason) + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, true) + } + } + + func testRemoteDisable_optInOutranksStaleHydration() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + // A stale fetch response landing after optIn() cleared the disable must not re-record it + model.clearRemoteDisable() + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertNil(model.remoteDisabledReason) + + // Once the server reports another state, recording re-arms for a later operator disable + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": true, "notification_types": 1]) + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertEqual(model.remoteDisabledReason, code) + } + } + + func testRemoteDisable_optInOutranksStaleHydrationWithNothingRecorded() { + // The customer's first disable is the common case for this race, and it is the one a + // "only arm when a disable was already recorded" flag would miss. The customer disables + // the subscription, a fetch goes out that will report it, and the user calls optIn() + // before that response lands. Nothing is recorded locally at that point, so the opt-in + // has to arm the guard anyway or the fetch re-suppresses the subscription the user just + // opted into, and the next update re-sends the code. Android pins the same behavior in + // "optIn takes precedence over a pending fetch even when no remote disable was recorded". + let model = OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + XCTAssertNil(model.remoteDisabledReason) + + model.clearRemoteDisable() + + for code in Self.remoteDisableCodes { + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + XCTAssertNil(model.remoteDisabledReason, "a fetch predating optIn() must not record \(code)") + XCTAssertEqual(model.jsonRepresentation()["enabled"] as? Bool, true) + } + } + + func testRemoteDisable_clearedWhenServerReportsEnabled() { + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": true, "notification_types": 1]) + + XCTAssertNil(model.remoteDisabledReason) + } + } + + /** + A push subscription the app owner turned off remotely must stay disabled across a login to a + different external ID. The fetch hydrates the server's disable state onto the existing + subscription, and the login's Create User payload echoes it back. + + Uses -22 (unsubscribed by hand from the dashboard) rather than -31 so this end-to-end path also + proves the exact recorded code survives, instead of every remote disable reporting as -31. + */ + func testLoginToDifferentUser_afterRemoteDisable_sendsDisabledPushSubscription() throws { + /* Setup */ + let client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userB_EUID) + + // Fetching user A reports the push subscription unsubscribed from the dashboard + var disabledResponse = MockUserRequests.testDefaultFullCreateUserResponse( + onesignalId: anonUserOSID, + externalId: userA_EUID, + subscriptionId: testPushSubId + ) + let disabledSub = MockUserRequests.testDefaultPushSubPayload(id: testPushSubId) + .merging(["enabled": false, "notification_types": -22]) { _, new in new } + disabledResponse["subscriptions"] = [disabledSub] + client.setMockResponseForRequest( + request: "", + response: disabledResponse + ) + OneSignalCoreImpl.setSharedClient(client) + + // 1. Start with an anonymous user and log in to user A; the post-identify fetch + // hydrates the remote disable onto the existing push subscription + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + + OneSignalCoreMocks.waitUntil("Fetch did not hydrate the remote disable") { + OneSignalUserManagerImpl.sharedInstance.user.identityModel.externalId == userA_EUID && + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.remoteDisabledReason == -22 + } + + /* When */ + + // 2. Log in to user B, which sends a Create User carrying the push subscription + OneSignalUserManagerImpl.sharedInstance.login(externalId: userB_EUID, token: nil) + + func createUserBRequest() -> OSRequestCreateUser? { + return client.executedRequests.compactMap { $0 as? OSRequestCreateUser }.first { + ($0.parameters?["identity"] as? [String: String])?[OS_EXTERNAL_ID] == userB_EUID + } + } + OneSignalCoreMocks.waitUntil("Create User for user B was not sent") { + createUserBRequest() != nil + } + + /* Then */ + + let subscriptions = try XCTUnwrap(createUserBRequest()?.parameters?["subscriptions"] as? [[String: Any]]) + XCTAssertEqual(subscriptions.first?["enabled"] as? Bool, false) + XCTAssertEqual(subscriptions.first?["notification_types"] as? Int, -22) + } +} + + +/** + `optedIn` is the only signal the public API gives an app for "will push reach this device", so a + subscription the app owner turned off remotely has to report false there. Kept in its own class + because these build bare models and never touch the user manager singleton. + */ +final class RemoteDisableOptedInTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + // Firing a push subscription change reaches the user manager singleton and can enqueue a + // delta, so reset it between tests the way the rest of this suite does. + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + } + + /// The notification_types codes the app owner sets remotely: -22 from the dashboard, -31 through + /// the REST API. Treated identically, recorded separately. + private static let remoteDisableCodes = [-22, -31] + + private func makePushModel() -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .push, + address: "test-token", + subscriptionId: "test-sub-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + private func pushModelWithRemoteDisable(_ code: Int) -> OSSubscriptionModel { + let model = makePushModel() + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "enabled": false, "notification_types": code]) + return model + } + + func testRemoteDisable_reportsOptedInFalse() { + // A remote disable suppresses delivery, so the property clients read to decide whether push + // works must say so. Before this, a preference center showed "subscribed" on a device the + // app owner had turned off, and nothing in the public API revealed why. + for code in Self.remoteDisableCodes { + let model = pushModelWithRemoteDisable(code) + + XCTAssertFalse(model.optedIn, "a \(code) disable must report optedIn false") + // currentPushSubscriptionState builds the observer payload, so it has to agree. + XCTAssertFalse(model.currentPushSubscriptionState.optedIn) + + // The toggle a client drives off is not a dead end: opting in reports true again. + model.clearRemoteDisable() + XCTAssertTrue(model.optedIn) + XCTAssertTrue(model.currentPushSubscriptionState.optedIn) + } + } + + func testRemoteDisable_optedInIgnoresDeviceRecoverableCodes() { + // Only the two server-owned codes reach optedIn, and they arrive through + // remoteDisabledReason rather than notificationTypes. A device-side delivery error is + // recoverable by re-asserting local state, so it must not read as an opt-out to the app. + let model = makePushModel() + + for code in [-2, -13, -25, -30, -32] { + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": code]) + XCTAssertTrue(model.optedIn, "\(code) is device-recoverable and must not clear optedIn") + } + } + + func testRemoteDisable_hydrationDoesNotEnqueueAnEnabledDelta() { + // Hydrating the disable flips optedIn, which observers need to hear about, but the server + // is where the state came from. Enqueueing an `enabled` delta for it would send a request + // telling the server what it just told us, so the observer fires without one. + for code in Self.remoteDisableCodes { + let model = makePushModel() + let spy = SpyModelChangedHandler() + model.changeNotifier.subscribe(spy) + + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": code]) + XCTAssertEqual(model.remoteDisabledReason, code) + XCTAssertTrue(spy.serverUpdates.isEmpty, "hydrating \(code) must not enqueue a delta") + // The reason was still written, so the assertion above is not passing vacuously. + XCTAssertTrue(spy.hydratedUpdates.contains("remoteDisabledReason")) + + // Clearing it from the server side is the same story. + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": -2]) + XCTAssertNil(model.remoteDisabledReason) + XCTAssertFalse(spy.serverUpdates.contains("enabled")) + + // An optIn() clear is the opposite case: the delta is how the server re-enables it. + model.hydrateRemoteDisableState(from: ["id": "test-sub-id", "notification_types": code]) + spy.serverUpdates.removeAll() + model.clearRemoteDisable() + XCTAssertTrue(spy.serverUpdates.contains("enabled"), "optIn() must re-enable on the server") + } + } +} + +/// Records the properties a model reported as changed, split by whether the change was meant to +/// reach the server. `hydrating` is true for writes that only mirror state the server already has. +private class SpyModelChangedHandler: OSModelChangedHandler { + var serverUpdates: [String] = [] + var hydratedUpdates: [String] = [] + + func onModelUpdated(args: OSModelChangedArgs, hydrating: Bool) { + if hydrating { + hydratedUpdates.append(args.property) + } else { + serverUpdates.append(args.property) + } + } }