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)
+ }
+}