Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = [
"<true/>",
"<integer>1</integer>",
"<real>1.0</real>",
"<string>YES</string>",
"<string>yes</string>",
"<string>true</string>",
"<string>1</string>"
]
for xml in optedOut {
XCTAssertTrue(OSDeviceGestureDetector.isDisabledByApp(infoPlistValue: try plistValue(xml)), xml)
}

let stillOn = [
"<false/>",
"<integer>0</integer>",
"<string>NO</string>",
"<string>false</string>",
"<string>0</string>",
"<string></string>"
]
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 = [
"<array><string>YES</string></array>",
"<dict><key>enabled</key><true/></dict>",
"<date>2026-09-11T00:00:00Z</date>",
"<data>WUVT</data>"
]
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict><key>k</key>\(xml)</dict></plist>
"""
let dict = try XCTUnwrap(PropertyListSerialization.propertyList(from: Data(plist.utf8), format: nil) as? [String: Any])
return try XCTUnwrap(dict["k"], xml)
}
}
Loading