Skip to content
Draft
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 @@ -82,6 +82,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 */; };
B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; };
25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; };
Expand Down Expand Up @@ -1815,6 +1817,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>"; };
8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = "<group>"; };
7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingConfiguration.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2305,6 +2309,7 @@
3C5C70072FCBAA5C00102E2C /* OneSignalConfig.swift */,
3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */,
4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */,
A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */,
DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */,
DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */,
);
Expand Down Expand Up @@ -2607,6 +2612,7 @@
3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */,
A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */,
A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */,
A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */,
3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */,
3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */,
3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */,
Expand Down Expand Up @@ -4486,6 +4492,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 @@ -4654,6 +4661,7 @@
3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */,
A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */,
A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */,
A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */,
C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */,
3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
/*
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-user gesture: `requiredCycles` background/foreground cycles within
/// `windowSeconds`, then runs the handler installed by `start(handler:)`. The handler lives
/// in the user layer, which this framework cannot see, and sends the Update User request
/// that sets `test_user_name`.
///
/// 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 inside it
/// takes sustained five-second round trips.
///
/// Adding `killSwitchKey` to the app's enabled feature keys disables the gesture. Absent
/// means enabled, so a device that has never fetched flags still has it. Reads the raw
/// `OSFeatureFlagsStore` list because `OSFeatureManager` only resolves keys the KMP catalog
/// registers.
@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"

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 workQueue: OSDispatchQueue
/// Monotonic clock, so wall-clock jumps from NTP or manual time changes cannot stretch
/// or shrink the window.
private let nowProvider: () -> TimeInterval
private let enabledFlagsProvider: () -> [String]
private let shouldAwaitProvider: () -> Bool

private let stateLock = NSLock()
/// Installed by `start(handler:)`; first installer wins so repeated starts stay idempotent.
private var handler: (() -> Void)?
private var started = false
/// Set by `tearDown` and never cleared, so work already queued for an instance that
/// `shared` has dropped cannot fire the handler or re-register observers.
private var invalidated = false
private var notificationTokens: [NSObjectProtocol] = []
private var lastBackgroundedAt: TimeInterval?
private var cycleCompletions: [TimeInterval] = []

init(
notificationCenter: NotificationCenter = .default,
workQueue: OSDispatchQueue = DispatchQueue.main,
nowProvider: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
enabledFlagsProvider: @escaping () -> [String] = { OSFeatureFlagsStore.shared.sdkRemoteFeatureFlags },
shouldAwaitProvider: @escaping () -> Bool = {
OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil)
},
handler: (() -> Void)? = nil
) {
self.notificationCenter = notificationCenter
self.workQueue = workQueue
self.nowProvider = nowProvider
self.enabledFlagsProvider = enabledFlagsProvider
self.shouldAwaitProvider = shouldAwaitProvider
self.handler = handler
super.init()
}

/// Idempotent: registers the lifecycle observers once. The handler runs after a
/// completed gesture passes the readiness and kill-switch gates.
@objc public static func start(handler: @escaping () -> Void) {
let service = shared
service.adoptHandlerIfUnset(handler)
service.registerLifecycleObserversIfNeeded()
}

@objc public static func reset() {
lock.withLock {
_shared?.tearDown()
_shared = nil
}
}

private func adoptHandlerIfUnset(_ newHandler: @escaping () -> Void) {
stateLock.withLock {
if handler == nil {
handler = newHandler
}
}
}

/// 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
handler = nil
lastBackgroundedAt = nil
cycleCompletions.removeAll()
return current
}
tokens.forEach(notificationCenter.removeObserver)
}

func onUnfocused() {
let timestamp = nowProvider()
stateLock.withLock {
lastBackgroundedAt = timestamp
}
}

func onFocus() {
let timestamp = nowProvider()
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.
OneSignalLog.onesignalLog(
.LL_VERBOSE,
message: "OSDeviceGestureDetector: ignored a \(String(format: "%.3f", dwell))s background blip (rotation filter)"
)
return false
}
cycleCompletions.append(timestamp)
cycleCompletions.removeAll { timestamp - $0 > Self.windowSeconds }
OneSignalLog.onesignalLog(
.LL_VERBOSE,
message: "OSDeviceGestureDetector: 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 completedGesture {
fireHandler()
}
}

private func fireHandler() {
workQueue.async { [weak self] in
guard let self, !self.stateLock.withLock({ self.invalidated }) else {
return
}
guard !self.shouldAwaitProvider() else {
OneSignalLog.onesignalLog(
.LL_DEBUG,
message: "OSDeviceGestureDetector: gesture detected but the SDK is not ready (appId, consent, or storage)"
)
return
}
let disabled = self.enabledFlagsProvider().contains {
$0.caseInsensitiveCompare(Self.killSwitchKey) == .orderedSame
}
guard !disabled else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but disabled remotely")
return
}
guard let handler = self.stateLock.withLock({ self.handler }) else {
OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSDeviceGestureDetector: gesture detected but no handler is installed")
return
}
handler()
}
}
}
Loading
Loading