From 181a4eaf5257829eeb06e6d61678929947defd4f Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 26 Aug 2026 09:57:41 -0700 Subject: [PATCH 1/2] feat: [SDK-5088] add test-device gesture that copies the push subscription ID to the pasteboard Backgrounding and foregrounding the app 6 times within 30 seconds copies os: to the general pasteboard with a 5 minute expiry, ready to paste into the dashboard. A cycle is a didEnterBackground / didBecomeActive pair observed app-level (never per-scene, matching OSFeatureFlagsRefreshService), counted on a monotonic clock with dwell bounds (background 250ms..2.5s, foreground between counted cycles at most 2.5s) so ordinary app switching cannot complete the pattern. 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_test_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/OSTestDeviceGestureDetector.swift | 281 +++++++++++++++++ .../OSTestDeviceGestureDetectorTests.swift | 290 ++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 3 + 4 files changed, 582 insertions(+) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSTestDeviceGestureDetector.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSTestDeviceGestureDetectorTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a76259a58..854f74be0 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 /* OSTestDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSTestDeviceGestureDetector.swift */; }; + A5062F01A1B2C3D4E5F60004 /* OSTestDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSTestDeviceGestureDetectorTests.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 /* OSTestDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSTestDeviceGestureDetector.swift; sourceTree = ""; }; + A5062F01A1B2C3D4E5F60003 /* OSTestDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSTestDeviceGestureDetectorTests.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 /* OSTestDeviceGestureDetector.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, ); @@ -2607,6 +2612,7 @@ 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, + A5062F01A1B2C3D4E5F60003 /* OSTestDeviceGestureDetectorTests.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 /* OSTestDeviceGestureDetector.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 /* OSTestDeviceGestureDetectorTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSTestDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSTestDeviceGestureDetector.swift new file mode 100644 index 000000000..180e51f8d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSTestDeviceGestureDetector.swift @@ -0,0 +1,281 @@ +/* + 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: backgrounding and foregrounding the app `requiredCycles` +/// times within `windowSeconds`, then copies the push subscription ID to the general pasteboard +/// (prefixed with `clipboardPrefix`, expiring after `pasteboardExpirySeconds`) so the person can +/// paste it into the dashboard to register a test device. Everything stays on this device; no +/// request is made. +/// +/// A cycle is a `didEnterBackground` / `didBecomeActive` pair, which is itself a filter: Control +/// Center pulls, notification-shade peeks, and Face ID sheets fire `willResignActive` without +/// ever entering the background, so they cannot count. A cycle also only counts when both phases +/// are brief. The background phase must last between `minBackgroundDwellSeconds` and +/// `maxPhaseDwellSeconds`, and the foreground phase between counted cycles at most +/// `maxPhaseDwellSeconds`. The upper bounds keep ordinary app switching, which sits in the +/// background for several seconds, from ever completing the pattern by accident. The lower bound +/// exists for parity with Android, where rotation can produce a synthetic sub-millisecond pair; +/// UIKit has no equivalent, but the same constant keeps the gesture contract identical on both +/// platforms. +/// +/// Adding `killSwitchKey` to the app's enabled feature keys disables the gesture remotely. The +/// check is inverted (key present = off) so fresh installs and offline devices, which have +/// fetched no flags yet, still get a working gesture. Reads the raw `OSFeatureFlagsStore` list +/// rather than `OSFeatureManager` because the KMP catalog is an allowlist that hides keys it +/// doesn't register. +@objc(OSTestDeviceGestureDetector) +public final class OSTestDeviceGestureDetector: NSObject { + static let requiredCycles = 6 + static let windowSeconds: TimeInterval = 30 + + /// Longest a background or foreground phase can last and still count as knocking. + static let maxPhaseDwellSeconds: TimeInterval = 2.5 + + /// Shortest background phase a human can produce; anything faster is synthetic. + static let minBackgroundDwellSeconds: TimeInterval = 0.25 + + /// Mirrors the dashboard's paste box, which validates and strips this prefix. + static let clipboardPrefix = "os:" + + static let killSwitchKey = "sdk_test_device_gesture_disabled" + static let pasteboardExpirySeconds: TimeInterval = 300 + + private static let lock = NSLock() + private static var _shared: OSTestDeviceGestureDetector? + + static var shared: OSTestDeviceGestureDetector { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSTestDeviceGestureDetector() + _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 lastForegroundedAt: 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 = OSTestDeviceGestureDetector.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 + } + } + + /// Deliberately app-level rather than per-scene, in both scene and non-scene apps. + /// UIKit posts `didEnterBackgroundNotification` only once the *last* scene backgrounds + /// and `didBecomeActiveNotification` when the app becomes active again, so the OS + /// already aggregates the "did the whole app background" question 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 + lastForegroundedAt = nil + cycleCompletions.removeAll() + return current + } + tokens.forEach(notificationCenter.removeObserver) + } + + func onUnfocused() { + let timestamp = nowProvider() + stateLock.withLock { + if !cycleCompletions.isEmpty, + let focusedAt = lastForegroundedAt, + timestamp - focusedAt > Self.maxPhaseDwellSeconds { + // Lingering in the foreground is app use, not knocking; restart the pattern. + cycleCompletions.removeAll() + } + lastBackgroundedAt = timestamp + } + } + + func onFocus() { + let timestamp = nowProvider() + let completedGesture: Bool = stateLock.withLock { + let backgroundedAt = lastBackgroundedAt + lastBackgroundedAt = nil + lastForegroundedAt = timestamp + 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 { + return false + } + if dwell > Self.maxPhaseDwellSeconds { + // The user genuinely left the app; restart the pattern. + cycleCompletions.removeAll() + return false + } + cycleCompletions.append(timestamp) + cycleCompletions.removeAll { timestamp - $0 > Self.windowSeconds } + 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: "OSTestDeviceGestureDetector: 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: "OSTestDeviceGestureDetector: gesture detected but disabled remotely") + return + } + guard let subscriptionId = self.subscriptionIdProvider(), !subscriptionId.isEmpty else { + OneSignalLog.onesignalLog( + .LL_INFO, + message: "OSTestDeviceGestureDetector: gesture detected before the push subscription exists, nothing copied" + ) + return + } + self.pasteboardWriter(Self.clipboardPrefix + subscriptionId) + OneSignalLog.onesignalLog(.LL_INFO, message: "OSTestDeviceGestureDetector: push subscription ID copied to the pasteboard") + } + } + + /// The expiry caps how long the gesture clobbers whatever the person had copied. No + /// `localOnly` option: Universal Clipboard moving the ID to the Mac running the + /// dashboard is the happy path, 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/OSTestDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSTestDeviceGestureDetectorTests.swift new file mode 100644 index 000000000..0d64529be --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSTestDeviceGestureDetectorTests.swift @@ -0,0 +1,290 @@ +/* + 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 = "os:\(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 teardown can be asserted. +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 wired to controllable seams: 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: OSTestDeviceGestureDetector! + + init(center: NotificationCenter = NotificationCenter()) { + self.center = center + detector = OSTestDeviceGestureDetector( + 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 knock. Defaults sit comfortably inside the + /// qualifying band (0.25s..2.5s background, <=2.5s foreground). + 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 OSTestDeviceGestureDetectorTests: XCTestCase { + func testSixRapidCyclesWriteThePrefixedSubscriptionId() { + let harness = Harness() + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testFiveCyclesWriteNothing() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, []) + } + + func testLongBackgroundPhaseRestartsThePattern() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + // The user genuinely left; the five knocks above must not carry over. + harness.cycle(backgroundDwell: 5.0) + for _ in 1...5 { + harness.cycle() + } + XCTAssertEqual(harness.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testLongForegroundPhaseRestartsThePattern() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + // Lingering in the foreground clears the pattern; the knock that follows starts a + // fresh count of one. + harness.cycle(foregroundDwell: 5.0) + for _ in 1...4 { + harness.cycle() + } + XCTAssertEqual(harness.writes, []) + + harness.cycle() + XCTAssertEqual(harness.writes, [expectedWrite]) + } + + func testSubHumanBackgroundBlipIsIgnoredWithoutRestartingThePattern() { + let harness = Harness() + + for _ in 1...5 { + harness.cycle() + } + // Faster than any human app switch: neither counts nor clears, so one more real + // knock 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_Test_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 d52161428..99d45af02 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]; + [OSTestDeviceGestureDetector reset]; [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; @@ -580,6 +581,7 @@ + (void)setupProtectedDataObserverOnce { [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; StartFeatureFlagsRefresh(); + [OSTestDeviceGestureDetector start]; [OneSignal startNewSession:YES]; }; @@ -681,6 +683,7 @@ + (void)init { [self startLiveActivitiesManager]; [self startInAppMessages]; StartFeatureFlagsRefresh(); + [OSTestDeviceGestureDetector start]; } [self startNewSession:YES]; From b0fe10875433091fc080215c257ca9a5c3008023 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 28 Aug 2026 09:40:29 -0700 Subject: [PATCH 2/2] feat: [SDK-5088] mark the user as a test user instead of copying to the pasteboard The gesture now sends an Update User request setting test_user_name to the device-local time, so the dashboard can surface the device without any copy-paste step. The request reuses OSRequestUpdateProperties and executes directly through the client, bypassing the operation repo; a queued operation can replay hours later, and a stale raise-hand signal is worse than a failed one. The detector in OneSignalOSCore cannot see the user layer, so OneSignal.m installs the sending handler at start. Manual testing showed the dwell maximums made the gesture nearly impossible to perform, one slow icon tap silently reset all progress, so the 30s sliding window is now the only rate rule. The 250ms background floor stays as the rotation filter, and each counted cycle logs at verbose so manual testing can watch progress. Also renames OSTestDeviceGestureDetector to OSDeviceGestureDetector so the class does not read as a test class, and renames the remote kill-switch key to sdk_device_gesture_disabled to match; nothing on the backend serves the old key yet. --- .../OneSignal.xcodeproj/project.pbxproj | 16 +- ...or.swift => OSDeviceGestureDetector.swift} | 150 ++++++++---------- ...ift => OSDeviceGestureDetectorTests.swift} | 127 ++++++--------- .../Source/OneSignalUserManagerImpl.swift | 35 ++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 14 +- 5 files changed, 168 insertions(+), 174 deletions(-) rename iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/{OSTestDeviceGestureDetector.swift => OSDeviceGestureDetector.swift} (55%) rename iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/{OSTestDeviceGestureDetectorTests.swift => OSDeviceGestureDetectorTests.swift} (60%) diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 854f74be0..b186a8af3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -82,8 +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 /* OSTestDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSTestDeviceGestureDetector.swift */; }; - A5062F01A1B2C3D4E5F60004 /* OSTestDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSTestDeviceGestureDetectorTests.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 */; }; @@ -1817,8 +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 /* OSTestDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSTestDeviceGestureDetector.swift; sourceTree = ""; }; - A5062F01A1B2C3D4E5F60003 /* OSTestDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSTestDeviceGestureDetectorTests.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 = ""; }; @@ -2309,7 +2309,7 @@ 3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */, 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, - A5062F01A1B2C3D4E5F60001 /* OSTestDeviceGestureDetector.swift */, + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, ); @@ -2612,7 +2612,7 @@ 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, - A5062F01A1B2C3D4E5F60003 /* OSTestDeviceGestureDetectorTests.swift */, + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, @@ -4492,7 +4492,7 @@ A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */, A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, - A5062F01A1B2C3D4E5F60002 /* OSTestDeviceGestureDetector.swift in Sources */, + A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, @@ -4661,7 +4661,7 @@ 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, - A5062F01A1B2C3D4E5F60004 /* OSTestDeviceGestureDetectorTests.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/OSTestDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift similarity index 55% rename from iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSTestDeviceGestureDetector.swift rename to iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index 180e51f8d..4a57cd07f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSTestDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -29,103 +29,89 @@ import Foundation import OneSignalCore import UIKit -/// Detects the test-device gesture: backgrounding and foregrounding the app `requiredCycles` -/// times within `windowSeconds`, then copies the push subscription ID to the general pasteboard -/// (prefixed with `clipboardPrefix`, expiring after `pasteboardExpirySeconds`) so the person can -/// paste it into the dashboard to register a test device. Everything stays on this device; no -/// request is made. +/// 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, which is itself a filter: Control -/// Center pulls, notification-shade peeks, and Face ID sheets fire `willResignActive` without -/// ever entering the background, so they cannot count. A cycle also only counts when both phases -/// are brief. The background phase must last between `minBackgroundDwellSeconds` and -/// `maxPhaseDwellSeconds`, and the foreground phase between counted cycles at most -/// `maxPhaseDwellSeconds`. The upper bounds keep ordinary app switching, which sits in the -/// background for several seconds, from ever completing the pattern by accident. The lower bound -/// exists for parity with Android, where rotation can produce a synthetic sub-millisecond pair; -/// UIKit has no equivalent, but the same constant keeps the gesture contract identical on both -/// platforms. +/// 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 remotely. The -/// check is inverted (key present = off) so fresh installs and offline devices, which have -/// fetched no flags yet, still get a working gesture. Reads the raw `OSFeatureFlagsStore` list -/// rather than `OSFeatureManager` because the KMP catalog is an allowlist that hides keys it -/// doesn't register. -@objc(OSTestDeviceGestureDetector) -public final class OSTestDeviceGestureDetector: NSObject { +/// 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 - /// Longest a background or foreground phase can last and still count as knocking. - static let maxPhaseDwellSeconds: TimeInterval = 2.5 - /// Shortest background phase a human can produce; anything faster is synthetic. static let minBackgroundDwellSeconds: TimeInterval = 0.25 - /// Mirrors the dashboard's paste box, which validates and strips this prefix. - static let clipboardPrefix = "os:" - - static let killSwitchKey = "sdk_test_device_gesture_disabled" - static let pasteboardExpirySeconds: TimeInterval = 300 + static let killSwitchKey = "sdk_device_gesture_disabled" private static let lock = NSLock() - private static var _shared: OSTestDeviceGestureDetector? + private static var _shared: OSDeviceGestureDetector? - static var shared: OSTestDeviceGestureDetector { + static var shared: OSDeviceGestureDetector { lock.withLock { if let existing = _shared { return existing } - let created = OSTestDeviceGestureDetector() + let created = OSDeviceGestureDetector() _shared = created return created } } private let notificationCenter: NotificationCenter - private let mainQueue: OSDispatchQueue + 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 subscriptionIdProvider: () -> String? private let shouldAwaitProvider: () -> Bool - private let pasteboardWriter: (String) -> Void 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 write to the pasteboard or re-register observers. + /// `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 lastForegroundedAt: TimeInterval? private var cycleCompletions: [TimeInterval] = [] init( notificationCenter: NotificationCenter = .default, - mainQueue: OSDispatchQueue = DispatchQueue.main, + workQueue: 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 = OSTestDeviceGestureDetector.writeToGeneralPasteboard + handler: (() -> Void)? = nil ) { self.notificationCenter = notificationCenter - self.mainQueue = mainQueue + self.workQueue = workQueue self.nowProvider = nowProvider self.enabledFlagsProvider = enabledFlagsProvider - self.subscriptionIdProvider = subscriptionIdProvider self.shouldAwaitProvider = shouldAwaitProvider - self.pasteboardWriter = pasteboardWriter + self.handler = handler super.init() } - /// Idempotent: registers the lifecycle observers once and keeps counting from there. - @objc public static func start() { - shared.registerLifecycleObserversIfNeeded() + /// 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() { @@ -135,11 +121,17 @@ public final class OSTestDeviceGestureDetector: NSObject { } } - /// Deliberately app-level rather than per-scene, in both scene and non-scene apps. - /// UIKit posts `didEnterBackgroundNotification` only once the *last* scene backgrounds - /// and `didBecomeActiveNotification` when the app becomes active again, so the OS - /// already aggregates the "did the whole app background" question a cycle counter - /// needs. Per-scene events would over-count on multi-window iPad. + 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 { @@ -185,8 +177,8 @@ public final class OSTestDeviceGestureDetector: NSObject { let current = notificationTokens notificationTokens.removeAll() started = false + handler = nil lastBackgroundedAt = nil - lastForegroundedAt = nil cycleCompletions.removeAll() return current } @@ -196,12 +188,6 @@ public final class OSTestDeviceGestureDetector: NSObject { func onUnfocused() { let timestamp = nowProvider() stateLock.withLock { - if !cycleCompletions.isEmpty, - let focusedAt = lastForegroundedAt, - timestamp - focusedAt > Self.maxPhaseDwellSeconds { - // Lingering in the foreground is app use, not knocking; restart the pattern. - cycleCompletions.removeAll() - } lastBackgroundedAt = timestamp } } @@ -211,22 +197,26 @@ public final class OSTestDeviceGestureDetector: NSObject { let completedGesture: Bool = stateLock.withLock { let backgroundedAt = lastBackgroundedAt lastBackgroundedAt = nil - lastForegroundedAt = timestamp 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 { - return false - } - if dwell > Self.maxPhaseDwellSeconds { - // The user genuinely left the app; restart the pattern. - cycleCompletions.removeAll() + // 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 @@ -234,19 +224,19 @@ public final class OSTestDeviceGestureDetector: NSObject { return false } if completedGesture { - copySubscriptionIdToPasteboard() + fireHandler() } } - private func copySubscriptionIdToPasteboard() { - mainQueue.async { [weak self] in + 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: "OSTestDeviceGestureDetector: gesture detected but the SDK is not ready (appId, consent, or storage)" + message: "OSDeviceGestureDetector: gesture detected but the SDK is not ready (appId, consent, or storage)" ) return } @@ -254,28 +244,14 @@ public final class OSTestDeviceGestureDetector: NSObject { $0.caseInsensitiveCompare(Self.killSwitchKey) == .orderedSame } guard !disabled else { - OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSTestDeviceGestureDetector: gesture detected but disabled remotely") + 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: "OSTestDeviceGestureDetector: gesture detected before the push subscription exists, nothing copied" - ) + guard let handler = self.stateLock.withLock({ self.handler }) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but no handler is installed") return } - self.pasteboardWriter(Self.clipboardPrefix + subscriptionId) - OneSignalLog.onesignalLog(.LL_INFO, message: "OSTestDeviceGestureDetector: push subscription ID copied to the pasteboard") + handler() } } - - /// The expiry caps how long the gesture clobbers whatever the person had copied. No - /// `localOnly` option: Universal Clipboard moving the ID to the Mac running the - /// dashboard is the happy path, 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/OSTestDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift similarity index 60% rename from iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSTestDeviceGestureDetectorTests.swift rename to iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index 0d64529be..71622d563 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSTestDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -29,10 +29,7 @@ import UIKit import XCTest @testable import OneSignalOSCore -private let subscriptionId = "aaaabbbb-cccc-dddd-eeee-ffff00001111" -private let expectedWrite = "os:\(subscriptionId)" - -/// Runs queued work inline so pasteboard writes are observable synchronously. +/// Runs queued work inline so handler invocations are observable synchronously. private final class InlineQueue: OSDispatchQueue { func async(execute work: @escaping @convention(block) () -> Void) { work() @@ -43,7 +40,7 @@ private final class InlineQueue: OSDispatchQueue { } } -/// Counts live observer registrations so teardown can be asserted. +/// Counts live observer registrations so tests can assert teardown. private final class ObserverTrackingCenter: NotificationCenter { private(set) var liveObservers = 0 @@ -63,33 +60,31 @@ private final class ObserverTrackingCenter: NotificationCenter { } } -/// Owns a detector wired to controllable seams: an injected notification center, a fake -/// monotonic clock, and a writer that records instead of touching the real pasteboard. +/// 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 currentSubscriptionId: String? = subscriptionId var shouldAwait = false - private(set) var writes: [String] = [] - private(set) var detector: OSTestDeviceGestureDetector! + private(set) var fires = 0 + private(set) var detector: OSDeviceGestureDetector! - init(center: NotificationCenter = NotificationCenter()) { + init(center: NotificationCenter = NotificationCenter(), installHandler: Bool = true) { self.center = center - detector = OSTestDeviceGestureDetector( + detector = OSDeviceGestureDetector( notificationCenter: center, - mainQueue: InlineQueue(), + workQueue: 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) } + handler: installHandler ? { [unowned self] in self.fires += 1 } : nil ) detector.registerLifecycleObserversIfNeeded() } - /// One foreground-dwell + background-dwell knock. Defaults sit comfortably inside the - /// qualifying band (0.25s..2.5s background, <=2.5s foreground). + /// 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) @@ -98,75 +93,67 @@ private final class Harness { } } -final class OSTestDeviceGestureDetectorTests: XCTestCase { - func testSixRapidCyclesWriteThePrefixedSubscriptionId() { +final class OSDeviceGestureDetectorTests: XCTestCase { + func testSixRapidCyclesFireTheHandlerOnce() { let harness = Harness() for _ in 1...6 { harness.cycle() } - XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(harness.fires, 1) } - func testFiveCyclesWriteNothing() { + func testFiveCyclesFireNothing() { let harness = Harness() for _ in 1...5 { harness.cycle() } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.fires, 0) } - func testLongBackgroundPhaseRestartsThePattern() { + func testCyclesSlowerThanTheWindowNeverAccumulateSix() { let harness = Harness() - for _ in 1...5 { - harness.cycle() + // 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) } - // The user genuinely left; the five knocks above must not carry over. - harness.cycle(backgroundDwell: 5.0) - for _ in 1...5 { - harness.cycle() - } - XCTAssertEqual(harness.writes, []) - harness.cycle() - XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(harness.fires, 0) } - func testLongForegroundPhaseRestartsThePattern() { + func testPauseMidGestureDoesNotResetProgress() { let harness = Harness() - for _ in 1...5 { + for _ in 1...3 { harness.cycle() } - // Lingering in the foreground clears the pattern; the knock that follows starts a - // fresh count of one. - harness.cycle(foregroundDwell: 5.0) - for _ in 1...4 { + // 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, []) - harness.cycle() - XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(harness.fires, 1) } - func testSubHumanBackgroundBlipIsIgnoredWithoutRestartingThePattern() { + func testSubHumanBackgroundBlipDoesNotCountAsACycle() { let harness = Harness() for _ in 1...5 { harness.cycle() } - // Faster than any human app switch: neither counts nor clears, so one more real - // knock completes the gesture. + // 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, []) + XCTAssertEqual(harness.fires, 0) harness.cycle() - XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(harness.fires, 1) } func testRepeatedActivationsWithoutABackgroundDoNotCount() { @@ -180,10 +167,10 @@ final class OSTestDeviceGestureDetectorTests: XCTestCase { harness.now += 0.5 harness.center.post(name: UIApplication.didBecomeActiveNotification, object: nil) } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.fires, 0) harness.cycle() - XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(harness.fires, 1) } func testColdStartActivationDoesNotCount() { @@ -193,10 +180,10 @@ final class OSTestDeviceGestureDetectorTests: XCTestCase { for _ in 1...5 { harness.cycle() } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.fires, 0) harness.cycle() - XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(harness.fires, 1) } func testDetectorReArmsAfterFiring() { @@ -206,21 +193,21 @@ final class OSTestDeviceGestureDetectorTests: XCTestCase { harness.cycle() } - XCTAssertEqual(harness.writes, [expectedWrite, expectedWrite]) + XCTAssertEqual(harness.fires, 2) } - func testKillSwitchKeySuppressesTheWriteCaseInsensitively() { + func testKillSwitchKeySuppressesTheHandlerCaseInsensitively() { let harness = Harness() - harness.flags = ["SDK_Test_Device_Gesture_Disabled"] + harness.flags = ["SDK_Device_Gesture_Disabled"] for _ in 1...6 { harness.cycle() } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.fires, 0) } - func testUnrelatedFlagKeysDoNotSuppressTheWrite() { + func testUnrelatedFlagKeysDoNotSuppressTheHandler() { let harness = Harness() harness.flags = ["sdk_custom_logging", "sdk_identity_verification"] @@ -228,10 +215,10 @@ final class OSTestDeviceGestureDetectorTests: XCTestCase { harness.cycle() } - XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertEqual(harness.fires, 1) } - func testNotReadySdkSuppressesTheWrite() { + func testNotReadySdkSuppressesTheHandler() { let harness = Harness() harness.shouldAwait = true @@ -239,29 +226,17 @@ final class OSTestDeviceGestureDetectorTests: XCTestCase { harness.cycle() } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.fires, 0) } - func testMissingSubscriptionIdWritesNothing() { - let harness = Harness() - harness.currentSubscriptionId = nil - - for _ in 1...6 { - harness.cycle() - } - - XCTAssertEqual(harness.writes, []) - } - - func testEmptySubscriptionIdWritesNothing() { - let harness = Harness() - harness.currentSubscriptionId = "" + func testMissingHandlerIsSafe() { + let harness = Harness(installHandler: false) for _ in 1...6 { harness.cycle() } - XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.fires, 0) } func testRegistrationIsIdempotent() { @@ -281,10 +256,10 @@ final class OSTestDeviceGestureDetectorTests: XCTestCase { harness.detector.tearDown() XCTAssertEqual(center.liveObservers, 0) - // Queued or in-flight gestures on a torn-down instance must not write. + // Queued or in-flight gestures on a torn-down instance must not fire. for _ in 1...6 { harness.cycle() } - XCTAssertEqual(harness.writes, []) + 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 99d45af02..f5633b541 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -141,7 +141,7 @@ + (OneSignalReceiveReceiptsController*)receiveReceiptsController { + (void)clearStatics { [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; - [OSTestDeviceGestureDetector reset]; + [OSDeviceGestureDetector reset]; [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; @@ -503,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) { @@ -581,7 +589,7 @@ + (void)setupProtectedDataObserverOnce { [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; StartFeatureFlagsRefresh(); - [OSTestDeviceGestureDetector start]; + StartDeviceGestureDetector(); [OneSignal startNewSession:YES]; }; @@ -683,7 +691,7 @@ + (void)init { [self startLiveActivitiesManager]; [self startInAppMessages]; StartFeatureFlagsRefresh(); - [OSTestDeviceGestureDetector start]; + StartDeviceGestureDetector(); } [self startNewSession:YES];