Skip to content
Open
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
8 changes: 8 additions & 0 deletions iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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 */; };
32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; };
B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; };
Expand Down Expand Up @@ -1819,6 +1821,8 @@
A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshService.swift; sourceTree = "<group>"; };
A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = "<group>"; };
A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = "<group>"; };
A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = "<group>"; };
A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = "<group>"; };
3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = "<group>"; };
9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = "<group>"; };
8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2311,6 +2315,7 @@
3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */,
3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */,
4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */,
A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */,
DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */,
DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */,
);
Expand Down Expand Up @@ -2615,6 +2620,7 @@
5156E0E0A1B2C3D4E5F60003 /* OSObservabilityEventRecorderTests.swift */,
A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */,
A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */,
A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */,
3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */,
9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */,
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */,
Expand Down Expand Up @@ -4495,6 +4501,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 */,
Expand Down Expand Up @@ -4665,6 +4672,7 @@
5156E0E0A1B2C3D4E5F60004 /* OSObservabilityEventRecorderTests.swift in Sources */,
A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */,
A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */,
A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */,
C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */,
32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */,
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
/*
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, prefixed
/// `os:` (see `clipText`), so the person can paste it into the dashboard. Without a subscription
/// it copies `noSubscriptionClipText` instead, so someone following the docs can tell the gesture
/// worked.
///
/// 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 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.
///
/// Every recognised gesture also records `OSObservabilityEvent.deviceGesture`, with its outcome
/// and the copied ID, so the gesture's usage can be measured.
@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"

/// The copied ID expires after five minutes. Whatever it replaced is not restored.
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 and keeps counting through sleep, so neither a wall-clock jump nor a nap can
/// stretch or shrink the window; `systemUptime` would stop at each lock and stitch visits
/// hours apart into one window.
private let nowProvider: () -> TimeInterval
private let isDisabledRemotelyProvider: () -> Bool
private let subscriptionIdProvider: () -> String?
private let shouldAwaitProvider: () -> Bool
private let pasteboardWriter: (String) -> Void
private let eventRecorder: OSObservabilityEventRecorderProtocol

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 = {
TimeInterval(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)) / TimeInterval(NSEC_PER_SEC)
},
isDisabledRemotelyProvider: @escaping () -> Bool = {
OSFeatureManager.shared.isEnabled(featureKey: OSDeviceGestureDetector.killSwitchKey)
},
subscriptionIdProvider: @escaping () -> String? = { OneSignalIdentifiers.subscriptionId },
shouldAwaitProvider: @escaping () -> Bool = {
OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil)
},
pasteboardWriter: @escaping (String) -> Void = OSDeviceGestureDetector.writeToGeneralPasteboard,
eventRecorder: OSObservabilityEventRecorderProtocol = OSObservabilityEventRecorder.shared
) {
self.notificationCenter = notificationCenter
self.mainQueue = mainQueue
self.nowProvider = nowProvider
self.isDisabledRemotelyProvider = isDisabledRemotelyProvider
self.subscriptionIdProvider = subscriptionIdProvider
self.shouldAwaitProvider = shouldAwaitProvider
self.pasteboardWriter = pasteboardWriter
self.eventRecorder = eventRecorder
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()
// Logged after the lock: OneSignalLog runs app listeners synchronously, and one that
// re-enters the SDK would deadlock on it.
var progress: String?
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.
progress = "ignored a \(String(format: "%.3f", dwell))s background blip (rotation filter)"
return false
}
cycleCompletions.append(timestamp)
cycleCompletions.removeAll { timestamp - $0 > Self.windowSeconds }
progress = "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 let progress {
OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSDeviceGestureDetector: \(progress)")
}
if completedGesture {
copySubscriptionIdToPasteboard()
}
}

private func copySubscriptionIdToPasteboard() {
mainQueue.async { [weak self] in
guard let self, !self.stateLock.withLock({ self.invalidated }) else {
return
}
// Not recorded either: without an app id or consent, nothing about the device may ship.
guard !self.shouldAwaitProvider() else {
OneSignalLog.onesignalLog(
.LL_DEBUG,
message: "OSDeviceGestureDetector: gesture detected but the SDK is not ready (appId, consent, or storage)"
)
return
}
guard !self.isDisabledRemotelyProvider() else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but disabled remotely")
self.recordGesture(.disabled)
return
}
guard let subscriptionId = self.subscriptionIdProvider(), !subscriptionId.isEmpty else {
self.pasteboardWriter(Self.noSubscriptionClipText)
OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: pasteboard set, gesture result no_id")
self.recordGesture(.noId)
return
}
self.pasteboardWriter(Self.clipText(subscriptionId: subscriptionId))
OneSignalLog.onesignalLog(.LL_INFO, message: "OSDeviceGestureDetector: pasteboard set, gesture result copied")
self.recordGesture(.copied, copiedId: subscriptionId)
}
}

/// Wire values of `gesture.result`, which backend queries match on.
private enum GestureResult: String {
case copied
case noId = "no_id"
case disabled
}

/// `copied` and `noId` are recorded after the pasteboard write, so neither claims a change that
/// did not happen. `disabled` is recorded at the decision, since nothing is written.
private func recordGesture(_ result: GestureResult, copiedId: String? = nil) {
var attributes = ["gesture.result": result.rawValue]
if let copiedId {
attributes["gesture.push_subscription_id"] = copiedId
}
eventRecorder.record(event: .deviceGesture, attributes: attributes)
}

private static let clipPrefix = "os: "
static let noSubscriptionClipText = clipPrefix + "no subscription ID yet"

/// The `os:` prefix marks the value as a OneSignal ID, for the dashboard's paste target and for
/// anyone who copied it by accident.
static func clipText(subscriptionId: String) -> String {
clipPrefix + 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)]
)
}
}
Loading
Loading