diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 5461b697b..bdc222b97 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 5461b697bcff33fd2082bcbd8e4ab7f0ea7691b1 +Subproject commit bdc222b974e79ddacc9b048f6ea92a3b75e4c7a9 diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 4fcfefb03..07412429b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -83,6 +83,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 */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1819,6 +1821,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 = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; @@ -2311,6 +2315,7 @@ 3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */, 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, ); @@ -2615,6 +2620,7 @@ 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, @@ -4495,6 +4501,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 */, @@ -4665,6 +4672,7 @@ 5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, + A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.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..606aaac3d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -0,0 +1,298 @@ +/* + 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-device gesture: `requiredCycles` background/foreground cycles within +/// `windowSeconds`, then copies the push subscription ID to the general pasteboard, prefixed +/// `os:` (see `clipText`), so the person can paste it into the dashboard. Without a subscription +/// it copies `noSubscriptionClipText` instead, so someone following the docs can tell the gesture +/// worked. +/// +/// 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 fit inside it +/// at round trips of five seconds or faster. +/// +/// The `killSwitchKey` catalog flag turns the gesture off. Absent means enabled, so a device +/// that has never fetched flags still has it. +/// +/// Every recognised gesture also records `OSObservabilityEvent.deviceGesture`, with its outcome +/// and the copied ID, so the gesture's usage can be measured. +@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" + + /// The copied ID expires after five minutes. Whatever it replaced is not restored. + static let pasteboardExpirySeconds: TimeInterval = 300 + + 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 mainQueue: OSDispatchQueue + /// Monotonic and keeps counting through sleep, so neither a wall-clock jump nor a nap can + /// stretch or shrink the window; `systemUptime` would stop at each lock and stitch visits + /// hours apart into one window. + private let nowProvider: () -> TimeInterval + private let isDisabledRemotelyProvider: () -> Bool + private let subscriptionIdProvider: () -> String? + private let shouldAwaitProvider: () -> Bool + private let pasteboardWriter: (String) -> Void + private let eventRecorder: OSObservabilityEventRecorderProtocol + + private let stateLock = NSLock() + private var started = false + /// Set by `tearDown` and never cleared, so work already queued for an instance that + /// `shared` has dropped cannot write to the pasteboard or re-register observers. + private var invalidated = false + private var notificationTokens: [NSObjectProtocol] = [] + private var lastBackgroundedAt: TimeInterval? + private var cycleCompletions: [TimeInterval] = [] + + init( + notificationCenter: NotificationCenter = .default, + mainQueue: OSDispatchQueue = DispatchQueue.main, + nowProvider: @escaping () -> TimeInterval = { + TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC) + }, + isDisabledRemotelyProvider: @escaping () -> Bool = { + OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey) + }, + subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, + shouldAwaitProvider: @escaping () -> Bool = { + OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) + }, + pasteboardWriter: @escaping (String) -> Void = OSDeviceGestureDetector.writeToGeneralPasteboard, + eventRecorder: OSObservabilityEventRecorderProtocol = OSObservabilityEventRecorder.shared + ) { + self.notificationCenter = notificationCenter + self.mainQueue = mainQueue + self.nowProvider = nowProvider + self.isDisabledRemotelyProvider = isDisabledRemotelyProvider + self.subscriptionIdProvider = subscriptionIdProvider + self.shouldAwaitProvider = shouldAwaitProvider + self.pasteboardWriter = pasteboardWriter + self.eventRecorder = eventRecorder + super.init() + } + + /// Idempotent: registers the lifecycle observers once and keeps counting from there. + @objc public static func start() { + shared.registerLifecycleObserversIfNeeded() + } + + @objc public static func reset() { + lock.withLock { + _shared?.tearDown() + _shared = nil + } + } + + /// 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 + lastBackgroundedAt = nil + cycleCompletions.removeAll() + return current + } + tokens.forEach(notificationCenter.removeObserver) + } + + func onUnfocused() { + let timestamp = nowProvider() + stateLock.withLock { + lastBackgroundedAt = timestamp + } + } + + func onFocus() { + let timestamp = nowProvider() + // Logged after the lock: OneSignalLog runs app listeners synchronously, and one that + // re-enters the SDK would deadlock on it. + var progress: String? + 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. + progress = "ignored a \(String(format: "%.3f", dwell))s background blip (rotation filter)" + return false + } + cycleCompletions.append(timestamp) + cycleCompletions.removeAll { timestamp - $0 > Self.windowSeconds } + progress = "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 let progress { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSDeviceGestureDetector: \(progress)") + } + if completedGesture { + copySubscriptionIdToPasteboard() + } + } + + private func copySubscriptionIdToPasteboard() { + mainQueue.async { [weak self] in + guard let self, !self.stateLock.withLock({ self.invalidated }) else { + return + } + // Not recorded either: without an app id or consent, nothing about the device may ship. + guard !self.shouldAwaitProvider() else { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OSDeviceGestureDetector: gesture detected but the SDK is not ready (appId, consent, or storage)" + ) + return + } + guard !self.isDisabledRemotelyProvider() else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but disabled remotely") + self.recordGesture(.disabled) + return + } + guard let subscriptionId = self.subscriptionIdProvider(), !subscriptionId.isEmpty else { + self.pasteboardWriter(Self.noSubscriptionClipText) + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: pasteboard set, gesture result no_id") + self.recordGesture(.noId) + return + } + self.pasteboardWriter(Self.clipText(subscriptionId: subscriptionId)) + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: pasteboard set, gesture result copied") + self.recordGesture(.copied, copiedId: subscriptionId) + } + } + + /// Wire values of `gesture.result`, which backend queries match on. + private enum GestureResult: String { + case copied + case noId = "no_id" + case disabled + } + + /// `copied` and `noId` are recorded after the pasteboard write, so neither claims a change that + /// did not happen. `disabled` is recorded at the decision, since nothing is written. + private func recordGesture(_ result: GestureResult, copiedId: String? = nil) { + var attributes = ["gesture.result": result.rawValue] + if let copiedId { + attributes["gesture.push_subscription_id"] = copiedId + } + eventRecorder.record(event: .deviceGesture, attributes: attributes) + } + + private static let clipPrefix = "os: " + static let noSubscriptionClipText = clipPrefix + "no subscription ID yet" + + /// The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and for + /// anyone who copied it by accident. + static func clipText(subscriptionId: String) -> String { + clipPrefix + subscriptionId + } + + /// No `localOnly` option: Universal Clipboard carrying the ID to the Mac running the + /// dashboard is the point, not a leak. + private static func writeToGeneralPasteboard(_ value: String) { + UIPasteboard.general.setItems( + [["public.utf8-plain-text": value]], + options: [.expirationDate: Date().addingTimeInterval(pasteboardExpirySeconds)] + ) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift new file mode 100644 index 000000000..61d39ba23 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -0,0 +1,434 @@ +/* + 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 OneSignalKMP +import UIKit +import XCTest +@_spi(OneSignalInternal) @testable import OneSignalOSCore + +private let subscriptionId = "aaaabbbb-cccc-dddd-eeee-ffff00001111" +private let expectedWrite = OSDeviceGestureDetector.clipText(subscriptionId: subscriptionId) + +/// Runs queued work inline so pasteboard writes 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() + } +} + +/// Captures queued work so a test can tear the detector down before the write runs. +private final class DeferringQueue: OSDispatchQueue { + private(set) var pending: [() -> Void] = [] + + func async(execute work: @escaping @convention(block) () -> Void) { + pending.append(work) + } + + func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { + pending.append(work) + } + + func drain() { + let work = pending + pending.removeAll() + work.forEach { $0() } + } +} + +/// 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) + } +} + +/// Captures what the detector records so tests can assert the event and its attributes. +private final class EventRecorderSpy: OSObservabilityEventRecorderProtocol { + private(set) var events: [OSObservabilityEvent] = [] + private(set) var attributes: [[String: String]] = [] + + func record(event: OSObservabilityEvent, attributes: [String: String]) { + events.append(event) + self.attributes.append(attributes) + } +} + +/// Owns a detector driven through an injected notification center, a fake monotonic clock, +/// a writer that records instead of touching the real pasteboard, and a recorder spy. +private final class Harness { + let center: NotificationCenter + var now: TimeInterval = 1_000 + var killSwitchOn = false + var currentSubscriptionId: String? = subscriptionId + var shouldAwait = false + private(set) var writes: [String] = [] + let recorder = EventRecorderSpy() + private(set) var detector: OSDeviceGestureDetector! + + init(center: NotificationCenter = NotificationCenter(), queue: OSDispatchQueue = InlineQueue()) { + self.center = center + detector = OSDeviceGestureDetector( + notificationCenter: center, + mainQueue: queue, + nowProvider: { [unowned self] in self.now }, + isDisabledRemotelyProvider: { [unowned self] in self.killSwitchOn }, + subscriptionIdProvider: { [unowned self] in self.currentSubscriptionId }, + shouldAwaitProvider: { [unowned self] in self.shouldAwait }, + pasteboardWriter: { [unowned self] in self.writes.append($0) }, + eventRecorder: recorder + ) + 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 testSixRapidCyclesWriteThePrefixedSubscriptionId() { + let harness = Harness() + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(expectedWrite, "os: \(subscriptionId)") + } + + func testFiveCyclesWriteNothing() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + 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.writes, []) + } + + 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.writes, [expectedWrite]) + } + + 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.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + 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.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testColdStartActivationDoesNotCount() { + let harness = Harness() + + harness.center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + for _ in 1...5 { + harness.cycle() + } + XCTAssertEqual(harness.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testDetectorReArmsAfterFiring() { + let harness = Harness() + + for _ in 1...12 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite, expectedWrite]) + } + + func testKillSwitchSuppressesTheWrite() { + let harness = Harness() + harness.killSwitchOn = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testKillSwitchKeyMatchesTheCatalog() { + // The feature manager only answers for catalog keys, so a drift here would silently + // turn the switch into a no-op. + XCTAssertEqual(OSDeviceGestureDetector.killSwitchKey, FeatureFlag.sdkDeviceGestureDisabled.key) + } + + func testNotReadySdkSuppressesTheWrite() { + let harness = Harness() + harness.shouldAwait = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testMissingSubscriptionIdWritesThePlaceholder() { + // Someone following the docs gets a visible result that says why there is no ID. + let harness = Harness() + harness.currentSubscriptionId = nil + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, ["os: no subscription ID yet"]) + } + + func testEmptySubscriptionIdWritesThePlaceholder() { + let harness = Harness() + harness.currentSubscriptionId = "" + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, ["os: no subscription ID yet"]) + } + + 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) + } + + func testTearDownDropsAWriteAlreadyQueued() { + // A gesture can complete just before a reset lands. The write is on the main queue by + // then, so the block itself has to notice the instance is gone. + let queue = DeferringQueue() + let harness = Harness(queue: queue) + + for _ in 1...6 { + harness.cycle() + } + XCTAssertEqual(queue.pending.count, 1) + XCTAssertEqual(harness.writes, []) + + harness.detector.tearDown() + queue.drain() + + XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.recorder.events, []) + } + + func testTwoHundredFortyNineMillisecondsIsABlipAndTwoHundredFiftyIsACycle() { + // The floor is inclusive: exactly the minimum counts. Six blips leave the window empty, + // so the six real cycles right after still need all six. + let harness = Harness() + + for _ in 1...6 { + harness.cycle(backgroundDwell: 0.249) + } + XCTAssertEqual(harness.writes, []) + + for _ in 1...6 { + harness.cycle(backgroundDwell: 0.25) + } + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testWindowIsInclusiveAtExactlyThirtySeconds() { + // Five 2s cycles complete at +2s..+10s. A sixth completing exactly 30s after the first + // still counts; one millisecond later the first has aged out and only five remain. + let exact = Harness() + for _ in 1...5 { + exact.cycle() + } + exact.cycle(backgroundDwell: 1.0, foregroundDwell: 21.0) + XCTAssertEqual(exact.writes, [expectedWrite]) + + let late = Harness() + for _ in 1...5 { + late.cycle() + } + late.cycle(backgroundDwell: 1.0, foregroundDwell: 21.001) + XCTAssertEqual(late.writes, []) + } + + // MARK: - Observability event + + // Every recognised gesture records deviceGesture with its outcome, whether or not an ID was + // copied, so the backend can answer how often the gesture happens and how often it pays off. + + func testCompletedGestureRecordsACopiedEventCarryingTheSubscriptionId() { + let harness = Harness() + + // Progress is silent: the event fires on recognition, not per cycle. + for _ in 1...5 { + harness.cycle() + } + XCTAssertEqual(harness.recorder.events, []) + + harness.cycle() + + XCTAssertEqual(harness.recorder.events, [.deviceGesture]) + XCTAssertEqual(harness.recorder.attributes, [[ + "gesture.result": "copied", + "gesture.push_subscription_id": subscriptionId + ]]) + } + + func testKillSwitchRecordsADisabledResultWithoutAnId() { + let harness = Harness() + harness.killSwitchOn = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, [.deviceGesture]) + XCTAssertEqual(harness.recorder.attributes, [["gesture.result": "disabled"]]) + } + + func testMissingOrEmptySubscriptionIdRecordsANoIdResult() { + // Both shapes mean the same thing to the backend: the gesture ran before the device had + // anything worth pasting. + for missingId in [nil, ""] { + let harness = Harness() + harness.currentSubscriptionId = missingId + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, [.deviceGesture]) + XCTAssertEqual(harness.recorder.attributes, [["gesture.result": "no_id"]]) + } + } + + func testNotReadySdkRecordsNothing() { + // The event would ship to the backend, and nothing may leave the device before the app id + // and consent are in place. + let harness = Harness() + harness.shouldAwait = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, []) + } + + func testEachRecognitionRecordsItsOwnEvent() { + let harness = Harness() + + for _ in 1...12 { + harness.cycle() + } + + XCTAssertEqual(harness.recorder.events, [.deviceGesture, .deviceGesture]) + XCTAssertEqual(harness.recorder.attributes.map { $0["gesture.result"] }, ["copied", "copied"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index bfdcfbfb8..b3efddea7 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; @@ -580,6 +581,7 @@ + (void)setupProtectedDataObserverOnce { [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; StartFeatureFlagsRefresh(); + [OSDeviceGestureDetector start]; [OneSignal startNewSession:YES]; }; @@ -681,6 +683,7 @@ + (void)init { [self startLiveActivitiesManager]; [self startInAppMessages]; StartFeatureFlagsRefresh(); + [OSDeviceGestureDetector start]; } [self startNewSession:YES]; @@ -709,6 +712,7 @@ + (void)handleAppIdChange:(NSString*)appId { _didCallDownloadParameters = false; [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; + [OSDeviceGestureDetector reset]; // Flags are app-scoped but stored unscoped, and APP_STARTUP flags never unlatch // within a process, so both the cache and the latch have to go. [OSFeatureManager resetAndClearCachedFlags];