From 9d172e3a281539082ef411168dd75069c92bc12a Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 2 Sep 2026 19:29:03 -0700 Subject: [PATCH] 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 names what it is and which app copied it, so anyone who performs the gesture by accident can tell what landed on their pasteboard. 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 | 272 +++++++++++++++++ .../OSDeviceGestureDetectorTests.swift | 284 ++++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 3 + 4 files changed, 567 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 a76259a58..b186a8af3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -82,6 +82,8 @@ A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */; }; A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; + A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; + A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1815,6 +1817,8 @@ A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshService.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingConfiguration.swift; sourceTree = ""; }; @@ -2305,6 +2309,7 @@ 3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */, 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, + A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, ); @@ -2607,6 +2612,7 @@ 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, + A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, @@ -4486,6 +4492,7 @@ A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */, A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, + A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, @@ -4654,6 +4661,7 @@ 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, + A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift new file mode 100644 index 000000000..f0a89be1b --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -0,0 +1,272 @@ +/* + 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 so the +/// person can paste it into the dashboard. The clip is self-describing (see `clipText`) +/// because it lands on the pasteboard of anyone who happens to perform the gesture, and a +/// bare UUID there is a mystery. +/// +/// 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") + } + } + + /// Names what the ID is and which app copied it, so an accidental copy explains itself. + static func clipText(subscriptionId: String) -> String { + let info = Bundle.main.infoDictionary ?? [:] + let candidates = [info["CFBundleDisplayName"] as? String, info["CFBundleName"] as? String, Bundle.main.bundleIdentifier] + let appName = candidates.compactMap { $0 }.first { !$0.isEmpty } ?? "this app" + return "OneSignal subscription ID for \(appName): \(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..4ace9c996 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -0,0 +1,284 @@ +/* + 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 testSixRapidCyclesWriteTheSelfDescribingSubscriptionId() { + let harness = Harness() + + for _ in 1...6 { + harness.cycle() + } + + XCTAssertEqual(harness.writes, [expectedWrite]) + XCTAssertTrue(expectedWrite.hasPrefix("OneSignal subscription ID for ")) + XCTAssertTrue(expectedWrite.hasSuffix(": \(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 d52161428..2283d9bd4 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];