From 17c8167ded5baa4a9822867a97a32ead997b39ea Mon Sep 17 00:00:00 2001 From: Miranda Wilson Date: Fri, 19 Jan 2024 14:51:19 +0000 Subject: [PATCH] Add VisionOS events and entities (#857) * Add new VisionOS classes * Add tests * Update demo * Standardise ID capitalisation * Automatically track window group entity with OpenWindow and DismissWindow events * Make string IDs the main property * Add ImmersiveSpaceStateMachine * Update demo app * Update following merge * Add immersive space entity to open event when autotracking is off * Small review changes * Always update state on open event * Correct visionOS capitalisation * Add immersive space entity by default * Generate viewId UUID for immersive space --- Examples | 2 +- .../TrackerControllerIQWrapper.swift | 5 + .../StateMachine/ImmersiveSpaceState.swift | 30 +++ .../ImmersiveSpaceStateMachine.swift | 107 +++++++++ Sources/Core/Tracker/ServiceProvider.swift | 1 + Sources/Core/Tracker/Tracker.swift | 15 ++ .../Core/Tracker/TrackerControllerImpl.swift | 10 + Sources/Core/Tracker/TrackerDefaults.swift | 1 + Sources/Core/TrackerConstants.swift | 8 + .../Configurations/TrackerConfiguration.swift | 100 +++++--- .../Entities/ImmersiveSpaceEntity.swift | 113 +++++++++ .../VisionOS/Entities/WindowGroupEntity.swift | 94 ++++++++ .../Events/DismissImmersiveSpaceEvent.swift | 26 +++ .../VisionOS/Events/DismissWindowEvent.swift | 68 ++++++ .../Events/OpenImmersiveSpaceEvent.swift | 68 ++++++ .../VisionOS/Events/OpenWindowEvent.swift | 68 ++++++ Tests/VisionOS/TestImmersiveSpaceState.swift | 221 ++++++++++++++++++ Tests/VisionOS/TestVisionOSEntities.swift | 51 ++++ Tests/VisionOS/TestVisionOSEvents.swift | 155 ++++++++++++ 19 files changed, 1104 insertions(+), 39 deletions(-) create mode 100644 Sources/Core/StateMachine/ImmersiveSpaceState.swift create mode 100644 Sources/Core/StateMachine/ImmersiveSpaceStateMachine.swift create mode 100644 Sources/Snowplow/VisionOS/Entities/ImmersiveSpaceEntity.swift create mode 100644 Sources/Snowplow/VisionOS/Entities/WindowGroupEntity.swift create mode 100644 Sources/Snowplow/VisionOS/Events/DismissImmersiveSpaceEvent.swift create mode 100644 Sources/Snowplow/VisionOS/Events/DismissWindowEvent.swift create mode 100644 Sources/Snowplow/VisionOS/Events/OpenImmersiveSpaceEvent.swift create mode 100644 Sources/Snowplow/VisionOS/Events/OpenWindowEvent.swift create mode 100644 Tests/VisionOS/TestImmersiveSpaceState.swift create mode 100644 Tests/VisionOS/TestVisionOSEntities.swift create mode 100644 Tests/VisionOS/TestVisionOSEvents.swift diff --git a/Examples b/Examples index f7cfc1f87..1dc54ed5e 160000 --- a/Examples +++ b/Examples @@ -1 +1 @@ -Subproject commit f7cfc1f874138386f89521ed102cb957643e578c +Subproject commit 1dc54ed5ef0326d2b003c75bb5d747deb893202b diff --git a/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift index 77b752207..5894f5e6b 100644 --- a/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift +++ b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift @@ -216,6 +216,11 @@ class TrackerControllerIQWrapper: TrackerController { get { return InternalQueue.sync { controller.userAnonymisation } } set { InternalQueue.sync { controller.userAnonymisation = newValue } } } + + var immersiveSpaceContext: Bool { + get { return InternalQueue.sync { controller.immersiveSpaceContext } } + set { InternalQueue.sync { controller.immersiveSpaceContext = newValue } } + } var advertisingIdentifierRetriever: (() -> UUID?)? { get { return InternalQueue.sync { controller.advertisingIdentifierRetriever } } diff --git a/Sources/Core/StateMachine/ImmersiveSpaceState.swift b/Sources/Core/StateMachine/ImmersiveSpaceState.swift new file mode 100644 index 000000000..513d68b4b --- /dev/null +++ b/Sources/Core/StateMachine/ImmersiveSpaceState.swift @@ -0,0 +1,30 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +class ImmersiveSpaceState: State { + var dismissEventTracked = false + + var id: String + var viewId: UUID? + var immersionStyle: ImmersionStyle? + var upperLimbVisibility: UpperLimbVisibility? + + init(id: String, viewId: UUID? = nil, immersionStyle: ImmersionStyle? = nil, upperLimbVisibility: UpperLimbVisibility? = nil) { + self.id = id + self.viewId = viewId + self.immersionStyle = immersionStyle + self.upperLimbVisibility = upperLimbVisibility + } +} diff --git a/Sources/Core/StateMachine/ImmersiveSpaceStateMachine.swift b/Sources/Core/StateMachine/ImmersiveSpaceStateMachine.swift new file mode 100644 index 000000000..2c1c71d2e --- /dev/null +++ b/Sources/Core/StateMachine/ImmersiveSpaceStateMachine.swift @@ -0,0 +1,107 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +class ImmersiveSpaceStateMachine: StateMachineProtocol { + + static var identifier: String { return "ImmersiveSpace" } + var identifier: String { return ImmersiveSpaceStateMachine.identifier } + + var subscribedEventSchemasForEventsBefore: [String] { + return [] + } + + var subscribedEventSchemasForTransitions: [String] { + return [swiftuiOpenImmersiveSpaceSchema, swiftuiDismissImmersiveSpaceSchema] + } + + var subscribedEventSchemasForEntitiesGeneration: [String] { + return ["*"] + } + + var subscribedEventSchemasForPayloadUpdating: [String] { + return [] + } + + var subscribedEventSchemasForAfterTrackCallback: [String] { + return [] + } + + var subscribedEventSchemasForFiltering: [String] { + return [] + } + + func eventsBefore(event: Event) -> [Event]? { + return nil + } + + func transition(from event: Event, state: State?) -> State? { + if let e = event as? OpenImmersiveSpaceEvent { + return ImmersiveSpaceState( + id: e.id, + viewId: e.viewId, + immersionStyle: e.immersionStyle, + upperLimbVisibility: e.upperLimbVisibility + ) + } else { + if let s = state as? ImmersiveSpaceState { + if s.dismissEventTracked { + return nil + } + // state persists for the first Dismiss event after an Open + let currentState = ImmersiveSpaceState( + id: s.id, + viewId: s.viewId, + immersionStyle: s.immersionStyle, + upperLimbVisibility: s.upperLimbVisibility + ) + currentState.dismissEventTracked = true + return currentState + } + } + return nil + } + + func entities(from event: InspectableEvent, state: State?) -> [SelfDescribingJson]? { + // the open event already has the entity + if event.schema == swiftuiOpenImmersiveSpaceSchema { + return nil + } + + if let s = state as? ImmersiveSpaceState { + if s.dismissEventTracked == true && event.schema != swiftuiDismissImmersiveSpaceSchema { + return nil + } + let entity = ImmersiveSpaceEntity( + id: s.id, + viewId: s.viewId, + immersionStyle: s.immersionStyle, + upperLimbVisibility: s.upperLimbVisibility + ) + return [entity] + } + return nil + } + + func payloadValues(from event: InspectableEvent, state: State?) -> [String : Any]? { + return nil + } + + func afterTrack(event: InspectableEvent) { + } + + func filter(event: InspectableEvent, state: State?) -> Bool? { + return nil + } +} diff --git a/Sources/Core/Tracker/ServiceProvider.swift b/Sources/Core/Tracker/ServiceProvider.swift index 39e117c85..6afd20785 100644 --- a/Sources/Core/Tracker/ServiceProvider.swift +++ b/Sources/Core/Tracker/ServiceProvider.swift @@ -301,6 +301,7 @@ class ServiceProvider: NSObject, ServiceProviderProtocol { tracker.installEvent = trackerConfiguration.installAutotracking tracker.trackerDiagnostic = trackerConfiguration.diagnosticAutotracking tracker.userAnonymisation = trackerConfiguration.userAnonymisation + tracker.immersiveSpaceContext = trackerConfiguration.immersiveSpaceContext tracker.advertisingIdentifierRetriever = trackerConfiguration.advertisingIdentifierRetriever if gdprConfiguration.sourceConfig != nil { tracker.gdprContext = GDPRContext( diff --git a/Sources/Core/Tracker/Tracker.swift b/Sources/Core/Tracker/Tracker.swift index 1752e0976..2bd492bfc 100644 --- a/Sources/Core/Tracker/Tracker.swift +++ b/Sources/Core/Tracker/Tracker.swift @@ -232,6 +232,21 @@ class Tracker: NSObject { } } } + + private var _immersiveSpaceContext = false + var immersiveSpaceContext: Bool { + get { + return _immersiveSpaceContext + } + set(immersiveSpaceContext) { + self._immersiveSpaceContext = immersiveSpaceContext + if immersiveSpaceContext { + self.addOrReplace(stateMachine: ImmersiveSpaceStateMachine()) + } else { + _ = self.stateManager.removeStateMachine(ImmersiveSpaceStateMachine.identifier) + } + } + } /// GDPR context /// You can enable or disable the context by setting this property diff --git a/Sources/Core/Tracker/TrackerControllerImpl.swift b/Sources/Core/Tracker/TrackerControllerImpl.swift index 172fcdd4d..588deac8d 100644 --- a/Sources/Core/Tracker/TrackerControllerImpl.swift +++ b/Sources/Core/Tracker/TrackerControllerImpl.swift @@ -281,6 +281,16 @@ class TrackerControllerImpl: Controller, TrackerController { tracker.userAnonymisation = newValue } } + + var immersiveSpaceContext: Bool { + get { + return tracker.immersiveSpaceContext + } + set { + dirtyConfig.immersiveSpaceContext = newValue + tracker.immersiveSpaceContext = newValue + } + } var advertisingIdentifierRetriever: (() -> UUID?)? { get { diff --git a/Sources/Core/Tracker/TrackerDefaults.swift b/Sources/Core/Tracker/TrackerDefaults.swift index a310ce6dd..517f0ef7f 100644 --- a/Sources/Core/Tracker/TrackerDefaults.swift +++ b/Sources/Core/Tracker/TrackerDefaults.swift @@ -32,4 +32,5 @@ class TrackerDefaults { private(set) static var platformContext = true private(set) static var geoLocationContext = false private(set) static var screenEngagementAutotracking = true + private(set) static var immersiveSpaceContext = true } diff --git a/Sources/Core/TrackerConstants.swift b/Sources/Core/TrackerConstants.swift index 5e055a738..063aa7600 100644 --- a/Sources/Core/TrackerConstants.swift +++ b/Sources/Core/TrackerConstants.swift @@ -60,6 +60,7 @@ let kSPErrorSchema = "iglu:com.snowplowanalytics.snowplow/application_error/json let kSPApplicationInstallSchema = "iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0" let kSPGdprContextSchema = "iglu:com.snowplowanalytics.snowplow/gdpr/jsonschema/1-0-0" let kSPDiagnosticErrorSchema = "iglu:com.snowplowanalytics.snowplow/diagnostic_error/jsonschema/1-0-0" + let ecommerceActionSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/snowplow_ecommerce_action/jsonschema/1-0-2" let ecommerceProductSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/product/jsonschema/1-0-0" let ecommerceCartSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/cart/jsonschema/1-0-0" @@ -75,6 +76,13 @@ let kSPScreenSummarySchema = "iglu:com.snowplowanalytics.mobile/screen_summary/j let kSPListItemViewSchema = "iglu:com.snowplowanalytics.mobile/list_item_view/jsonschema/1-0-0" let kSPScrollChangedSchema = "iglu:com.snowplowanalytics.mobile/scroll_changed/jsonschema/1-0-0" +let swiftuiOpenWindowSchema = "iglu:com.apple.swiftui/open_window/jsonschema/1-0-0" +let swiftuiDismissWindowSchema = "iglu:com.apple.swiftui/dismiss_window/jsonschema/1-0-0" +let swiftuiOpenImmersiveSpaceSchema = "iglu:com.apple.swiftui/open_immersive_space/jsonschema/1-0-0" +let swiftuiDismissImmersiveSpaceSchema = "iglu:com.apple.swiftui/dismiss_immersive_space/jsonschema/1-0-0" +let swiftuiWindowGroupSchema = "iglu:com.apple.swiftui/window_group/jsonschema/1-0-0" +let swiftuiImmersiveSpaceSchema = "iglu:com.apple.swiftui/immersive_space/jsonschema/1-0-0" + // --- Event Keys let kSPEventPageView = "pv" let kSPEventStructured = "se" diff --git a/Sources/Snowplow/Configurations/TrackerConfiguration.swift b/Sources/Snowplow/Configurations/TrackerConfiguration.swift index 6b6697872..3e8e6b949 100644 --- a/Sources/Snowplow/Configurations/TrackerConfiguration.swift +++ b/Sources/Snowplow/Configurations/TrackerConfiguration.swift @@ -31,47 +31,50 @@ public protocol TrackerConfigurationProtocol: AnyObject { /// It sets the logger delegate that receive logs from the tracker. @objc var loggerDelegate: LoggerDelegate? { get set } - /// Whether application context is sent with all the tracked events. + /// Whether the application context entity is sent with all the tracked events. @objc var applicationContext: Bool { get set } - /// Whether mobile/platform context is sent with all the tracked events. + /// Whether the mobile/platform context entity is sent with all the tracked events. @objc var platformContext: Bool { get set } - /// Whether geo-location context is sent with all the tracked events. + /// Whether the geo-location context entity is sent with all the tracked events. @objc var geoLocationContext: Bool { get set } - /// Whether session context is sent with all the tracked events. + /// Whether the session context entity is sent with all the tracked events. @objc var sessionContext: Bool { get set } - /// Whether deepLink context is sent with all the ScreenView events. + /// Whether the deepLink context entity is sent with all the ScreenView events. @objc var deepLinkContext: Bool { get set } - /// Whether screen context is sent with all the tracked events. + /// Whether the screen context entity is sent with all the tracked events. @objc var screenContext: Bool { get set } - /// Whether enable automatic tracking of ScreenView events. + /// Whether to enable automatic tracking of ScreenView events. @objc var screenViewAutotracking: Bool { get set } - /// Whether to enable tracking the screen end event and the screen summary context entity. + /// Whether to enable tracking of the screen end event and the screen summary context entity. /// Make sure that you have lifecycle autotracking enabled for screen summary to have complete information. @objc var screenEngagementAutotracking: Bool { get set } - /// Whether enable automatic tracking of background and foreground transitions. + /// Whether to enable automatic tracking of background and foreground transitions. @objc var lifecycleAutotracking: Bool { get set } - /// Whether enable automatic tracking of install event. + /// Whether to enable automatic tracking of install event. @objc var installAutotracking: Bool { get set } - /// Whether enable crash reporting. + /// Whether to enable crash reporting. @objc var exceptionAutotracking: Bool { get set } - /// Whether enable diagnostic reporting. + /// Whether to enable diagnostic reporting. @objc var diagnosticAutotracking: Bool { get set } /// Whether to anonymise client-side user identifiers in session (userId, previousSessionId), subject (userId, networkUserId, domainUserId, ipAddress) and platform context entities (IDFA) /// Setting this property on a running tracker instance starts a new session (if sessions are tracked). @objc var userAnonymisation: Bool { get set } + /// Whether the immersive space context entity should be sent with events tracked within an immersive space (visionOS). + @objc + var immersiveSpaceContext: Bool { get set } /// Decorate the v_tracker field in the tracker protocol. /// @note Do not use. Internal use only. @objc @@ -134,7 +137,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _applicationContext: Bool? - /// Whether application context is sent with all the tracked events. + /// Whether the application context entity is sent with all the tracked events. @objc public var applicationContext: Bool { get { return _applicationContext ?? sourceConfig?.applicationContext ?? TrackerDefaults.applicationContext } @@ -142,7 +145,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _platformContext: Bool? - /// Whether mobile/platform context is sent with all the tracked events. + /// Whether the mobile/platform context entity is sent with all the tracked events. @objc public var platformContext: Bool { get { return _platformContext ?? sourceConfig?.platformContext ?? TrackerDefaults.platformContext } @@ -150,7 +153,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _geoLocationContext: Bool? - /// Whether geo-location context is sent with all the tracked events. + /// Whether the geo-location context entity is sent with all the tracked events. @objc public var geoLocationContext: Bool { get { return _geoLocationContext ?? sourceConfig?.geoLocationContext ?? TrackerDefaults.geoLocationContext } @@ -158,7 +161,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _sessionContext: Bool? - /// Whether session context is sent with all the tracked events. + /// Whether the session context entity is sent with all the tracked events. @objc public var sessionContext: Bool { get { return _sessionContext ?? sourceConfig?.sessionContext ?? TrackerDefaults.sessionContext } @@ -166,7 +169,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _deepLinkContext: Bool? - /// Whether deepLink context is sent with all the ScreenView events. + /// Whether the deepLink context entity is sent with all the ScreenView events. @objc public var deepLinkContext: Bool { get { return _deepLinkContext ?? sourceConfig?.deepLinkContext ?? TrackerDefaults.deepLinkContext } @@ -174,7 +177,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _screenContext: Bool? - /// Whether screen context is sent with all the tracked events. + /// Whether the screen context entity is sent with all the tracked events. @objc public var screenContext: Bool { get { return _screenContext ?? sourceConfig?.screenContext ?? TrackerDefaults.screenContext } @@ -182,7 +185,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _screenViewAutotracking: Bool? - /// Whether enable automatic tracking of ScreenView events. + /// Whether to enable automatic tracking of ScreenView events. @objc public var screenViewAutotracking: Bool { get { return _screenViewAutotracking ?? sourceConfig?.screenViewAutotracking ?? TrackerDefaults.autotrackScreenViews } @@ -190,7 +193,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _screenEngagementAutotracking: Bool? - /// Whether enable tracking the screen end event and the screen summary context entity. + /// Whether to enable tracking of the screen end event and the screen summary context entity. /// Make sure that you have lifecycle autotracking enabled for screen summary to have complete information. @objc public var screenEngagementAutotracking: Bool { @@ -199,7 +202,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _lifecycleAutotracking: Bool? - /// Whether enable automatic tracking of background and foreground transitions. + /// Whether to enable automatic tracking of background and foreground transitions. @objc public var lifecycleAutotracking: Bool { get { return _lifecycleAutotracking ?? sourceConfig?.lifecycleAutotracking ?? TrackerDefaults.lifecycleEvents } @@ -207,7 +210,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _installAutotracking: Bool? - /// Whether enable automatic tracking of install event. + /// Whether to enable automatic tracking of install event. @objc public var installAutotracking: Bool { get { return _installAutotracking ?? sourceConfig?.installAutotracking ?? TrackerDefaults.installEvent } @@ -215,7 +218,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _exceptionAutotracking: Bool? - /// Whether enable crash reporting. + /// Whether to enable crash reporting. @objc public var exceptionAutotracking: Bool { get { return _exceptionAutotracking ?? sourceConfig?.exceptionAutotracking ?? TrackerDefaults.exceptionEvents } @@ -223,7 +226,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati } private var _diagnosticAutotracking: Bool? - /// Whether enable diagnostic reporting. + /// Whether to enable diagnostic reporting. @objc public var diagnosticAutotracking: Bool { get { return _diagnosticAutotracking ?? sourceConfig?.diagnosticAutotracking ?? TrackerDefaults.trackerDiagnostic } @@ -239,6 +242,14 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati set { _userAnonymisation = newValue } } + private var _immersiveSpaceContext: Bool? + /// Whether the immersive space context entity should be sent with events tracked within an immersive space (visionOS). + @objc + public var immersiveSpaceContext: Bool { + get { return _immersiveSpaceContext ?? sourceConfig?.immersiveSpaceContext ?? TrackerDefaults.immersiveSpaceContext } + set { _immersiveSpaceContext = newValue } + } + private var _trackerVersionSuffix: String? /// Decorate the v_tracker field in the tracker protocol. /// @note Do not use. Internal use only. @@ -344,6 +355,9 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati if let userAnonymisation = dictionary["userAnonymisation"] as? Bool { self.userAnonymisation = userAnonymisation } + if let immersiveSpaceContext = dictionary["immersiveSpaceContext"] as? Bool { + self.immersiveSpaceContext = immersiveSpaceContext + } } // MARK: - Builders @@ -383,98 +397,98 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati return self } - /// Whether application context is sent with all the tracked events. + /// Whether the application context entity is sent with all the tracked events. @objc public func applicationContext(_ applicationContext: Bool) -> Self { self.applicationContext = applicationContext return self } - /// Whether mobile/platform context is sent with all the tracked events. + /// Whether the mobile/platform context entity is sent with all the tracked events. @objc public func platformContext(_ platformContext: Bool) -> Self { self.platformContext = platformContext return self } - /// List of properties of the platform context to track. If not passed and `platformContext` is enabled, all available properties will be tracked. + /// List of properties of the platform context entity to track. If not passed and `platformContext` is enabled, all available properties will be tracked. /// The required `osType`, `osVersion`, `deviceManufacturer`, and `deviceModel` properties will be tracked in the entity regardless of this setting. public func platformContextProperties(_ platformContextProperties: [PlatformContextProperty]?) -> Self { self.platformContextProperties = platformContextProperties return self } - /// Whether geo-location context is sent with all the tracked events. + /// Whether the geo-location context entity is sent with all the tracked events. @objc public func geoLocationContext(_ geoLocationContext: Bool) -> Self { self.geoLocationContext = geoLocationContext return self } - /// Whether session context is sent with all the tracked events. + /// Whether the session context entity is sent with all the tracked events. @objc public func sessionContext(_ sessionContext: Bool) -> Self { self.sessionContext = sessionContext return self } - /// Whether deepLink context is sent with all the ScreenView events. + /// Whether the deepLink context entity is sent with all the ScreenView events. @objc public func deepLinkContext(_ deepLinkContext: Bool) -> Self { self.deepLinkContext = deepLinkContext return self } - /// Whether screen context is sent with all the tracked events. + /// Whether the screen context entity is sent with all the tracked events. @objc public func screenContext(_ screenContext: Bool) -> Self { self.screenContext = screenContext return self } - /// Whether enable automatic tracking of ScreenView events. + /// Whether to enable automatic tracking of ScreenView events. @objc public func screenViewAutotracking(_ screenViewAutotracking: Bool) -> Self { self.screenViewAutotracking = screenViewAutotracking return self } - /// Whether enable tracking the screen end event and the screen summary context entity. + /// Whether to enable tracking of the screen end event and the screen summary context entity. @objc public func screenEngagementAutotracking(_ screenEngagementAutotracking: Bool) -> Self { self.screenEngagementAutotracking = screenEngagementAutotracking return self } - /// Whether enable automatic tracking of background and foreground transitions. + /// Whether to enable automatic tracking of background and foreground transitions. @objc public func lifecycleAutotracking(_ lifecycleAutotracking: Bool) -> Self { self.lifecycleAutotracking = lifecycleAutotracking return self } - /// Whether enable automatic tracking of install event. + /// Whether to enable automatic tracking of install event. @objc public func installAutotracking(_ installAutotracking: Bool) -> Self { self.installAutotracking = installAutotracking return self } - /// Whether enable crash reporting. + /// Whether to enable crash reporting. @objc public func exceptionAutotracking(_ exceptionAutotracking: Bool) -> Self { self.exceptionAutotracking = exceptionAutotracking return self } - /// Whether enable diagnostic reporting. + /// Whether to enable diagnostic reporting. @objc public func diagnosticAutotracking(_ diagnosticAutotracking: Bool) -> Self { self.diagnosticAutotracking = diagnosticAutotracking return self } - /// Whether to anonymise client-side user identifiers in session (userId, previousSessionId), subject (userId, networkUserId, domainUserId, ipAddress) and platform context entities (IDFA) + /// Whether to anonymise client-side user identifiers in session (userId, previousSessionId), subject (userId, networkUserId, domainUserId, ipAddress) and platform context entities (IDFA). /// Setting this property on a running tracker instance starts a new session (if sessions are tracked). @objc public func userAnonymisation(_ userAnonymisation: Bool) -> Self { @@ -482,6 +496,13 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati return self } + /// Whether the immersive space context entity should be sent with events tracked within an immersive space (visionOS). + @objc + public func immersiveSpaceContext(_ immersiveSpaceContext: Bool) -> Self { + self.immersiveSpaceContext = immersiveSpaceContext + return self + } + /// Decorate the v_tracker field in the tracker protocol. /// @note Do not use. Internal use only. @objc @@ -523,6 +544,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati copy.diagnosticAutotracking = diagnosticAutotracking copy.trackerVersionSuffix = trackerVersionSuffix copy.userAnonymisation = userAnonymisation + copy.immersiveSpaceContext = immersiveSpaceContext copy.advertisingIdentifierRetriever = advertisingIdentifierRetriever return copy } @@ -553,6 +575,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati coder.encode(diagnosticAutotracking, forKey: "diagnosticAutotracking") coder.encode(trackerVersionSuffix, forKey: "trackerVersionSuffix") coder.encode(userAnonymisation, forKey: "userAnonymisation") + coder.encode(immersiveSpaceContext, forKey: "immersiveSpaceContext") } required init?(coder: NSCoder) { @@ -586,5 +609,6 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati self.trackerVersionSuffix = trackerVersionSuffix } userAnonymisation = coder.decodeBool(forKey: "userAnonymisation") + immersiveSpaceContext = coder.decodeBool(forKey: "immersiveSpaceContext") } } diff --git a/Sources/Snowplow/VisionOS/Entities/ImmersiveSpaceEntity.swift b/Sources/Snowplow/VisionOS/Entities/ImmersiveSpaceEntity.swift new file mode 100644 index 000000000..d59fa4830 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Entities/ImmersiveSpaceEntity.swift @@ -0,0 +1,113 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/// The style of a visionOS immersive space. +public enum ImmersionStyle: Int { + /// Default immersion style. + case automatic + /// Displays unbounded content that obscures pass-through video. + case full + /// Displays unbounded content intermixed with other app content. + case mixed + /// Content displays with no clipping boundaries applied. + case progressive +} + +extension ImmersionStyle { + var value: String { + switch self { + case .automatic: + return "automatic" + case .full: + return "full" + case .mixed: + return "mixed" + case .progressive: + return "progressive" + } + } +} + +/// The visibility of the user's upper limbs in a visionOS immersive space. +public enum UpperLimbVisibility: Int { + /// Limbs may be visible or hidden depending on the policies of the component accepting the visibility configuration. + case automatic + /// Limbs may be visible. + case visible + /// Limbs may be hidden. + case hidden +} + +extension UpperLimbVisibility { + var value: String { + switch self { + case .automatic: + return "automatic" + case .visible: + return "visible" + case .hidden: + return "hidden" + } + } +} + +/** + Properties for the visionOS immersive space entity. + Entity schema: `iglu:com.apple.swiftui/immersive_space/jsonschema/1-0-0` + */ +public class ImmersiveSpaceEntity: SelfDescribingJson { + + /// The identifier of the immersive space to present. + public var id: String + + /// UUID for the view of the immersive space. + public var viewId: UUID? + + /// The style of an immersive space. + public var immersionStyle: ImmersionStyle? + + /// Preferred visibility of the user's upper limbs, while an immersive space scene is presented. + public var upperLimbVisibility: UpperLimbVisibility? + + override public var data: [String : Any] { + get { + var data: [String : Any] = [ + "id": id + ] + if let viewId = viewId { data["view_id"] = viewId.uuidString } + if let immersionStyle = immersionStyle { data["immersion_style"] = immersionStyle.value } + if let upperLimbVisibility = upperLimbVisibility { data["upper_limb_visibility"] = upperLimbVisibility.value } + return data + } + set {} + } + + /// - Parameter id: A localized string key to use for the window's title in system menus and in the window's title bar. + /// - Parameter viewId: UUID for the view of the immersive space. Generated by the tracker if not provided. + /// - Parameter immersionStyle: A specification for the appearance and interaction of a window. + /// - Parameter upperLimbVisibility: A specification for the appearance and interaction of a window. + public init( + id: String, + viewId: UUID? = nil, + immersionStyle: ImmersionStyle? = nil, + upperLimbVisibility: UpperLimbVisibility? = nil + ) { + self.id = id + self.viewId = viewId ?? UUID() + self.immersionStyle = immersionStyle + self.upperLimbVisibility = upperLimbVisibility + super.init(schema: swiftuiImmersiveSpaceSchema, andData: [:]) + } +} diff --git a/Sources/Snowplow/VisionOS/Entities/WindowGroupEntity.swift b/Sources/Snowplow/VisionOS/Entities/WindowGroupEntity.swift new file mode 100644 index 000000000..d75819436 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Entities/WindowGroupEntity.swift @@ -0,0 +1,94 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/// A specification for the appearance and interaction of a window. +public enum WindowStyle: Int { + /// Default window style. + case automatic + /// Hides both the window’s title and the backing of the titlebar area. + case hiddenTitleBar + /// Plain window style. + case plain + /// Displays the title bar section of the window. + case titleBar + /// Creates a 3D volumetric window. + case volumetric +} + +extension WindowStyle { + var value: String { + switch self { + case .automatic: + return "automatic" + case .hiddenTitleBar: + return "hiddenTitleBar" + case .plain: + return "plain" + case .titleBar: + return "titleBar" + case .volumetric: + return "volumetric" + } + } +} + +/** + Properties for the SwiftUI window group entity. + Entity schema: `iglu:com.apple.swiftui/window_group/jsonschema/1-0-0` + */ +public class WindowGroupEntity: SelfDescribingJson { + + /// A string that uniquely identifies the window group. Identifiers must be unique among the window groups in your app. + public var id: String + + /// UUID for the current window within the group. + public var windowId: UUID? + + /// A localized string key to use for the window's title in system menus and in the window's title bar. Provide a title that describes the purpose of the window. + public var titleKey: String? + + /// A specification for the appearance and interaction of a window. + public var windowStyle: WindowStyle? + + override public var data: [String : Any] { + get { + var data: [String : Any] = [ + "id": id + ] + if let windowId = windowId { data["window_id"] = windowId.uuidString } + if let titleKey = titleKey { data["title_key"] = titleKey } + if let windowStyle = windowStyle { data["window_style"] = windowStyle.value } + return data + } + set {} + } + + /// - Parameter id: A string that uniquely identifies the window group. + /// - Parameter windowId: UUID for the current window within the group. + /// - Parameter titleKey: A localized string key to use for the window's title in system menus and in the window's title bar. + /// - Parameter windowStyle: A specification for the appearance and interaction of a window. + public init( + id: String, + windowId: UUID? = nil, + titleKey: String? = nil, + windowStyle: WindowStyle? = nil + ) { + self.id = id + self.windowId = windowId + self.titleKey = titleKey + self.windowStyle = windowStyle + super.init(schema: swiftuiWindowGroupSchema, andData: [:]) + } +} diff --git a/Sources/Snowplow/VisionOS/Events/DismissImmersiveSpaceEvent.swift b/Sources/Snowplow/VisionOS/Events/DismissImmersiveSpaceEvent.swift new file mode 100644 index 000000000..78f48d71c --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/DismissImmersiveSpaceEvent.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Event for a visionOS immersive space being dismissed. */ +public class DismissImmersiveSpaceEvent: SelfDescribingAbstract { + + override var schema: String { + return swiftuiDismissImmersiveSpaceSchema + } + + override var payload: [String : Any] { + return [:] + } +} diff --git a/Sources/Snowplow/VisionOS/Events/DismissWindowEvent.swift b/Sources/Snowplow/VisionOS/Events/DismissWindowEvent.swift new file mode 100644 index 000000000..f2742efa6 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/DismissWindowEvent.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Event for a SwiftUI window group being dismissed. */ +public class DismissWindowEvent: SelfDescribingAbstract { + + /// A string that uniquely identifies the window group. Identifiers must be unique among the window groups in your app. + public var id: String + + /// UUID for the current window within the group. + public var windowId: UUID? + + /// A localized string key to use for the window's title in system menus and in the window's title bar. Provide a title that describes the purpose of the window. + public var titleKey: String? + + /// A specification for the appearance and interaction of a window. + public var windowStyle: WindowStyle? + + override var schema: String { + return swiftuiDismissWindowSchema + } + + override var payload: [String : Any] { + return [:] + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + let windowGroup = WindowGroupEntity( + id: self.id, + windowId: self.windowId, + titleKey: self.titleKey, + windowStyle: self.windowStyle + ) + entities.append(windowGroup) + return entities + } + } + + /// - Parameter id: A string that uniquely identifies the window group. + /// - Parameter windowId: UUID for the current window within the group. + /// - Parameter titleKey: A localized string key to use for the window's title in system menus and in the window's title bar. + /// - Parameter windowStyle: A specification for the appearance and interaction of a window. + public init( + id: String, + windowId: UUID? = nil, + titleKey: String? = nil, + windowStyle: WindowStyle? = nil + ) { + self.id = id + self.windowId = windowId + self.titleKey = titleKey + self.windowStyle = windowStyle + } +} diff --git a/Sources/Snowplow/VisionOS/Events/OpenImmersiveSpaceEvent.swift b/Sources/Snowplow/VisionOS/Events/OpenImmersiveSpaceEvent.swift new file mode 100644 index 000000000..0e4b52e60 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/OpenImmersiveSpaceEvent.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Event for a visionOS immersive space being opened. */ +public class OpenImmersiveSpaceEvent: SelfDescribingAbstract { + + /// The identifier of the immersive space to present. + public var id: String + + /// UUID for the view of the immersive space. + public var viewId: UUID? + + /// The style of an immersive space. + public var immersionStyle: ImmersionStyle? + + /// Preferred visibility of the user's upper limbs, while an immersive space scene is presented. + public var upperLimbVisibility: UpperLimbVisibility? + + override var schema: String { + return swiftuiOpenImmersiveSpaceSchema + } + + override var payload: [String : Any] { + return [:] + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + let space = ImmersiveSpaceEntity( + id: self.id, + viewId: self.viewId, + immersionStyle: self.immersionStyle, + upperLimbVisibility: self.upperLimbVisibility + ) + entities.append(space) + return entities + } + } + + /// - Parameter id: A localized string key to use for the window's title in system menus and in the window's title bar. + /// - Parameter viewId: UUID for the view of the immersive space. + /// - Parameter immersionStyle: A specification for the appearance and interaction of a window. + /// - Parameter upperLimbVisibility: A specification for the appearance and interaction of a window. + public init( + id: String, + viewId: UUID? = nil, + immersionStyle: ImmersionStyle? = nil, + upperLimbVisibility: UpperLimbVisibility? = nil + ) { + self.id = id + self.viewId = viewId + self.immersionStyle = immersionStyle + self.upperLimbVisibility = upperLimbVisibility + } +} diff --git a/Sources/Snowplow/VisionOS/Events/OpenWindowEvent.swift b/Sources/Snowplow/VisionOS/Events/OpenWindowEvent.swift new file mode 100644 index 000000000..d5db777b6 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/OpenWindowEvent.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import Foundation + +/** Event for a SwiftUI window group being opened. */ +public class OpenWindowEvent: SelfDescribingAbstract { + + /// A string that uniquely identifies the window group. Identifiers must be unique among the window groups in your app. + public var id: String + + /// UUID for the current window within the group. + public var windowId: UUID? + + /// A localized string key to use for the window's title in system menus and in the window's title bar. Provide a title that describes the purpose of the window. + public var titleKey: String? + + /// A specification for the appearance and interaction of a window. + public var windowStyle: WindowStyle? + + override var schema: String { + return swiftuiOpenWindowSchema + } + + override var payload: [String : Any] { + return [:] + } + + override internal var entitiesForProcessing: [SelfDescribingJson]? { + get { + var entities = [SelfDescribingJson]() + let windowGroup = WindowGroupEntity( + id: self.id, + windowId: self.windowId, + titleKey: self.titleKey, + windowStyle: self.windowStyle + ) + entities.append(windowGroup) + return entities + } + } + + /// - Parameter id: A string that uniquely identifies the window group. + /// - Parameter windowId: UUID for the current window within the group. + /// - Parameter titleKey: A localized string key to use for the window's title in system menus and in the window's title bar. + /// - Parameter windowStyle: A specification for the appearance and interaction of a window. + public init( + id: String, + windowId: UUID? = nil, + titleKey: String? = nil, + windowStyle: WindowStyle? = nil + ) { + self.id = id + self.windowId = windowId + self.titleKey = titleKey + self.windowStyle = windowStyle + } +} diff --git a/Tests/VisionOS/TestImmersiveSpaceState.swift b/Tests/VisionOS/TestImmersiveSpaceState.swift new file mode 100644 index 000000000..8a79aa316 --- /dev/null +++ b/Tests/VisionOS/TestImmersiveSpaceState.swift @@ -0,0 +1,221 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import XCTest +@testable import SnowplowTracker + +class TestImmersiveSpaceState: XCTestCase { + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testImmersiveSpaceStateMachine() { + let eventStore = MockEventStore() + let emitter = Emitter( + networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), + namespace: "namespace", + eventStore: eventStore + ) + let tracker = Tracker(trackerNamespace: "namespace", appId: nil, emitter: emitter) { tracker in + tracker.base64Encoded = false + tracker.immersiveSpaceContext = true + } + + // Send events + + // no entity before OpenImmersiveSpaceEvent + track(Timing(category: "category", variable: "variable", timing: 123), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + var payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + var entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertFalse(entities!.contains("immersive_space")) + + // OpenImmersiveSpaceEvent has the entity + track(OpenImmersiveSpaceEvent(id: "original_space_state"), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertTrue(entities!.contains("original_space_state")) + + // as do subsequent events + track(Timing(category: "category", variable: "variable", timing: 123), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertTrue(entities!.contains("original_space_state")) + + // tracking another OpenImmersiveSpaceEvent updates the state + track(OpenImmersiveSpaceEvent(id: "second_space"), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertFalse(entities!.contains("original_space_state")) + XCTAssertTrue(entities!.contains("second_space")) + + // subsequent events have the new entity + track(Timing(category: "category", variable: "variable", timing: 123), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertTrue(entities!.contains("second_space")) + + // the entity is also attached to the DismissImmersiveSpaceEvent + track(DismissImmersiveSpaceEvent(), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertTrue(entities!.contains("immersive_space")) + XCTAssertTrue(entities!.contains("second_space")) + + // events following the dismiss event do not have the entity + // including other dismiss events + track(DismissImmersiveSpaceEvent(), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertFalse(entities!.contains("immersive_space")) + + track(Foreground(index: 1), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertFalse(entities!.contains("immersive_space")) + + // can start adding the entity again if open event is tracked + track(OpenImmersiveSpaceEvent(id: "a_new_space"), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertTrue(entities!.contains("immersive_space")) + XCTAssertFalse(entities!.contains("second_space")) + XCTAssertTrue(entities!.contains("a_new_space")) + } + + func testEntityNotConfigured() { + let eventStore = MockEventStore() + let emitter = Emitter( + networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), + namespace: "namespace", + eventStore: eventStore + ) + let tracker = Tracker(trackerNamespace: "namespace", appId: nil, emitter: emitter) { tracker in + tracker.base64Encoded = false + tracker.immersiveSpaceContext = false + } + + // Send events + + // no entity before OpenImmersiveSpaceEvent + track(Timing(category: "category", variable: "variable", timing: 123), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + var payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + var entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertFalse(entities!.contains("immersive_space")) + + // OpenImmersiveSpaceEvent has the entity + track(OpenImmersiveSpaceEvent(id: "original space state"), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertTrue(entities!.contains("immersive_space")) + + // other events do not + track(Timing(category: "category", variable: "variable", timing: 123), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertFalse(entities!.contains("immersive_space")) + + // no entity for DismissImmersiveSpaceEvent + track(DismissImmersiveSpaceEvent(), tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertFalse(entities!.contains("immersive_space")) + + // can add it manually + let event = Foreground(index: 1) + event.entities.append(ImmersiveSpaceEntity(id: "space")) + track(event, tracker) + if eventStore.lastInsertedRow == -1 { + XCTFail() + } + payload = eventStore.db[Int64(eventStore.lastInsertedRow)] + _ = eventStore.removeAllEvents() + entities = (payload?["co"]) as? String + XCTAssertNotNil(entities) + XCTAssertTrue(entities!.contains("immersive_space")) + } + + private func track(_ event: Event, _ tracker: Tracker) { + InternalQueue.sync { + _ = tracker.track(event) + } + } +} diff --git a/Tests/VisionOS/TestVisionOSEntities.swift b/Tests/VisionOS/TestVisionOSEntities.swift new file mode 100644 index 000000000..074daca62 --- /dev/null +++ b/Tests/VisionOS/TestVisionOSEntities.swift @@ -0,0 +1,51 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import XCTest +@testable import SnowplowTracker + +class TestVisionOSEntities: XCTestCase { + let uuid = UUID() + + func testBuildsImmersiveSpaceEntity() { + let space = ImmersiveSpaceEntity( + id: "space_123", + viewId: uuid, + immersionStyle: ImmersionStyle.automatic, + upperLimbVisibility: UpperLimbVisibility.visible + ) + let entity = space.data + + XCTAssertEqual(swiftuiImmersiveSpaceSchema, space.schema) + XCTAssertEqual("space_123", entity["id"] as? String) + XCTAssertEqual(uuid.uuidString, entity["view_id"] as? String) + XCTAssertEqual("automatic", entity["immersion_style"] as? String) + XCTAssertEqual("visible", entity["upper_limb_visibility"] as? String) + } + + func testBuildsWindowGroupEntity() { + let windows = WindowGroupEntity( + id: "group_id", + windowId: uuid, + titleKey: "title", + windowStyle: .plain + ) + let entity = windows.data + + XCTAssertEqual(swiftuiWindowGroupSchema, windows.schema) + XCTAssertEqual("group_id", entity["id"] as? String) + XCTAssertEqual(uuid.uuidString, entity["window_id"] as? String) + XCTAssertEqual("title", entity["title_key"] as? String) + XCTAssertEqual("plain", entity["window_style"] as? String) + } +} diff --git a/Tests/VisionOS/TestVisionOSEvents.swift b/Tests/VisionOS/TestVisionOSEvents.swift new file mode 100644 index 000000000..31cc0b735 --- /dev/null +++ b/Tests/VisionOS/TestVisionOSEvents.swift @@ -0,0 +1,155 @@ +// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// +// This program is licensed to you under the Apache License Version 2.0, +// and you may not use this file except in compliance with the Apache License +// Version 2.0. You may obtain a copy of the Apache License Version 2.0 at +// http://www.apache.org/licenses/LICENSE-2.0. +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the Apache License Version 2.0 is distributed on +// an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the Apache License Version 2.0 for the specific +// language governing permissions and limitations there under. + +import XCTest +@testable import SnowplowTracker + +class TestVisionOSEvents: XCTestCase { + var eventSink: EventSink? + var trackedEvents: [InspectableEvent] { return eventSink?.trackedEvents ?? [] } + var tracker: TrackerController? + + override func setUp() { + tracker = createTracker() + } + + override func tearDown() { + Snowplow.removeAllTrackers() + eventSink = nil + } + + func testTrackOpenWindow() { + let event = OpenWindowEvent( + id: "group_id" + ) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(swiftuiOpenWindowSchema, event.schema) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getWindowGroupEntities(entities).count) + } + + func testTrackDismissWindow() { + let event = DismissWindowEvent( + id: "window", + windowStyle: .automatic + ) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(swiftuiDismissWindowSchema, event.schema) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getWindowGroupEntities(entities).count) + } + + func testTrackOpenImmersiveSpace() { + let event = OpenImmersiveSpaceEvent( + id: "space", + immersionStyle: .full + ) + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(swiftuiOpenImmersiveSpaceSchema, event.schema) + + let entities = trackedEvents[0].entities + XCTAssertEqual(1, getImmersiveSpaceEntities(entities).count) + + let spaceEntity = getImmersiveSpaceEntities(entities)[0] as? ImmersiveSpaceEntity + let viewId = spaceEntity!.viewId + XCTAssertNotNil(viewId) + } + + func testTrackDismissImmersiveSpace() { + let event = DismissImmersiveSpaceEvent() + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + XCTAssertEqual(1, trackedEvents.count) + XCTAssertEqual(swiftuiDismissImmersiveSpaceSchema, event.schema) + + let entities = trackedEvents[0].entities + XCTAssertEqual(0, getImmersiveSpaceEntities(entities).count) + } + + func testImmersiveSpaceEntityAddedByDefault() { + let event = OpenImmersiveSpaceEvent(id: "space") + + _ = tracker?.track(event) + waitForEventsToBeTracked() + + let event2 = ScreenView(name: "screen") + + _ = tracker?.track(event2) + waitForEventsToBeTracked() + + XCTAssertEqual(2, trackedEvents.count) + + let entities = trackedEvents[1].entities + XCTAssertEqual(1, getImmersiveSpaceEntities(entities).count) + } + + private func createTracker() -> TrackerController { + let networkConfig = NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)) + let trackerConfig = TrackerConfiguration() + + let namespace = "testVisionOS" + String(describing: Int.random(in: 0..<100)) + eventSink = EventSink() + + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, eventSink!]) + } + + private func waitForEventsToBeTracked() { + let expect = expectation(description: "Wait for events to be tracked") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { () -> Void in + expect.fulfill() + } + wait(for: [expect], timeout: 1) + } + + private func getWindowGroupEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == swiftuiWindowGroupSchema) { + entities.append(entity) + } + } + } + return entities + } + + private func getImmersiveSpaceEntities(_ all: [SelfDescribingJson]?) -> [SelfDescribingJson] { + var entities: [SelfDescribingJson] = [] + if let all = all { + for entity in all { + if (entity.schema == swiftuiImmersiveSpaceSchema) { + entities.append(entity) + } + } + } + return entities + } +}