diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift index 606aaac3d..68bf6c6a4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDeviceGestureDetector.swift @@ -41,8 +41,10 @@ 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 -/// that has never fetched flags still has it. +/// 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. /// /// Every recognised gesture also records `OSObservabilityEvent.deviceGesture`, with its outcome /// and the copied ID, so the gesture's usage can be measured. @@ -54,7 +56,8 @@ 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. 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 @@ -101,7 +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 = { + OSDeviceGestureDetector.isDisabledByApp( + infoPlistValue: Bundle.main.object(forInfoDictionaryKey: OSDeviceGestureDetector.disableInfoPlistKey) + ) }, subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId }, shouldAwaitProvider: @escaping () -> Bool = { @@ -114,6 +123,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 +147,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 +159,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() @@ -287,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 61d39ba23..e06356591 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) }, @@ -249,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() { @@ -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. @@ -432,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) + } +}