From aae06e656f42c1bba5af67a453a788110c85017a Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 2 Sep 2026 19:29:03 -0700 Subject: [PATCH 1/5] feat: [SDK-5088] add device gesture that copies the push subscription ID to the pasteboard Backgrounding and foregrounding the app 6 times within 30 seconds copies the push subscription ID to the general pasteboard with a 5 minute expiry, ready to paste into the dashboard. The clip is os: . The short prefix marks the value as a OneSignal ID for the dashboard's paste target and for anyone who copied it by accident. A cycle is a didEnterBackground / didBecomeActive pair observed app-level (never per-scene, matching OSFeatureFlagsRefreshService), counted on a monotonic clock. A cycle needs a background phase of at least 250ms, and the 30s sliding window is the only rate rule. Each counted cycle logs at verbose so manual testing can watch progress. The detector starts alongside StartFeatureFlagsRefresh, including the protected-data recovery path, and resets in clearStatics. It skips when the SDK is not ready (appId, consent, storage) or the subscription does not exist yet, and adding sdk_device_gesture_disabled to an app's enabled feature keys turns it off remotely. The raw OSFeatureFlagsStore list is checked instead of OSFeatureManager because the KMP catalog hides unregistered keys. --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../Source/OSDeviceGestureDetector.swift | 268 +++++++++++++++++ .../OSDeviceGestureDetectorTests.swift | 283 ++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 3 + 4 files changed, 562 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index b047d3f2e..bd0bf8c1b 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 */; }; @@ -1820,6 +1822,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 = ""; }; @@ -2313,6 +2317,7 @@ 3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */, 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, ); @@ -2617,6 +2622,7 @@ 5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, @@ -4498,6 +4504,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 */, @@ -4668,6 +4675,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..e874c07f7 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -0,0 +1,268 @@ +/* + 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. +/// +/// 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" + + /// Caps how long the gesture clobbers whatever the person had copied. + 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 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 subscriptionIdProvider: () -> String? + private let shouldAwaitProvider: () -> Bool + private let pasteboardWriter: (String) -> Void + + 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 = { ProcessInfo.processInfo.systemUptime }, + enabledFlagsProvider: @escaping () -> [String] = { OSFeatureFlagsStore.shared.sdkRemoteFeatureFlags }, + subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, + shouldAwaitProvider: @escaping () -> Bool = { + OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) + }, + pasteboardWriter: @escaping (String) -> Void = OSDeviceGestureDetector.writeToGeneralPasteboard + ) { + self.notificationCenter = notificationCenter + self.mainQueue = mainQueue + self.nowProvider = nowProvider + self.enabledFlagsProvider = enabledFlagsProvider + self.subscriptionIdProvider = subscriptionIdProvider + self.shouldAwaitProvider = shouldAwaitProvider + self.pasteboardWriter = pasteboardWriter + 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() + 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 { + copySubscriptionIdToPasteboard() + } + } + + private func copySubscriptionIdToPasteboard() { + mainQueue.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 subscriptionId = self.subscriptionIdProvider(), !subscriptionId.isEmpty else { + OneSignalLog.onesignalLog( + .LL_INFO, + message: "OSDeviceGestureDetector: gesture detected before the push subscription exists, nothing copied" + ) + return + } + self.pasteboardWriter(Self.clipText(subscriptionId: subscriptionId)) + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: push subscription ID copied to the pasteboard") + } + } + + /// 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 { + "os: \(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..58c7bf950 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -0,0 +1,283 @@ +/* + 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 + +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() + } +} + +/// 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 writer that records instead of touching the real pasteboard. +private final class Harness { + let center: NotificationCenter + var now: TimeInterval = 1_000 + var flags: [String] = [] + var currentSubscriptionId: String? = subscriptionId + var shouldAwait = false + private(set) var writes: [String] = [] + private(set) var detector: OSDeviceGestureDetector! + + init(center: NotificationCenter = NotificationCenter()) { + self.center = center + detector = OSDeviceGestureDetector( + notificationCenter: center, + mainQueue: InlineQueue(), + nowProvider: { [unowned self] in self.now }, + enabledFlagsProvider: { [unowned self] in self.flags }, + subscriptionIdProvider: { [unowned self] in self.currentSubscriptionId }, + shouldAwaitProvider: { [unowned self] in self.shouldAwait }, + pasteboardWriter: { [unowned self] in self.writes.append($0) } + ) + 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 testKillSwitchKeySuppressesTheWriteCaseInsensitively() { + let harness = Harness() + harness.flags = ["SDK_Device_Gesture_Disabled"] + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testUnrelatedFlagKeysDoNotSuppressTheWrite() { + let harness = Harness() + harness.flags = ["sdk_custom_logging", "sdk_identity_verification"] + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testNotReadySdkSuppressesTheWrite() { + let harness = Harness() + harness.shouldAwait = true + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testMissingSubscriptionIdWritesNothing() { + let harness = Harness() + harness.currentSubscriptionId = nil + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testEmptySubscriptionIdWritesNothing() { + let harness = Harness() + harness.currentSubscriptionId = "" + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + 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 write. + for _ in 1...6 { + harness.cycle() + } + XCTAssertEqual(harness.writes, []) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 1ce666fd0..cebaf40a6 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]; From 65bd29c272d8616d502a5ebcbbdd80f95669bfc5 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 3 Sep 2026 21:16:05 -0700 Subject: [PATCH 2/5] feat: [SDK-5088] record the device gesture as an observability event OSDeviceGestureDetector now takes an OSObservabilityEventRecorderProtocol (OSObservabilityEventRecorder.shared by default) and records OSObservabilityEvent.deviceGesture each time the gesture is recognised. gesture.result is copied, no_id or disabled, and a copied event also carries gesture.push_subscription_id, the value that went on the pasteboard. The copied result is recorded after the pasteboard write, so it never overstates. A gesture while the SDK is not ready (no app id, consent withheld, storage unreadable) records nothing, because the event ships to the backend. Tests drive the detector with a recorder spy and pin the attribute names and values, since the log backend is queried by them. --- .../Source/OSDeviceGestureDetector.swift | 28 +++++- .../OSDeviceGestureDetectorTests.swift | 94 ++++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index e874c07f7..dcbbf1d4a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -43,6 +43,9 @@ import UIKit /// 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. +/// +/// 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 @@ -79,6 +82,7 @@ public final class OSDeviceGestureDetector: NSObject { 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 @@ -98,7 +102,8 @@ public final class OSDeviceGestureDetector: NSObject { shouldAwaitProvider: @escaping () -> Bool = { OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) }, - pasteboardWriter: @escaping (String) -> Void = OSDeviceGestureDetector.writeToGeneralPasteboard + pasteboardWriter: @escaping (String) -> Void = OSDeviceGestureDetector.writeToGeneralPasteboard, + eventRecorder: OSObservabilityEventRecorderProtocol = OSObservabilityEventRecorder.shared ) { self.notificationCenter = notificationCenter self.mainQueue = mainQueue @@ -107,6 +112,7 @@ public final class OSDeviceGestureDetector: NSObject { self.subscriptionIdProvider = subscriptionIdProvider self.shouldAwaitProvider = shouldAwaitProvider self.pasteboardWriter = pasteboardWriter + self.eventRecorder = eventRecorder super.init() } @@ -225,6 +231,7 @@ public final class OSDeviceGestureDetector: NSObject { 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, @@ -237,6 +244,7 @@ public final class OSDeviceGestureDetector: NSObject { } guard !disabled else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but disabled remotely") + self.recordGesture(.disabled) return } guard let subscriptionId = self.subscriptionIdProvider(), !subscriptionId.isEmpty else { @@ -244,11 +252,29 @@ public final class OSDeviceGestureDetector: NSObject { .LL_INFO, message: "OSDeviceGestureDetector: gesture detected before the push subscription exists, nothing copied" ) + self.recordGesture(.noId) return } self.pasteboardWriter(Self.clipText(subscriptionId: subscriptionId)) OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: push subscription ID copied to the pasteboard") + 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 + } + + /// Recorded once the outcome is known, so `copied` means the pasteboard write went through. + 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) } /// The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and for diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index 58c7bf950..b59a686ba 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -27,7 +27,7 @@ import UIKit import XCTest -@testable import OneSignalOSCore +@_spi(OneSignalInternal) @testable import OneSignalOSCore private let subscriptionId = "aaaabbbb-cccc-dddd-eeee-ffff00001111" private let expectedWrite = OSDeviceGestureDetector.clipText(subscriptionId: subscriptionId) @@ -63,8 +63,19 @@ private final class ObserverTrackingCenter: NotificationCenter { } } +/// 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, -/// and a writer that records instead of touching the real pasteboard. +/// 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 @@ -72,6 +83,7 @@ private final class Harness { var currentSubscriptionId: String? = subscriptionId var shouldAwait = false private(set) var writes: [String] = [] + let recorder = EventRecorderSpy() private(set) var detector: OSDeviceGestureDetector! init(center: NotificationCenter = NotificationCenter()) { @@ -83,7 +95,8 @@ private final class Harness { enabledFlagsProvider: { [unowned self] in self.flags }, subscriptionIdProvider: { [unowned self] in self.currentSubscriptionId }, shouldAwaitProvider: { [unowned self] in self.shouldAwait }, - pasteboardWriter: { [unowned self] in self.writes.append($0) } + pasteboardWriter: { [unowned self] in self.writes.append($0) }, + eventRecorder: recorder ) detector.registerLifecycleObserversIfNeeded() } @@ -280,4 +293,79 @@ final class OSDeviceGestureDetectorTests: XCTestCase { } XCTAssertEqual(harness.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.flags = [OSDeviceGestureDetector.killSwitchKey] + + 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"]) + } } From 5ce9a0ac92ce54d72719f10db9ddb33bbc2d5894 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 4 Sep 2026 00:08:00 -0700 Subject: [PATCH 3/5] feat: [SDK-5088] copy a placeholder when there is no subscription ID yet The gesture is going into the public docs, so a person who performs it and gets nothing cannot tell whether the SDK missed it or had no ID to give. Without a push subscription the pasteboard is now "os: no subscription ID yet", which says which one it was. The event result stays no_id and is recorded after the write, like copied. An SDK that is not ready, the remote kill switch and a gesture that was not recognised still copy nothing. --- .../Source/OSDeviceGestureDetector.swift | 19 +++++++++++-------- .../OSDeviceGestureDetectorTests.swift | 9 +++++---- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index dcbbf1d4a..d9c6f7902 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -31,7 +31,9 @@ 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. +/// `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 @@ -248,15 +250,13 @@ public final class OSDeviceGestureDetector: NSObject { return } guard let subscriptionId = self.subscriptionIdProvider(), !subscriptionId.isEmpty else { - OneSignalLog.onesignalLog( - .LL_INFO, - message: "OSDeviceGestureDetector: gesture detected before the push subscription exists, nothing copied" - ) + 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: push subscription ID copied to the pasteboard") + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: pasteboard set, gesture result copied") self.recordGesture(.copied, copiedId: subscriptionId) } } @@ -268,7 +268,7 @@ public final class OSDeviceGestureDetector: NSObject { case disabled } - /// Recorded once the outcome is known, so `copied` means the pasteboard write went through. + /// Recorded once the pasteboard is written, so a result never claims a change that did not happen. private func recordGesture(_ result: GestureResult, copiedId: String? = nil) { var attributes = ["gesture.result": result.rawValue] if let copiedId { @@ -277,10 +277,13 @@ public final class OSDeviceGestureDetector: NSObject { 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 { - "os: \(subscriptionId)" + clipPrefix + subscriptionId } /// No `localOnly` option: Universal Clipboard carrying the ID to the Mac running the diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index b59a686ba..bd36e58ae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -248,7 +248,8 @@ final class OSDeviceGestureDetectorTests: XCTestCase { XCTAssertEqual(harness.writes, []) } - func testMissingSubscriptionIdWritesNothing() { + func testMissingSubscriptionIdWritesThePlaceholder() { + // Someone following the docs gets a visible result that says why there is no ID. let harness = Harness() harness.currentSubscriptionId = nil @@ -256,10 +257,10 @@ final class OSDeviceGestureDetectorTests: XCTestCase { harness.cycle() } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.writes, ["os: no subscription ID yet"]) } - func testEmptySubscriptionIdWritesNothing() { + func testEmptySubscriptionIdWritesThePlaceholder() { let harness = Harness() harness.currentSubscriptionId = "" @@ -267,7 +268,7 @@ final class OSDeviceGestureDetectorTests: XCTestCase { harness.cycle() } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.writes, ["os: no subscription ID yet"]) } func testRegistrationIsIdempotent() { From 607b2a8173d4d3e1fbe12fe92315c9230609a059 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 4 Sep 2026 08:52:47 -0700 Subject: [PATCH 4/5] refactor: [SDK-5088] read the kill switch through the feature manager The detector scanned the raw fetched flag list because the kill switch key had no catalog entry. Now that it has one, OSDeviceGestureDetector asks OSFeatureManager for sdk_device_gesture_disabled like every other flag, which also drops the hand-rolled case handling. A test pins the key to the catalog entry, since the manager answers false for any key it does not know. The switch still reads present-means-off, and absent still means on. --- .../Source/OSDeviceGestureDetector.swift | 19 +++++++-------- .../OSDeviceGestureDetectorTests.swift | 24 ++++++++----------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index d9c6f7902..848d4c98b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -41,10 +41,8 @@ import UIKit /// 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. +/// 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. @@ -80,7 +78,7 @@ public final class OSDeviceGestureDetector: NSObject { /// 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 isDisabledRemotelyProvider: () -> Bool private let subscriptionIdProvider: () -> String? private let shouldAwaitProvider: () -> Bool private let pasteboardWriter: (String) -> Void @@ -99,7 +97,9 @@ public final class OSDeviceGestureDetector: NSObject { notificationCenter: NotificationCenter = .default, mainQueue: OSDispatchQueue = DispatchQueue.main, nowProvider: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, - enabledFlagsProvider: @escaping () -> [String] = { OSFeatureFlagsStore.shared.sdkRemoteFeatureFlags }, + isDisabledRemotelyProvider: @escaping () -> Bool = { + OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey) + }, subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, shouldAwaitProvider: @escaping () -> Bool = { OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) @@ -110,7 +110,7 @@ public final class OSDeviceGestureDetector: NSObject { self.notificationCenter = notificationCenter self.mainQueue = mainQueue self.nowProvider = nowProvider - self.enabledFlagsProvider = enabledFlagsProvider + self.isDisabledRemotelyProvider = isDisabledRemotelyProvider self.subscriptionIdProvider = subscriptionIdProvider self.shouldAwaitProvider = shouldAwaitProvider self.pasteboardWriter = pasteboardWriter @@ -241,10 +241,7 @@ public final class OSDeviceGestureDetector: NSObject { ) return } - let disabled = self.enabledFlagsProvider().contains { - $0.caseInsensitiveCompare(Self.killSwitchKey) == .orderedSame - } - guard !disabled else { + guard !self.isDisabledRemotelyProvider() else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but disabled remotely") self.recordGesture(.disabled) return diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index bd36e58ae..6538d9445 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -25,6 +25,7 @@ THE SOFTWARE. */ +import OneSignalKMP import UIKit import XCTest @_spi(OneSignalInternal) @testable import OneSignalOSCore @@ -79,7 +80,7 @@ private final class EventRecorderSpy: OSObservabilityEventRecorderProtocol { private final class Harness { let center: NotificationCenter var now: TimeInterval = 1_000 - var flags: [String] = [] + var killSwitchOn = false var currentSubscriptionId: String? = subscriptionId var shouldAwait = false private(set) var writes: [String] = [] @@ -92,7 +93,7 @@ private final class Harness { notificationCenter: center, mainQueue: InlineQueue(), nowProvider: { [unowned self] in self.now }, - enabledFlagsProvider: { [unowned self] in self.flags }, + 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) }, @@ -215,9 +216,9 @@ final class OSDeviceGestureDetectorTests: XCTestCase { XCTAssertEqual(harness.writes, [expectedWrite, expectedWrite]) } - func testKillSwitchKeySuppressesTheWriteCaseInsensitively() { + func testKillSwitchSuppressesTheWrite() { let harness = Harness() - harness.flags = ["SDK_Device_Gesture_Disabled"] + harness.killSwitchOn = true for _ in 1...6 { harness.cycle() @@ -226,15 +227,10 @@ final class OSDeviceGestureDetectorTests: XCTestCase { XCTAssertEqual(harness.writes, []) } - func testUnrelatedFlagKeysDoNotSuppressTheWrite() { - let harness = Harness() - harness.flags = ["sdk_custom_logging", "sdk_identity_verification"] - - for _ in 1...6 { - harness.cycle() - } - - XCTAssertEqual(harness.writes, [expectedWrite]) + 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() { @@ -320,7 +316,7 @@ final class OSDeviceGestureDetectorTests: XCTestCase { func testKillSwitchRecordsADisabledResultWithoutAnId() { let harness = Harness() - harness.flags = [OSDeviceGestureDetector.killSwitchKey] + harness.killSwitchOn = true for _ in 1...6 { harness.cycle() From 47a641b51dfbfe6f0b6757579676d00c2af2fc82 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 4 Sep 2026 16:24:18 -0700 Subject: [PATCH 5/5] fix: [SDK-5088] reset on app-id change, log outside the lock, count real time Four review findings and three wording fixes on the detector. handleAppIdChange now resets the detector next to the remote logger, flag refresh and feature manager. Without it, cycle progress and a queued pasteboard write survived into the new app, and the re-run start() was a no-op because the old instance was still marked started. onFocus builds its verbose progress line inside stateLock and logs after releasing it. OneSignalLog runs app listeners synchronously at every level, and a listener that re-enters the SDK would deadlock on the non-recursive lock, the same hazard the feature manager and the event recorder already avoid. The clock is now CLOCK_MONOTONIC_RAW, which on Darwin keeps counting through sleep. ProcessInfo.systemUptime stopped at every lock, so six brief visits spread over an afternoon could add up to one "30 second" window and copy the ID to a pasteboard nobody asked about. The window now means what the docs say. Wording: the class doc described the slowest qualifying pace as if it were a floor, the expiry comment read as if the previous pasteboard came back, and the recordGesture doc claimed no result is recorded without a write, which disabled contradicts. Tests add a deferring queue that proves tearDown drops a write already queued, which the old teardown test claimed but never exercised, plus the 249ms versus 250ms dwell edge and the 30 second window edge. --- .../Source/OSDeviceGestureDetector.swift | 36 +++++----- .../OSDeviceGestureDetectorTests.swift | 72 ++++++++++++++++++- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 1 + 3 files changed, 90 insertions(+), 19 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index 848d4c98b..606aaac3d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -38,8 +38,8 @@ import UIKit /// 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. +/// 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. @@ -56,7 +56,7 @@ public final class OSDeviceGestureDetector: NSObject { static let killSwitchKey = "sdk_device_gesture_disabled" - /// Caps how long the gesture clobbers whatever the person had copied. + /// The copied ID expires after five minutes. Whatever it replaced is not restored. static let pasteboardExpirySeconds: TimeInterval = 300 private static let lock = NSLock() @@ -75,8 +75,9 @@ public final class OSDeviceGestureDetector: NSObject { private let notificationCenter: NotificationCenter private let mainQueue: OSDispatchQueue - /// Monotonic clock, so wall-clock jumps from NTP or manual time changes cannot stretch - /// or shrink the window. + /// 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? @@ -96,7 +97,9 @@ public final class OSDeviceGestureDetector: NSObject { init( notificationCenter: NotificationCenter = .default, mainQueue: OSDispatchQueue = DispatchQueue.main, - nowProvider: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + nowProvider: @escaping () -> TimeInterval = { + TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC) + }, isDisabledRemotelyProvider: @escaping () -> Bool = { OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey) }, @@ -194,6 +197,9 @@ public final class OSDeviceGestureDetector: NSObject { 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 @@ -204,25 +210,22 @@ public final class OSDeviceGestureDetector: NSObject { 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)" - ) + progress = "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)" - ) + 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() } @@ -265,7 +268,8 @@ public final class OSDeviceGestureDetector: NSObject { case disabled } - /// Recorded once the pasteboard is written, so a result never claims a change that did not happen. + /// `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 { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index 6538d9445..61d39ba23 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -44,6 +44,25 @@ private final class InlineQueue: OSDispatchQueue { } } +/// 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 @@ -87,11 +106,11 @@ private final class Harness { let recorder = EventRecorderSpy() private(set) var detector: OSDeviceGestureDetector! - init(center: NotificationCenter = NotificationCenter()) { + init(center: NotificationCenter = NotificationCenter(), queue: OSDispatchQueue = InlineQueue()) { self.center = center detector = OSDeviceGestureDetector( notificationCenter: center, - mainQueue: InlineQueue(), + mainQueue: queue, nowProvider: { [unowned self] in self.now }, isDisabledRemotelyProvider: { [unowned self] in self.killSwitchOn }, subscriptionIdProvider: { [unowned self] in self.currentSubscriptionId }, @@ -283,12 +302,59 @@ final class OSDeviceGestureDetectorTests: XCTestCase { 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) - // Queued or in-flight gestures on a torn-down instance must not write. 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 diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index cebaf40a6..d8d58d094 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -712,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];