From 47f75d9fcd2989dbd0da29219c1b7fb860303539 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 10 Sep 2026 10:43:05 -0700 Subject: [PATCH 1/2] feat: [SDK-5193] let an app opt out of subscription ID copy in Info.plist The gesture is on by default and, until now, only OneSignal could turn it off, through the remote kill switch. An app that does not want a pasteboard write in production can now set OneSignal_disable_subscription_id_copy to YES in Info.plist, read the same way as OneSignal_disable_badge_clearing. With the key set the detector never registers its observers, so nothing is counted, written or recorded. That is deliberately stricter than the remote kill switch, which still records disabled so attempts can be counted: an app that opted out has said no. One INFO line at start says the key was honored, so a typo in it is not silent. The started latch still flips, so the recovery path's second start stays quiet. No public API, so wrapper SDKs need no change to expose it. --- .../Source/OSDeviceGestureDetector.swift | 17 +++++++++++++++- .../OSDeviceGestureDetectorTests.swift | 20 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index 606aaac3d..cf4c25ee6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -42,7 +42,9 @@ import UIKit /// 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. +/// that has never fetched flags still has it. An app can also opt out for good with the +/// Info.plist key `disableInfoPlistKey`; then the detector never starts, so nothing is counted +/// or recorded. /// /// Every recognised gesture also records `OSObservabilityEvent.deviceGesture`, with its outcome /// and the copied ID, so the gesture's usage can be measured. @@ -55,6 +57,7 @@ public final class OSDeviceGestureDetector: NSObject { static let minBackgroundDwellSeconds: TimeInterval = 0.25 static let killSwitchKey = "sdk_device_gesture_disabled" + static let disableInfoPlistKey = "OneSignal_disable_subscription_id_copy" /// The copied ID expires after five minutes. Whatever it replaced is not restored. static let pasteboardExpirySeconds: TimeInterval = 300 @@ -80,6 +83,7 @@ public final class OSDeviceGestureDetector: NSObject { /// hours apart into one window. private let nowProvider: () -> TimeInterval private let isDisabledRemotelyProvider: () -> Bool + private let isDisabledByAppProvider: () -> Bool private let subscriptionIdProvider: () -> String? private let shouldAwaitProvider: () -> Bool private let pasteboardWriter: (String) -> Void @@ -103,6 +107,9 @@ public final class OSDeviceGestureDetector: NSObject { isDisabledRemotelyProvider: @escaping () -> Bool = { OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey) }, + isDisabledByAppProvider: @escaping () -> Bool = { + (Bundle.main.object(forInfoDictionaryKey: OSDeviceGestureDetector.disableInfoPlistKey) as? NSNumber)?.boolValue ?? false + }, subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, shouldAwaitProvider: @escaping () -> Bool = { OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) @@ -114,6 +121,7 @@ public final class OSDeviceGestureDetector: NSObject { self.mainQueue = mainQueue self.nowProvider = nowProvider self.isDisabledRemotelyProvider = isDisabledRemotelyProvider + self.isDisabledByAppProvider = isDisabledByAppProvider self.subscriptionIdProvider = subscriptionIdProvider self.shouldAwaitProvider = shouldAwaitProvider self.pasteboardWriter = pasteboardWriter @@ -137,6 +145,8 @@ public final class OSDeviceGestureDetector: NSObject { /// 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() { + // Read before the lock; it touches the app bundle, not the SDK. + let optedOut = isDisabledByAppProvider() let shouldSkip = stateLock.withLock { () -> Bool in if started || invalidated { return true @@ -147,6 +157,11 @@ public final class OSDeviceGestureDetector: NSObject { guard !shouldSkip else { return } + // The app owner's opt-out. Logged so a developer can confirm the key took. + guard !optedOut else { + OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: disabled by \(Self.disableInfoPlistKey), not starting") + return + } observe(UIApplication.didEnterBackgroundNotification) { [weak self] in self?.onUnfocused() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index 61d39ba23..177b1cd36 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -106,13 +106,14 @@ private final class Harness { let recorder = EventRecorderSpy() private(set) var detector: OSDeviceGestureDetector! - init(center: NotificationCenter = NotificationCenter(), queue: OSDispatchQueue = InlineQueue()) { + init(center: NotificationCenter = NotificationCenter(), queue: OSDispatchQueue = InlineQueue(), disabledByApp: Bool = false) { self.center = center detector = OSDeviceGestureDetector( notificationCenter: center, mainQueue: queue, nowProvider: { [unowned self] in self.now }, isDisabledRemotelyProvider: { [unowned self] in self.killSwitchOn }, + isDisabledByAppProvider: { disabledByApp }, subscriptionIdProvider: { [unowned self] in self.currentSubscriptionId }, shouldAwaitProvider: { [unowned self] in self.shouldAwait }, pasteboardWriter: { [unowned self] in self.writes.append($0) }, @@ -304,6 +305,23 @@ final class OSDeviceGestureDetectorTests: XCTestCase { XCTAssertEqual(center.liveObservers, 0) } + func testInfoPlistOptOutKeepsTheDetectorFromStarting() { + // The app owner said no: no observers are registered, so nothing is counted, written + // or recorded, and a second start does not register either because the latch holds. + let center = ObserverTrackingCenter() + let harness = Harness(center: center, disabledByApp: true) + XCTAssertEqual(center.liveObservers, 0) + + for _ in 1...6 { + harness.cycle() + } + XCTAssertEqual(harness.writes, []) + XCTAssertEqual(harness.recorder.events, []) + + harness.detector.registerLifecycleObserversIfNeeded() + 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. From 1c7d2d47fd8d0a7878b38a217f2016bf68a9cd6f Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 11 Sep 2026 12:29:43 -0700 Subject: [PATCH 2/2] fix: [SDK-5193] read the opt-out like the older Info.plist switches A String value such as YES now opts out, matching the dynamic boolValue the other Info.plist keys use, instead of being ignored. Also renames killSwitchKey to remoteKillSwitchKey to tell the two keys apart. --- .../Source/OSDeviceGestureDetector.swift | 16 +++-- .../OSDeviceGestureDetectorTests.swift | 58 ++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index cf4c25ee6..68bf6c6a4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -41,7 +41,7 @@ import UIKit /// 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 +/// The `remoteKillSwitchKey` catalog flag turns the gesture off. Absent means enabled, so a device /// that has never fetched flags still has it. An app can also opt out for good with the /// Info.plist key `disableInfoPlistKey`; then the detector never starts, so nothing is counted /// or recorded. @@ -56,7 +56,7 @@ public final class OSDeviceGestureDetector: NSObject { /// Shortest background phase a human can produce; anything faster is synthetic. static let minBackgroundDwellSeconds: TimeInterval = 0.25 - static let killSwitchKey = "sdk_device_gesture_disabled" + static let remoteKillSwitchKey = "sdk_device_gesture_disabled" static let disableInfoPlistKey = "OneSignal_disable_subscription_id_copy" /// The copied ID expires after five minutes. Whatever it replaced is not restored. @@ -105,10 +105,12 @@ public final class OSDeviceGestureDetector: NSObject { TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC) }, isDisabledRemotelyProvider: @escaping () -> Bool = { - OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey) + OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.remoteKillSwitchKey) }, isDisabledByAppProvider: @escaping () -> Bool = { - (Bundle.main.object(forInfoDictionaryKey: OSDeviceGestureDetector.disableInfoPlistKey) as? NSNumber)?.boolValue ?? false + OSDeviceGestureDetector.isDisabledByApp( + infoPlistValue: Bundle.main.object(forInfoDictionaryKey: OSDeviceGestureDetector.disableInfoPlistKey) + ) }, subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, shouldAwaitProvider: @escaping () -> Bool = { @@ -302,6 +304,12 @@ public final class OSDeviceGestureDetector: NSObject { clipPrefix + subscriptionId } + /// Dynamic `boolValue`, as Objective-C sends to `id`. A Boolean, Number or String such as + /// `YES` opts out. Anything else, including a missing key, does not. + static func isDisabledByApp(infoPlistValue value: Any?) -> Bool { + (value as AnyObject?)?.boolValue ?? false + } + /// 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) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift index 177b1cd36..e06356591 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeviceGestureDetectorTests.swift @@ -250,7 +250,7 @@ final class OSDeviceGestureDetectorTests: XCTestCase { 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) + XCTAssertEqual(OSDeviceGestureDetector.remoteKillSwitchKey, FeatureFlag.sdkDeviceGestureDisabled.key) } func testNotReadySdkSuppressesTheWrite() { @@ -450,3 +450,59 @@ final class OSDeviceGestureDetectorTests: XCTestCase { XCTAssertEqual(harness.recorder.attributes.map { $0["gesture.result"] }, ["copied", "copied"]) } } + +/// Parsing of the Info.plist opt-out value. A separate class so the one above stays under +/// SwiftLint's body length limit. +final class OSDeviceGestureDetectorInfoPlistTests: XCTestCase { + func testReadsTheValueTypesTheOlderSwitchesAccept() throws { + // Each case is the literal Info.plist text. A String such as YES has to count too. + let optedOut = [ + "", + "1", + "1.0", + "YES", + "yes", + "true", + "1" + ] + for xml in optedOut { + XCTAssertTrue(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: try plistValue(xml)), xml) + } + + let stillOn = [ + "", + "0", + "NO", + "false", + "0", + "" + ] + for xml in stillOn { + XCTAssertFalse(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: try plistValue(xml)), xml) + } + } + + func testIgnoresAMissingOrMistypedValue() throws { + // A missing or mistyped value leaves the gesture on and must not crash. + XCTAssertFalse(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: nil)) + let mistyped = [ + "YES", + "enabled", + "2026-09-11T00:00:00Z", + "WUVT" + ] + for xml in mistyped { + XCTAssertFalse(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: try plistValue(xml)), xml) + } + } + + /// The value of one Info.plist key written as `xml`, with the type `Bundle.main` would return. + private func plistValue(_ xml: String) throws -> Any { + let plist = """ + + k\(xml) + """ + let dict = try XCTUnwrap(PropertyListSerialization.propertyList(from: Data(plist.utf8), format: nil) as? [String: Any]) + return try XCTUnwrap(dict["k"], xml) + } +}