diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a76259a58..b186a8af3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -82,6 +82,8 @@ A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */; }; A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; + A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; + A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1815,6 +1817,8 @@ A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshService.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingConfiguration.swift; sourceTree = ""; }; @@ -2305,6 +2309,7 @@ 3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */, 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, ); @@ -2607,6 +2612,7 @@ 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, @@ -4486,6 +4492,7 @@ A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */, A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, + A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, @@ -4654,6 +4661,7 @@ 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, + A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift new file mode 100644 index 000000000..4a57cd07f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -0,0 +1,257 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import UIKit + +/// Detects the test-user gesture: `requiredCycles` background/foreground cycles within +/// `windowSeconds`, then runs the handler installed by `start(handler:)`. The handler lives +/// in the user layer, which this framework cannot see, and sends the Update User request +/// that sets `test_user_name`. +/// +/// A cycle is a `didEnterBackground`/`didBecomeActive` pair whose background phase lasts at +/// least `minBackgroundDwellSeconds`. Pairing keeps `willResignActive`-only blips (Control +/// Center, Face ID) from counting; the floor matches Android, where rotation emits a +/// synthetic sub-millisecond pair. The window is the only rate rule; six cycles inside it +/// takes sustained five-second round trips. +/// +/// Adding `killSwitchKey` to the app's enabled feature keys disables the gesture. Absent +/// means enabled, so a device that has never fetched flags still has it. Reads the raw +/// `OSFeatureFlagsStore` list because `OSFeatureManager` only resolves keys the KMP catalog +/// registers. +@objc(OSDeviceGestureDetector) +public final class OSDeviceGestureDetector: NSObject { + static let requiredCycles = 6 + static let windowSeconds: TimeInterval = 30 + + /// Shortest background phase a human can produce; anything faster is synthetic. + static let minBackgroundDwellSeconds: TimeInterval = 0.25 + + static let killSwitchKey = "sdk_device_gesture_disabled" + + private static let lock = NSLock() + private static var _shared: OSDeviceGestureDetector? + + static var shared: OSDeviceGestureDetector { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSDeviceGestureDetector() + _shared = created + return created + } + } + + private let notificationCenter: NotificationCenter + private let workQueue: OSDispatchQueue + /// Monotonic clock, so wall-clock jumps from NTP or manual time changes cannot stretch + /// or shrink the window. + private let nowProvider: () -> TimeInterval + private let enabledFlagsProvider: () -> [String] + private let shouldAwaitProvider: () -> Bool + + private let stateLock = NSLock() + /// Installed by `start(handler:)`; first installer wins so repeated starts stay idempotent. + private var handler: (() -> Void)? + private var started = false + /// Set by `tearDown` and never cleared, so work already queued for an instance that + /// `shared` has dropped cannot fire the handler or re-register observers. + private var invalidated = false + private var notificationTokens: [NSObjectProtocol] = [] + private var lastBackgroundedAt: TimeInterval? + private var cycleCompletions: [TimeInterval] = [] + + init( + notificationCenter: NotificationCenter = .default, + workQueue: OSDispatchQueue = DispatchQueue.main, + nowProvider: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + enabledFlagsProvider: @escaping () -> [String] = { OSFeatureFlagsStore.shared.sdkRemoteFeatureFlags }, + shouldAwaitProvider: @escaping () -> Bool = { + OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) + }, + handler: (() -> Void)? = nil + ) { + self.notificationCenter = notificationCenter + self.workQueue = workQueue + self.nowProvider = nowProvider + self.enabledFlagsProvider = enabledFlagsProvider + self.shouldAwaitProvider = shouldAwaitProvider + self.handler = handler + super.init() + } + + /// Idempotent: registers the lifecycle observers once. The handler runs after a + /// completed gesture passes the readiness and kill-switch gates. + @objc public static func start(handler: @escaping () -> Void) { + let service = shared + service.adoptHandlerIfUnset(handler) + service.registerLifecycleObserversIfNeeded() + } + + @objc public static func reset() { + lock.withLock { + _shared?.tearDown() + _shared = nil + } + } + + private func adoptHandlerIfUnset(_ newHandler: @escaping () -> Void) { + stateLock.withLock { + if handler == nil { + handler = newHandler + } + } + } + + /// App-level rather than per-scene: UIKit posts `didEnterBackgroundNotification` only + /// once the last scene backgrounds, exactly the whole-app signal a cycle counter needs. + /// Per-scene events would over-count on multi-window iPad. + func registerLifecycleObserversIfNeeded() { + let shouldSkip = stateLock.withLock { () -> Bool in + if started || invalidated { + return true + } + started = true + return false + } + guard !shouldSkip else { + return + } + + observe(UIApplication.didEnterBackgroundNotification) { [weak self] in + self?.onUnfocused() + } + observe(UIApplication.didBecomeActiveNotification) { [weak self] in + self?.onFocus() + } + } + + private func observe(_ name: Notification.Name, handler: @escaping () -> Void) { + let token = notificationCenter.addObserver(forName: name, object: nil, queue: nil) { _ in + handler() + } + // A reset can land between registering above and recording below. Nothing else + // holds this token by then, so it has to be torn down here or it outlives the + // service with no way to reach it. + let recorded = stateLock.withLock { () -> Bool in + guard !invalidated else { + return false + } + notificationTokens.append(token) + return true + } + if !recorded { + notificationCenter.removeObserver(token) + } + } + + func tearDown() { + let tokens: [NSObjectProtocol] = stateLock.withLock { + invalidated = true + let current = notificationTokens + notificationTokens.removeAll() + started = false + handler = nil + lastBackgroundedAt = nil + cycleCompletions.removeAll() + return current + } + tokens.forEach(notificationCenter.removeObserver) + } + + func onUnfocused() { + let timestamp = nowProvider() + stateLock.withLock { + lastBackgroundedAt = timestamp + } + } + + func onFocus() { + let timestamp = nowProvider() + let completedGesture: Bool = stateLock.withLock { + let backgroundedAt = lastBackgroundedAt + lastBackgroundedAt = nil + guard let backgroundedAt else { + // Cold launch, or a repeated activation with no background in between. + return false + } + let dwell = timestamp - backgroundedAt + if dwell < Self.minBackgroundDwellSeconds { + // Faster than any human app switch; Android rotation emits pairs like this. + OneSignalLog.onesignalLog( + .LL_VERBOSE, + message: "OSDeviceGestureDetector: ignored a \(String(format: "%.3f", dwell))s background blip (rotation filter)" + ) + return false + } + cycleCompletions.append(timestamp) + cycleCompletions.removeAll { timestamp - $0 > Self.windowSeconds } + OneSignalLog.onesignalLog( + .LL_VERBOSE, + message: "OSDeviceGestureDetector: cycle \(cycleCompletions.count)/\(Self.requiredCycles) within the window " + + "(background \(String(format: "%.2f", dwell))s)" + ) + if cycleCompletions.count >= Self.requiredCycles { + cycleCompletions.removeAll() + return true + } + return false + } + if completedGesture { + fireHandler() + } + } + + private func fireHandler() { + workQueue.async { [weak self] in + guard let self, !self.stateLock.withLock({ self.invalidated }) else { + return + } + guard !self.shouldAwaitProvider() else { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSDeviceGestureDetector: gesture detected but the SDK is not ready (appId, consent, or storage)" + ) + return + } + let disabled = self.enabledFlagsProvider().contains { + $0.caseInsensitiveCompare(Self.killSwitchKey) == .orderedSame + } + guard !disabled else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but disabled remotely") + return + } + guard let handler = self.stateLock.withLock({ self.handler }) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but no handler is installed") + return + } + handler() + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift new file mode 100644 index 000000000..71622d563 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -0,0 +1,265 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import UIKit +import XCTest +@testable import OneSignalOSCore + +/// Runs queued work inline so handler invocations are observable synchronously. +private final class InlineQueue: OSDispatchQueue { + func async(execute work: @escaping @convention(block) () -> Void) { + work() + } + + func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { + work() + } +} + +/// Counts live observer registrations so tests can assert teardown. +private final class ObserverTrackingCenter: NotificationCenter { + private(set) var liveObservers = 0 + + override func addObserver( + forName name: NSNotification.Name?, + object obj: Any?, + queue: OperationQueue?, + using block: @escaping (Notification) -> Void + ) -> NSObjectProtocol { + liveObservers += 1 + return super.addObserver(forName: name, object: obj, queue: queue, using: block) + } + + override func removeObserver(_ observer: Any) { + liveObservers -= 1 + super.removeObserver(observer) + } +} + +/// Owns a detector driven through an injected notification center, a fake monotonic clock, +/// and a handler that counts invocations instead of sending a request. +private final class Harness { + let center: NotificationCenter + var now: TimeInterval = 1_000 + var flags: [String] = [] + var shouldAwait = false + private(set) var fires = 0 + private(set) var detector: OSDeviceGestureDetector! + + init(center: NotificationCenter = NotificationCenter(), installHandler: Bool = true) { + self.center = center + detector = OSDeviceGestureDetector( + notificationCenter: center, + workQueue: InlineQueue(), + nowProvider: { [unowned self] in self.now }, + enabledFlagsProvider: { [unowned self] in self.flags }, + shouldAwaitProvider: { [unowned self] in self.shouldAwait }, + handler: installHandler ? { [unowned self] in self.fires += 1 } : nil + ) + detector.registerLifecycleObserversIfNeeded() + } + + /// One foreground-dwell + background-dwell cycle, 2s in total by default, so six of + /// them sit well inside the 30s window. + func cycle(backgroundDwell: TimeInterval = 1.0, foregroundDwell: TimeInterval = 1.0) { + now += foregroundDwell + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + now += backgroundDwell + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + } +} + +final class OSDeviceGestureDetectorTests: XCTestCase { + func testSixRapidCyclesFireTheHandlerOnce() { + let harness = Harness() + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 1) + } + + func testFiveCyclesFireNothing() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 0) + } + + func testCyclesSlowerThanTheWindowNeverAccumulateSix() { + let harness = Harness() + + // 7 seconds per round trip caps the window at five cycles, so a user who + // backgrounds the app all day at a normal pace can never fire this. + for _ in 1...8 { + harness.cycle(backgroundDwell: 3.0, foregroundDwell: 4.0) + } + + XCTAssertEqual(harness.fires, 0) + } + + func testPauseMidGestureDoesNotResetProgress() { + let harness = Harness() + + for _ in 1...3 { + harness.cycle() + } + // A pause costs time, not accumulated cycles; all six still land inside the window. + harness.cycle(foregroundDwell: 10.0) + for _ in 1...2 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 1) + } + + func testSubHumanBackgroundBlipDoesNotCountAsACycle() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + // Faster than any human app switch: it does not count, so one more real cycle + // completes the gesture. + harness.cycle(backgroundDwell: 0.001) + XCTAssertEqual(harness.fires, 0) + + harness.cycle() + XCTAssertEqual(harness.fires, 1) + } + + func testRepeatedActivationsWithoutABackgroundDoNotCount() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + // UIKit can post didBecomeActive repeatedly with no background event in between. + for _ in 1...6 { + harness.now += 0.5 + harness.center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + } + XCTAssertEqual(harness.fires, 0) + + harness.cycle() + XCTAssertEqual(harness.fires, 1) + } + + func testColdStartActivationDoesNotCount() { + let harness = Harness() + + harness.center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + for _ in 1...5 { + harness.cycle() + } + XCTAssertEqual(harness.fires, 0) + + harness.cycle() + XCTAssertEqual(harness.fires, 1) + } + + func testDetectorReArmsAfterFiring() { + let harness = Harness() + + for _ in 1...12 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 2) + } + + func testKillSwitchKeySuppressesTheHandlerCaseInsensitively() { + let harness = Harness() + harness.flags = ["SDK_Device_Gesture_Disabled"] + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 0) + } + + func testUnrelatedFlagKeysDoNotSuppressTheHandler() { + let harness = Harness() + harness.flags = ["sdk_custom_logging", "sdk_identity_verification"] + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 1) + } + + func testNotReadySdkSuppressesTheHandler() { + let harness = Harness() + harness.shouldAwait = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 0) + } + + func testMissingHandlerIsSafe() { + let harness = Harness(installHandler: false) + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.fires, 0) + } + + func testRegistrationIsIdempotent() { + let center = ObserverTrackingCenter() + let harness = Harness(center: center) + + harness.detector.registerLifecycleObserversIfNeeded() + + XCTAssertEqual(center.liveObservers, 2) + } + + func testTearDownLeavesNoLifecycleObserversBehind() { + let center = ObserverTrackingCenter() + let harness = Harness(center: center) + XCTAssertEqual(center.liveObservers, 2) + + harness.detector.tearDown() + XCTAssertEqual(center.liveObservers, 0) + + // Queued or in-flight gestures on a torn-down instance must not fire. + for _ in 1...6 { + harness.cycle() + } + XCTAssertEqual(harness.fires, 0) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index d9850691f..dece97649 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -698,6 +698,41 @@ extension OneSignalUserManagerImpl: OSUser { _user?.identityModel.onesignalId } + /// Marks this device as a test user by sending `test_user_name` = device-local time + /// straight through the client; the operation repo could replay a stale mark hours + /// later. `OSDeviceGestureDetector` calls this when the gesture completes. + @objc + public func markCurrentUserAsTestUser() { + guard let identityModel = identityModelStore.getModels()[OS_IDENTITY_MODEL_KEY] else { + OneSignalLog.onesignalLog(.LL_INFO, message: "OneSignalUserManagerImpl.markCurrentUserAsTestUser skipped: no user exists yet") + return + } + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + let testUserName = formatter.string(from: Date()) + let request = OSRequestUpdateProperties( + params: ["properties": ["test_user_name": testUserName]], + identityModel: identityModel + ) + // prepareForExecution needs the onesignal ID, so a user still being created fails here. + guard request.prepareForExecution(newRecordsState: newRecordsState) else { + OneSignalLog.onesignalLog( + .LL_INFO, + message: "OneSignalUserManagerImpl.markCurrentUserAsTestUser skipped: user not created on the backend yet" + ) + return + } + OneSignalCoreImpl.sharedClient().execute(request) { _ in + OneSignalLog.onesignalLog(.LL_INFO, message: "OneSignalUserManagerImpl marked user as test user \"\(testUserName)\"") + } onFailure: { error in + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OneSignalUserManagerImpl.markCurrentUserAsTestUser failed: \(String(describing: error))" + ) + } + } + public func addObserver(_ observer: OSUserStateObserver) { // This is a method in the User namespace that doesn't require privacy consent first self.userStateChangesObserver.addObserver(observer) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index d52161428..f5633b541 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -141,6 +141,7 @@ + (OneSignalReceiveReceiptsController*)receiveReceiptsController { + (void)clearStatics { [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; + [OSDeviceGestureDetector reset]; [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; @@ -502,6 +503,14 @@ static void StartFeatureFlagsRefresh(void) { }); } +/// The detector lives in OneSignalOSCore, which cannot see the user layer, so the +/// handler that sends the test-user mark is installed from here. +static void StartDeviceGestureDetector(void) { + [OSDeviceGestureDetector startWithHandler:^{ + [OneSignalUserManagerImpl.sharedInstance markCurrentUserAsTestUser]; + }]; +} + /// Computes the initial value for `gProtectedDataAvailable` (see the case table in /// `+setupProtectedDataObserverOnce`). static BOOL ComputeInitialStorageReadable(void) { @@ -580,6 +589,7 @@ + (void)setupProtectedDataObserverOnce { [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; StartFeatureFlagsRefresh(); + StartDeviceGestureDetector(); [OneSignal startNewSession:YES]; }; @@ -681,6 +691,7 @@ + (void)init { [self startLiveActivitiesManager]; [self startInAppMessages]; StartFeatureFlagsRefresh(); + StartDeviceGestureDetector(); } [self startNewSession:YES];