From a4a1c9cbb319d74248a6c5268e86d834ee5caca8 Mon Sep 17 00:00:00 2001 From: Matus Tomlein Date: Thu, 11 Jan 2024 19:14:41 +0100 Subject: [PATCH] Add screen engagement tracking of time spent and list items scrolled on a screen (close #851) PR #853 --- Examples | 2 +- Integrationtests/TestTrackEventsToMicro.swift | 1 + Sources/Core/Events/ScreenEnd.swift | 26 ++++ .../TrackerControllerIQWrapper.swift | 5 + .../ListItemViewModifier.swift | 54 +++++++ .../ScreenStateMachine.swift | 8 + .../ScreenSummaryState.swift | 95 ++++++++++++ .../ScreenSummaryStateMachine.swift | 93 ++++++++++++ .../StateMachine/DeepLinkStateMachine.swift | 8 + .../StateMachine/LifecycleStateMachine.swift | 8 +- .../StateMachine/PluginStateMachine.swift | 8 + .../StateMachine/StateMachineProtocol.swift | 4 + Sources/Core/StateMachine/StateManager.swift | 25 +++ Sources/Core/Storage/SQLiteEventStore.swift | 4 +- Sources/Core/Tracker/ServiceProvider.swift | 1 + Sources/Core/Tracker/Tracker.swift | 28 +++- .../Core/Tracker/TrackerControllerImpl.swift | 8 + Sources/Core/Tracker/TrackerDefaults.swift | 1 + Sources/Core/TrackerConstants.swift | 4 + .../Configurations/TrackerConfiguration.swift | 26 ++++ Sources/Snowplow/Events/ListItemView.swift | 57 +++++++ Sources/Snowplow/Events/ScrollChanged.swift | 114 ++++++++++++++ Sources/Snowplow/Tracker/View.swift | 10 ++ Tests/Ecommerce/TestEcommerceController.swift | 1 + .../TestListItemViewModifier.swift | 68 +++++++++ .../TestScreenState.swift | 0 .../TestScreenSummaryStateMachine.swift | 142 ++++++++++++++++++ .../TestScreenViewModifier.swift | 0 Tests/TestStateManager.swift | 8 + Tests/Utils/TimeTraveler.swift | 4 + 30 files changed, 806 insertions(+), 7 deletions(-) create mode 100644 Sources/Core/Events/ScreenEnd.swift create mode 100644 Sources/Core/ScreenViewTracking/ListItemViewModifier.swift create mode 100644 Sources/Core/ScreenViewTracking/ScreenSummaryState.swift create mode 100644 Sources/Core/ScreenViewTracking/ScreenSummaryStateMachine.swift create mode 100644 Sources/Snowplow/Events/ListItemView.swift create mode 100644 Sources/Snowplow/Events/ScrollChanged.swift create mode 100644 Tests/ScreenViewTracking/TestListItemViewModifier.swift rename Tests/{ => ScreenViewTracking}/TestScreenState.swift (100%) create mode 100644 Tests/ScreenViewTracking/TestScreenSummaryStateMachine.swift rename Tests/{ => ScreenViewTracking}/TestScreenViewModifier.swift (100%) diff --git a/Examples b/Examples index 6dc5ecf54..f7cfc1f87 160000 --- a/Examples +++ b/Examples @@ -1 +1 @@ -Subproject commit 6dc5ecf54643345f7855a1d4187fcab0d756e51f +Subproject commit f7cfc1f874138386f89521ed102cb957643e578c diff --git a/Integrationtests/TestTrackEventsToMicro.swift b/Integrationtests/TestTrackEventsToMicro.swift index 3dcddbf35..0dd053b06 100644 --- a/Integrationtests/TestTrackEventsToMicro.swift +++ b/Integrationtests/TestTrackEventsToMicro.swift @@ -21,6 +21,7 @@ class TestTrackEventsToMicro: XCTestCase { super.setUp() let trackerConfig = TrackerConfiguration() + .screenEngagementAutotracking(false) .logLevel(.debug) tracker = Snowplow.createTracker(namespace: "testMicro-" + UUID().uuidString, diff --git a/Sources/Core/Events/ScreenEnd.swift b/Sources/Core/Events/ScreenEnd.swift new file mode 100644 index 000000000..525d39bc6 --- /dev/null +++ b/Sources/Core/Events/ScreenEnd.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 + +class ScreenEnd: SelfDescribingAbstract { + + override var schema: String { + return kSPScreenEndSchema + } + + override var payload: [String : Any] { + return [:] + } + +} diff --git a/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift index 7e3def3bd..77b752207 100644 --- a/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift +++ b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift @@ -196,6 +196,11 @@ class TrackerControllerIQWrapper: TrackerController { get { return InternalQueue.sync { controller.screenViewAutotracking } } set { InternalQueue.sync { controller.screenViewAutotracking = newValue } } } + + var screenEngagementAutotracking: Bool { + get { return InternalQueue.sync { controller.screenEngagementAutotracking } } + set { InternalQueue.sync { controller.screenEngagementAutotracking = newValue } } + } var trackerVersionSuffix: String? { get { return InternalQueue.sync { controller.trackerVersionSuffix } } diff --git a/Sources/Core/ScreenViewTracking/ListItemViewModifier.swift b/Sources/Core/ScreenViewTracking/ListItemViewModifier.swift new file mode 100644 index 000000000..478314936 --- /dev/null +++ b/Sources/Core/ScreenViewTracking/ListItemViewModifier.swift @@ -0,0 +1,54 @@ +// 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. + +#if canImport(SwiftUI) + +import SwiftUI +import Foundation + +@available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, *) +@available(watchOS, unavailable) +internal struct ListItemViewModifier: ViewModifier { + let index: Int + let itemsCount: Int? + let trackerNamespace: String? + + /// Get tracker by namespace if configured, otherwise return the default tracker + private var tracker: TrackerController? { + if let namespace = trackerNamespace { + return Snowplow.tracker(namespace: namespace) + } else { + return Snowplow.defaultTracker() + } + } + + /// Modifies the view to track the list item view when it appears + func body(content: Content) -> some View { + content.onAppear { + trackListItemView() + } + } + + func trackListItemView() { + let event = ListItemView(index: index) + event.itemsCount = itemsCount + + if let tracker = tracker { + _ = tracker.track(event) + } else { + logError(message: "List item view not tracked – tracker not initialized.") + } + } +} + +#endif diff --git a/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift b/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift index c6a184b22..15e682ef5 100644 --- a/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift +++ b/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift @@ -17,6 +17,10 @@ class ScreenStateMachine: StateMachineProtocol { static var identifier: String { return "ScreenContext" } var identifier: String { return ScreenStateMachine.identifier } + var subscribedEventSchemasForEventsBefore: [String] { + return [] + } + var subscribedEventSchemasForTransitions: [String] { return [kSPScreenViewSchema] } @@ -37,6 +41,10 @@ class ScreenStateMachine: StateMachineProtocol { return [] } + func eventsBefore(event: Event) -> [Event]? { + return nil + } + func transition(from event: Event, state currentState: State?) -> State? { if let screenView = event as? ScreenView { let newState: ScreenState = screenState(from: screenView) diff --git a/Sources/Core/ScreenViewTracking/ScreenSummaryState.swift b/Sources/Core/ScreenViewTracking/ScreenSummaryState.swift new file mode 100644 index 000000000..30f42fcb2 --- /dev/null +++ b/Sources/Core/ScreenViewTracking/ScreenSummaryState.swift @@ -0,0 +1,95 @@ +// 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 ScreenSummaryState: State { + + static var dateGenerator: () -> TimeInterval = { Date().timeIntervalSince1970 } + + private var lastUpdateTimestamp: TimeInterval = ScreenSummaryState.dateGenerator() + var foregroundSeconds: TimeInterval = 0 + var backgroundSeconds: TimeInterval = 0 + var lastItemIndex: Int? + var itemsCount: Int? + var minYOffset: Int? + var maxYOffset: Int? + var minXOffset: Int? + var maxXOffset: Int? + var contentHeight: Int? + var contentWidth: Int? + + var data: [String: Any] { + var data: [String: Any] = [ + "foreground_sec": round(foregroundSeconds * 100) / 100, + "background_sec": round(backgroundSeconds * 100) / 100 + ] + if let lastItemIndex = lastItemIndex { data["last_item_index"] = lastItemIndex } + if let itemsCount = itemsCount { data["items_count"] = itemsCount } + if let minXOffset = minXOffset { data["min_x_offset"] = minXOffset } + if let maxXOffset = maxXOffset { data["max_x_offset"] = maxXOffset } + if let minYOffset = minYOffset { data["min_y_offset"] = minYOffset } + if let maxYOffset = maxYOffset { data["max_y_offset"] = maxYOffset } + if let contentHeight = contentHeight { data["content_height"] = contentHeight } + if let contentWidth = contentWidth { data["content_width"] = contentWidth } + return data + } + + func updateTransitionToForeground() { + let currentTimestamp = ScreenSummaryState.dateGenerator() + + backgroundSeconds += currentTimestamp - lastUpdateTimestamp + lastUpdateTimestamp = currentTimestamp + } + + func updateTransitionToBackground() { + let currentTimestamp = ScreenSummaryState.dateGenerator() + + foregroundSeconds += currentTimestamp - lastUpdateTimestamp + lastUpdateTimestamp = currentTimestamp + } + + func updateForScreenEnd() { + let currentTimestamp = ScreenSummaryState.dateGenerator() + + foregroundSeconds += currentTimestamp - lastUpdateTimestamp + lastUpdateTimestamp = currentTimestamp + } + + func updateWithListItemView(_ event: ListItemView) { + lastItemIndex = max(event.index, lastItemIndex ?? 0) + if let totalItems = event.itemsCount { + self.itemsCount = max(totalItems, self.itemsCount ?? 0) + } + } + + func updateWithScrollChanged(_ event: ScrollChanged) { + if let yOffset = event.yOffset { + var maxYOffset = yOffset + if let viewHeight = event.viewHeight { maxYOffset += viewHeight } + + minYOffset = min(yOffset, minYOffset ?? yOffset) + self.maxYOffset = max(maxYOffset, self.maxYOffset ?? maxYOffset) + } + if let xOffset = event.xOffset { + var maxXOffset = xOffset + if let viewWidth = event.viewWidth { maxXOffset += viewWidth } + + minXOffset = min(xOffset, minXOffset ?? xOffset) + self.maxXOffset = max(maxXOffset, self.maxXOffset ?? maxXOffset) + } + if let height = event.contentHeight { contentHeight = max(height, contentHeight ?? 0) } + if let width = event.contentWidth { contentWidth = max(width, contentWidth ?? 0) } + } + +} diff --git a/Sources/Core/ScreenViewTracking/ScreenSummaryStateMachine.swift b/Sources/Core/ScreenViewTracking/ScreenSummaryStateMachine.swift new file mode 100644 index 000000000..f0e4b674a --- /dev/null +++ b/Sources/Core/ScreenViewTracking/ScreenSummaryStateMachine.swift @@ -0,0 +1,93 @@ +// 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 ScreenSummaryStateMachine: StateMachineProtocol { + static var identifier: String { return "ScreenSummaryContext" } + var identifier: String { return ScreenSummaryStateMachine.identifier } + + var subscribedEventSchemasForEventsBefore: [String] { + return [kSPScreenViewSchema] + } + + var subscribedEventSchemasForTransitions: [String] { + return [kSPScreenViewSchema, kSPScreenEndSchema, kSPForegroundSchema, kSPBackgroundSchema, kSPListItemViewSchema, kSPScrollChangedSchema] + } + + var subscribedEventSchemasForEntitiesGeneration: [String] { + return [kSPScreenEndSchema, kSPForegroundSchema, kSPBackgroundSchema] + } + + var subscribedEventSchemasForPayloadUpdating: [String] { + return [] + } + + var subscribedEventSchemasForAfterTrackCallback: [String] { + return [] + } + + var subscribedEventSchemasForFiltering: [String] { + return [kSPListItemViewSchema, kSPScrollChangedSchema, kSPScreenEndSchema] + } + + func eventsBefore(event: Event) -> [Event]? { + return [ScreenEnd()] + } + + func transition(from event: Event, state currentState: State?) -> State? { + if event is ScreenView { + return ScreenSummaryState() + } + else if let state = currentState as? ScreenSummaryState { + switch event { + case is Foreground: + state.updateTransitionToForeground() + case is Background: + state.updateTransitionToBackground() + case is ScreenEnd: + state.updateForScreenEnd() + case let itemView as ListItemView: + state.updateWithListItemView(itemView) + case let scrollChanged as ScrollChanged: + state.updateWithScrollChanged(scrollChanged) + default: + break + } + } + return currentState + } + + func entities(from event: InspectableEvent, state: State?) -> [SelfDescribingJson]? { + guard let state = state as? ScreenSummaryState else { return nil } + + return [ + SelfDescribingJson(schema: kSPScreenSummarySchema, andData: state.data) + ] + } + + func payloadValues(from event: InspectableEvent, state: State?) -> [String : Any]? { + return nil + } + + func filter(event: InspectableEvent, state: State?) -> Bool? { + if event.schema == kSPScreenEndSchema { + return state != nil + } + // do not track list item view or scroll changed events + return false + } + + func afterTrack(event: InspectableEvent) { + } +} diff --git a/Sources/Core/StateMachine/DeepLinkStateMachine.swift b/Sources/Core/StateMachine/DeepLinkStateMachine.swift index 9f551eb1f..b2a2f4ce1 100644 --- a/Sources/Core/StateMachine/DeepLinkStateMachine.swift +++ b/Sources/Core/StateMachine/DeepLinkStateMachine.swift @@ -29,6 +29,10 @@ class DeepLinkStateMachine: StateMachineProtocol { static var identifier: String { return "DeepLinkContext" } var identifier: String { return DeepLinkStateMachine.identifier } + var subscribedEventSchemasForEventsBefore: [String] { + return [] + } + var subscribedEventSchemasForTransitions: [String] { return [DeepLinkReceived.schema, kSPScreenViewSchema] } @@ -49,6 +53,10 @@ class DeepLinkStateMachine: StateMachineProtocol { return [] } + func eventsBefore(event: Event) -> [Event]? { + return nil + } + func transition(from event: Event, state: State?) -> State? { if let dlEvent = event as? DeepLinkReceived { return DeepLinkState(url: dlEvent.url, referrer: dlEvent.referrer) diff --git a/Sources/Core/StateMachine/LifecycleStateMachine.swift b/Sources/Core/StateMachine/LifecycleStateMachine.swift index 8bf143295..1d8bac816 100644 --- a/Sources/Core/StateMachine/LifecycleStateMachine.swift +++ b/Sources/Core/StateMachine/LifecycleStateMachine.swift @@ -17,10 +17,16 @@ class LifecycleStateMachine: StateMachineProtocol { static var identifier: String { return "Lifecycle" } var identifier: String { return LifecycleStateMachine.identifier } + var subscribedEventSchemasForEventsBefore: [String] = [] + + func eventsBefore(event: Event) -> [Event]? { + return nil + } + var subscribedEventSchemasForTransitions: [String] { return [kSPBackgroundSchema, kSPForegroundSchema] } - + func transition(from event: Event, state currentState: State?) -> State? { if let e = event as? Foreground { return LifecycleState(asForegroundWithIndex: e.index) diff --git a/Sources/Core/StateMachine/PluginStateMachine.swift b/Sources/Core/StateMachine/PluginStateMachine.swift index 67149ad2f..1fe4c5b48 100644 --- a/Sources/Core/StateMachine/PluginStateMachine.swift +++ b/Sources/Core/StateMachine/PluginStateMachine.swift @@ -35,6 +35,14 @@ class PluginStateMachine: StateMachineProtocol { self.filterConfiguration = filterConfiguration } + var subscribedEventSchemasForEventsBefore: [String] { + return [] + } + + func eventsBefore(event: Event) -> [Event]? { + return nil + } + var subscribedEventSchemasForTransitions: [String] { return [] } diff --git a/Sources/Core/StateMachine/StateMachineProtocol.swift b/Sources/Core/StateMachine/StateMachineProtocol.swift index 7dbff9499..9c1b08a32 100644 --- a/Sources/Core/StateMachine/StateMachineProtocol.swift +++ b/Sources/Core/StateMachine/StateMachineProtocol.swift @@ -15,12 +15,16 @@ import Foundation protocol StateMachineProtocol { var identifier: String { get } + var subscribedEventSchemasForEventsBefore: [String] { get } var subscribedEventSchemasForTransitions: [String] { get } var subscribedEventSchemasForEntitiesGeneration: [String] { get } var subscribedEventSchemasForPayloadUpdating: [String] { get } var subscribedEventSchemasForAfterTrackCallback: [String] { get } var subscribedEventSchemasForFiltering: [String] { get } + /// Only available for self-describing events (inheriting from SelfDescribingAbstract) + func eventsBefore(event: Event) -> [Event]? + /// Only available for self-describing events (inheriting from SelfDescribingAbstract) func transition(from event: Event, state: State?) -> State? diff --git a/Sources/Core/StateMachine/StateManager.swift b/Sources/Core/StateMachine/StateManager.swift index b4b911d5d..6ea46cdac 100644 --- a/Sources/Core/StateMachine/StateManager.swift +++ b/Sources/Core/StateMachine/StateManager.swift @@ -20,6 +20,7 @@ class StateManager { private var eventSchemaToPayloadUpdater: [String : [StateMachineProtocol]] = [:] private var eventSchemaToAfterTrackCallback: [String : [StateMachineProtocol]] = [:] private var eventSchemaToFilter: [String : [StateMachineProtocol]] = [:] + private var eventSchemaToEventsBefore: [String : [StateMachineProtocol]] = [:] private var trackerState = TrackerState() func addOrReplaceStateMachine(_ stateMachine: StateMachineProtocol) { @@ -50,6 +51,10 @@ class StateManager { toSchemaRegistry: &eventSchemaToFilter, schemas: stateMachine.subscribedEventSchemasForFiltering, stateMachine: stateMachine) + add( + toSchemaRegistry: &eventSchemaToEventsBefore, + schemas: stateMachine.subscribedEventSchemasForEventsBefore, + stateMachine: stateMachine) } func removeStateMachine(_ stateMachineIdentifier: String) -> Bool { @@ -78,6 +83,10 @@ class StateManager { fromSchemaRegistry: &eventSchemaToFilter, schemas: stateMachine.subscribedEventSchemasForFiltering, stateMachine: stateMachine) + remove( + fromSchemaRegistry: &eventSchemaToEventsBefore, + schemas: stateMachine.subscribedEventSchemasForEventsBefore, + stateMachine: stateMachine) return true } @@ -124,6 +133,22 @@ class StateManager { } return true } + + func eventsBefore(forProcessedEvent event: Event) -> [Event] { + var result: [Event] = [] + guard let sdEvent = event as? SelfDescribingAbstract else { return result } + + let schema = sdEvent.schema + var stateMachines = eventSchemaToEventsBefore[schema] ?? [] + stateMachines.append(contentsOf: eventSchemaToEventsBefore["*"] ?? []) + + for stateMachine in stateMachines { + if let eventsBefore = stateMachine.eventsBefore(event: event) { + result.append(contentsOf: eventsBefore) + } + } + return result + } func entities(forProcessedEvent event: InspectableEvent & StateMachineEvent) -> [SelfDescribingJson] { guard let schema = event.schema ?? event.eventName else { return [] } diff --git a/Sources/Core/Storage/SQLiteEventStore.swift b/Sources/Core/Storage/SQLiteEventStore.swift index 53afe9753..eea61c97f 100644 --- a/Sources/Core/Storage/SQLiteEventStore.swift +++ b/Sources/Core/Storage/SQLiteEventStore.swift @@ -36,10 +36,10 @@ class SQLiteEventStore: NSObject, EventStore { // MARK: SPEventStore implementation methods - func addEvent(_ payload: Payload) { + func addEvent(_ data: Payload) { InternalQueue.onQueuePrecondition() - self.database.insertRow(payload.dictionary) + self.database.insertRow(data.dictionary) } func removeEvent(withId storeId: Int64) -> Bool { diff --git a/Sources/Core/Tracker/ServiceProvider.swift b/Sources/Core/Tracker/ServiceProvider.swift index c25c88710..39e117c85 100644 --- a/Sources/Core/Tracker/ServiceProvider.swift +++ b/Sources/Core/Tracker/ServiceProvider.swift @@ -295,6 +295,7 @@ class ServiceProvider: NSObject, ServiceProviderProtocol { tracker.applicationContext = trackerConfiguration.applicationContext tracker.deepLinkContext = trackerConfiguration.deepLinkContext tracker.screenContext = trackerConfiguration.screenContext + tracker.screenEngagementAutotracking = trackerConfiguration.screenEngagementAutotracking tracker.autotrackScreenViews = trackerConfiguration.screenViewAutotracking tracker.lifecycleEvents = trackerConfiguration.lifecycleAutotracking tracker.installEvent = trackerConfiguration.installAutotracking diff --git a/Sources/Core/Tracker/Tracker.swift b/Sources/Core/Tracker/Tracker.swift index d3268468a..255e9131f 100644 --- a/Sources/Core/Tracker/Tracker.swift +++ b/Sources/Core/Tracker/Tracker.swift @@ -156,6 +156,19 @@ class Tracker: NSObject { } } + private var _screenEngagementAutotracking = false + var screenEngagementAutotracking: Bool { + get { return _screenEngagementAutotracking } + set { + self._screenEngagementAutotracking = newValue + if newValue { + self.addOrReplace(stateMachine: ScreenSummaryStateMachine()) + } else { + _ = self.stateManager.removeStateMachine(ScreenSummaryStateMachine.identifier) + } + } + } + var applicationContext = TrackerDefaults.applicationContext var autotrackScreenViews = TrackerDefaults.autotrackScreenViews @@ -400,14 +413,23 @@ class Tracker: NSObject { InternalQueue.onQueuePrecondition() if dataCollection { - event.beginProcessing(withTracker: self) - self.processEvent(event, eventId) - event.endProcessing(withTracker: self) + let events = withEventsBefore(event: event, eventId: eventId) + for (event, eventId) in events { + event.beginProcessing(withTracker: self) + self.processEvent(event, eventId) + event.endProcessing(withTracker: self) + } } return eventId } // MARK: - Event Decoration + + private func withEventsBefore(event: Event, eventId: UUID) -> [(event: Event, eventId: UUID)] { + let eventsBefore = stateManager.eventsBefore(forProcessedEvent: event) + + return eventsBefore.map { (event: $0, eventId: UUID()) } + [(event: event, eventId: eventId)] + } func processEvent(_ event: Event, _ eventId: UUID) { let stateSnapshot = stateManager.trackerState(forProcessedEvent: event) diff --git a/Sources/Core/Tracker/TrackerControllerImpl.swift b/Sources/Core/Tracker/TrackerControllerImpl.swift index 62b56618f..172fcdd4d 100644 --- a/Sources/Core/Tracker/TrackerControllerImpl.swift +++ b/Sources/Core/Tracker/TrackerControllerImpl.swift @@ -241,6 +241,14 @@ class TrackerControllerImpl: Controller, TrackerController { tracker.autotrackScreenViews = newValue } } + + var screenEngagementAutotracking: Bool { + get { return tracker.screenEngagementAutotracking } + set { + dirtyConfig.screenEngagementAutotracking = newValue + tracker.screenEngagementAutotracking = newValue + } + } var trackerVersionSuffix: String? { get { diff --git a/Sources/Core/Tracker/TrackerDefaults.swift b/Sources/Core/Tracker/TrackerDefaults.swift index 30b0f9a90..a310ce6dd 100644 --- a/Sources/Core/Tracker/TrackerDefaults.swift +++ b/Sources/Core/Tracker/TrackerDefaults.swift @@ -31,4 +31,5 @@ class TrackerDefaults { private(set) static var userAnonymisation = false private(set) static var platformContext = true private(set) static var geoLocationContext = false + private(set) static var screenEngagementAutotracking = true } diff --git a/Sources/Core/TrackerConstants.swift b/Sources/Core/TrackerConstants.swift index 253749b51..3ca535ac1 100644 --- a/Sources/Core/TrackerConstants.swift +++ b/Sources/Core/TrackerConstants.swift @@ -70,6 +70,10 @@ let ecommercePromotionSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/pr let ecommerceRefundSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/refund/jsonschema/1-0-0" let ecommerceUserSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/user/jsonschema/1-0-0" let ecommercePageSchema = "iglu:com.snowplowanalytics.snowplow.ecommerce/page/jsonschema/1-0-0" +let kSPScreenEndSchema = "iglu:com.snowplowanalytics.mobile/screen_end/jsonschema/1-0-0" +let kSPScreenSummarySchema = "iglu:com.snowplowanalytics.mobile/screen_summary/jsonschema/1-0-0" +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" // --- Event Keys let kSPEventPageView = "pv" diff --git a/Sources/Snowplow/Configurations/TrackerConfiguration.swift b/Sources/Snowplow/Configurations/TrackerConfiguration.swift index 5e940aacc..6b6697872 100644 --- a/Sources/Snowplow/Configurations/TrackerConfiguration.swift +++ b/Sources/Snowplow/Configurations/TrackerConfiguration.swift @@ -52,6 +52,10 @@ public protocol TrackerConfigurationProtocol: AnyObject { /// Whether 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. + /// 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. @objc var lifecycleAutotracking: Bool { get set } @@ -185,6 +189,15 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati set { _screenViewAutotracking = newValue } } + private var _screenEngagementAutotracking: Bool? + /// Whether enable tracking 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 { + get { return _screenEngagementAutotracking ?? sourceConfig?.screenEngagementAutotracking ?? TrackerDefaults.screenEngagementAutotracking } + set { _screenEngagementAutotracking = newValue } + } + private var _lifecycleAutotracking: Bool? /// Whether enable automatic tracking of background and foreground transitions. @objc @@ -313,6 +326,9 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati if let screenViewAutotracking = dictionary["screenViewAutotracking"] as? Bool { self.screenViewAutotracking = screenViewAutotracking } + if let screenEngagementAutotracking = dictionary["screenEngagementAutotracking"] as? Bool { + self.screenEngagementAutotracking = screenEngagementAutotracking + } if let lifecycleAutotracking = dictionary["lifecycleAutotracking"] as? Bool { self.lifecycleAutotracking = lifecycleAutotracking } @@ -423,6 +439,13 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati return self } + /// Whether enable tracking 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. @objc public func lifecycleAutotracking(_ lifecycleAutotracking: Bool) -> Self { @@ -493,6 +516,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati copy.deepLinkContext = deepLinkContext copy.screenContext = screenContext copy.screenViewAutotracking = screenViewAutotracking + copy.screenEngagementAutotracking = screenEngagementAutotracking copy.lifecycleAutotracking = lifecycleAutotracking copy.installAutotracking = installAutotracking copy.exceptionAutotracking = exceptionAutotracking @@ -522,6 +546,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati coder.encode(deepLinkContext, forKey: "deepLinkContext") coder.encode(screenContext, forKey: "screenContext") coder.encode(screenViewAutotracking, forKey: "screenViewAutotracking") + coder.encode(screenEngagementAutotracking, forKey: "screenEngagementAutotracking") coder.encode(lifecycleAutotracking, forKey: "lifecycleAutotracking") coder.encode(installAutotracking, forKey: "installAutotracking") coder.encode(exceptionAutotracking, forKey: "exceptionAutotracking") @@ -552,6 +577,7 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati deepLinkContext = coder.decodeBool(forKey: "deepLinkContext") screenContext = coder.decodeBool(forKey: "screenContext") screenViewAutotracking = coder.decodeBool(forKey: "screenViewAutotracking") + screenEngagementAutotracking = coder.decodeBool(forKey: "screenEngagementAutotracking") lifecycleAutotracking = coder.decodeBool(forKey: "lifecycleAutotracking") installAutotracking = coder.decodeBool(forKey: "installAutotracking") exceptionAutotracking = coder.decodeBool(forKey: "exceptionAutotracking") diff --git a/Sources/Snowplow/Events/ListItemView.swift b/Sources/Snowplow/Events/ListItemView.swift new file mode 100644 index 000000000..42cf55eb8 --- /dev/null +++ b/Sources/Snowplow/Events/ListItemView.swift @@ -0,0 +1,57 @@ +// 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 tracking the view of an item in a list. +/// If screen engagement tracking is enabled, the list item view events will be aggregated into a `screen_summary` entity. +/// +/// Schema: `iglu:com.snowplowanalytics.mobile/list_item_view/jsonschema/1-0-0` +@objc(SPListItemView) +public class ListItemView: SelfDescribingAbstract { + /// Index of the item in the list + @objc + public var index: Int + /// Total number of items in the list + public var itemsCount: Int? + + /// - Parameters: + /// - index: Index of the item in the list + @objc + public init(index: Int) { + self.index = index + } + + /// - Parameters: + /// - index: Index of the item in the list + /// - totalItems: Total number of items in the list + @objc + public init(index: Int, totalItems: Int) { + self.index = index + self.itemsCount = totalItems + } + + override var schema: String { + return kSPListItemViewSchema + } + + override var payload: [String : Any] { + var data = [ + "index": index + ] + if let itemsCount = itemsCount { + data["items_count"] = itemsCount + } + return data + } +} diff --git a/Sources/Snowplow/Events/ScrollChanged.swift b/Sources/Snowplow/Events/ScrollChanged.swift new file mode 100644 index 000000000..fc0b76512 --- /dev/null +++ b/Sources/Snowplow/Events/ScrollChanged.swift @@ -0,0 +1,114 @@ +// 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 tracked when a scroll view's scroll position changes. +/// If screen engagement tracking is enabled, the scroll changed events will be aggregated into a `screen_summary` entity. +/// +/// Schema: `iglu:com.snowplowanalytics.mobile/scroll_changed/jsonschema/1-0-0` +@objc(SPScrollChanged) +public class ScrollChanged: SelfDescribingAbstract { + /// Vertical scroll offset in pixels + public var yOffset: Int? + /// Horizontal scroll offset in pixels + public var xOffset: Int? + /// The height of the scroll view in pixels + public var viewHeight: Int? + /// The width of the scroll view in pixels + public var viewWidth: Int? + /// The height of the content in the scroll view in pixels + public var contentHeight: Int? + /// The width of the content in the scroll view in pixels + public var contentWidth: Int? + + /// - Parameters: + /// - xOffset: Horizontal scroll offset in pixels + /// - yOffset: Vertical scroll offset in pixels + /// - viewWidth: The width of the scroll view in pixels + /// - viewHeight: The height of the scroll view in pixels + /// - contentWidth: The width of the content in the scroll view in pixels + /// - contentHeight: The height of the content in the scroll view in pixels + public init( + xOffset: Int? = nil, + yOffset: Int? = nil, + viewWidth: Int? = nil, + viewHeight: Int? = nil, + contentWidth: Int? = nil, + contentHeight: Int? = nil + ) { + self.yOffset = yOffset + self.xOffset = xOffset + self.viewHeight = viewHeight + self.viewWidth = viewWidth + self.contentHeight = contentHeight + self.contentWidth = contentWidth + } + + /// Vertical scroll offset in pixels + @objc + public func yOffset(_ yOffset: Int) -> Self { + self.yOffset = yOffset + return self + } + + /// Horizontal scroll offset in pixels + @objc + public func xOffset(_ xOffset: Int) -> Self { + self.xOffset = xOffset + return self + } + + /// The height of the scroll view in pixels + @objc + public func viewHeight(_ viewHeight: Int) -> Self { + self.viewHeight = viewHeight + return self + } + + /// The width of the scroll view in pixels + @objc + public func viewWidth(_ viewWidth: Int) -> Self { + self.viewWidth = viewWidth + return self + } + + /// The height of the scroll view content in pixels + @objc + public func contentHeight(_ contentHeight: Int) -> Self { + self.contentHeight = contentHeight + return self + } + + /// The width of the scroll view content in pixels + @objc + public func contentWidth(_ contentWidth: Int) -> Self { + self.contentWidth = contentWidth + return self + } + + override var schema: String { + return kSPScrollChangedSchema + } + + override var payload: [String : Any] { + var data: [String: Any] = [:] + if let xOffset = xOffset { data["x_offset"] = xOffset } + if let yOffset = yOffset { data["y_offset"] = yOffset } + if let viewWidth = viewWidth { data["view_width"] = viewWidth } + if let viewHeight = viewHeight { data["view_height"] = viewHeight } + if let contentWidth = contentWidth { data["content_width"] = contentWidth } + if let contentHeight = contentHeight { data["content_height"] = contentHeight } + return data + } +} diff --git a/Sources/Snowplow/Tracker/View.swift b/Sources/Snowplow/Tracker/View.swift index 4773ffed0..2618485e0 100644 --- a/Sources/Snowplow/Tracker/View.swift +++ b/Sources/Snowplow/Tracker/View.swift @@ -29,6 +29,16 @@ public extension View { entities: entities, trackerNamespace: trackerNamespace)) } + + /// Sets up tracking of list item views that will be aggregated into the `screen_summary` entity if screen engagement tracking is enabled. + /// - Parameter index: Index of the item in the list + /// - Parameter itemsCount: Total number of items in the list + /// - Returns: View with the attached modifier to track list item views + func snowplowListItem(index: Int, itemsCount: Int?, trackerNamespace: String? = nil) -> some View { + return modifier(ListItemViewModifier(index: index, + itemsCount: itemsCount, + trackerNamespace: trackerNamespace)) + } } #endif diff --git a/Tests/Ecommerce/TestEcommerceController.swift b/Tests/Ecommerce/TestEcommerceController.swift index 4bb6c273c..cd5ed23a0 100644 --- a/Tests/Ecommerce/TestEcommerceController.swift +++ b/Tests/Ecommerce/TestEcommerceController.swift @@ -102,6 +102,7 @@ class TestEcommerceController: XCTestCase { let trackerConfig = TrackerConfiguration() trackerConfig.installAutotracking = false trackerConfig.lifecycleAutotracking = false + trackerConfig.screenEngagementAutotracking = false let namespace = "testEcommerce" + String(describing: Int.random(in: 0..<100)) eventSink = EventSink() diff --git a/Tests/ScreenViewTracking/TestListItemViewModifier.swift b/Tests/ScreenViewTracking/TestListItemViewModifier.swift new file mode 100644 index 000000000..3187f2af8 --- /dev/null +++ b/Tests/ScreenViewTracking/TestListItemViewModifier.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 +import XCTest +@testable import SnowplowTracker + +#if canImport(SwiftUI) +#if os(iOS) || os(tvOS) || os(macOS) + +class TestListItemViewModifier: XCTestCase { + func testTracksListItemViewEvent() { + if #available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, *) { + let expect = expectation(description: "Event received") + createTracker { event in + XCTAssertEqual(1, event.payload["index"] as? Int) + XCTAssertEqual(5, event.payload["items_count"] as? Int) + XCTAssertEqual(kSPListItemViewSchema, event.schema) + expect.fulfill() + } + + let modifier = ListItemViewModifier( + index: 1, + itemsCount: 5, + trackerNamespace: "listItemViewTracker" + ) + modifier.trackListItemView() + + wait(for: [expect], timeout: 1) + } + } + + private func createTracker(afterTrack: @escaping (InspectableEvent) -> ()) { + let networkConfig = NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)) + + _ = Snowplow.createTracker( + namespace: "listItemViewTracker", + network: networkConfig, + configurations: [ + EventSink(callback: afterTrack), + TrackerConfiguration() + .installAutotracking(false) + .lifecycleAutotracking(false) + .screenEngagementAutotracking(false) + ]) + } +} + +private struct ScreenViewExpected: Codable { + let name: String +} + +private struct AnythingEntityExpected: Codable { + let works: Bool +} + +#endif +#endif diff --git a/Tests/TestScreenState.swift b/Tests/ScreenViewTracking/TestScreenState.swift similarity index 100% rename from Tests/TestScreenState.swift rename to Tests/ScreenViewTracking/TestScreenState.swift diff --git a/Tests/ScreenViewTracking/TestScreenSummaryStateMachine.swift b/Tests/ScreenViewTracking/TestScreenSummaryStateMachine.swift new file mode 100644 index 000000000..f8816a101 --- /dev/null +++ b/Tests/ScreenViewTracking/TestScreenSummaryStateMachine.swift @@ -0,0 +1,142 @@ +// 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 + +import Foundation +class TestScreenSummaryStateMachine: XCTestCase { + var timeTraveler = TimeTraveler() + + override func setUp() { + ScreenSummaryState.dateGenerator = timeTraveler.generateTimeInterval + super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testTrackTransitionToBackgroundAndForeground() { + let expectBackground = expectation(description: "Background event") + let expectForeground = expectation(description: "Foreground event") + + let eventSink = EventSink { event in + if event.schema == kSPBackgroundSchema { + let entity = event.entities.first { $0.schema == kSPScreenSummarySchema } + XCTAssertEqual((entity?.data as? [String: Any])?["foreground_sec"] as? Double, 10.0) + XCTAssertEqual((entity?.data as? [String: Any])?["background_sec"] as? Double, 0.0) + expectBackground.fulfill() + } + + if event.schema == kSPForegroundSchema { + let entity = event.entities.first { $0.schema == kSPScreenSummarySchema } + XCTAssertEqual((entity?.data as? [String: Any])?["foreground_sec"] as? Double, 10.0) + XCTAssertEqual((entity?.data as? [String: Any])?["background_sec"] as? Double, 5.0) + expectForeground.fulfill() + } + } + + let tracker = createTracker([eventSink]) + + _ = tracker.track(ScreenView(name: "Screen 1")) + InternalQueue.sync { timeTraveler.travel(by: 10) } + _ = tracker.track(Background(index: 1)) + InternalQueue.sync { timeTraveler.travel(by: 5) } + _ = tracker.track(Foreground(index: 1)) + + wait(for: [expectBackground, expectForeground], timeout: 10) + } + + func testTracksScreenEndEventWithScreenSummary() { + let expectScreenEnd = expectation(description: "Screen end event") + + let eventSink = EventSink { event in + if event.schema == kSPScreenEndSchema { + let entity = event.entities.first { $0.schema == kSPScreenSummarySchema } + XCTAssertEqual((entity?.data as? [String: Any])?["foreground_sec"] as? Double, 10.0) + XCTAssertEqual((entity?.data as? [String: Any])?["background_sec"] as? Double, 0.0) + expectScreenEnd.fulfill() + } + } + + let tracker = createTracker([eventSink]) + + _ = tracker.track(ScreenView(name: "Screen 1")) + InternalQueue.sync { timeTraveler.travel(by: 10) } + _ = tracker.track(ScreenView(name: "Screen 2")) + + wait(for: [expectScreenEnd], timeout: 10) + } + + func testUpdatesListMetrics() { + let expectScreenEnd = expectation(description: "Screen end event") + + let eventSink = EventSink { event in + if event.schema == kSPScreenEndSchema { + let entity = event.entities.first { $0.schema == kSPScreenSummarySchema } + XCTAssertEqual((entity?.data as? [String: Any])?["last_item_index"] as? Int, 3) + XCTAssertEqual((entity?.data as? [String: Any])?["items_count"] as? Int, 10) + expectScreenEnd.fulfill() + } + } + + let tracker = createTracker([eventSink]) + + _ = tracker.track(ScreenView(name: "Screen 1")) + _ = tracker.track(ListItemView(index: 1, totalItems: 10)) + _ = tracker.track(ListItemView(index: 3, totalItems: 10)) + _ = tracker.track(ListItemView(index: 2, totalItems: 10)) + _ = tracker.track(ScreenView(name: "Screen 2")) + + wait(for: [expectScreenEnd], timeout: 10) + } + + func testUpdatesScrollMetrics() { + let expectScreenEnd = expectation(description: "Screen end event") + + let eventSink = EventSink { event in + if event.schema == kSPScreenEndSchema { + let entity = event.entities.first { $0.schema == kSPScreenSummarySchema } + XCTAssertEqual((entity?.data as? [String: Any])?["min_y_offset"] as? Int, 10) + XCTAssertEqual((entity?.data as? [String: Any])?["min_x_offset"] as? Int, 15) + XCTAssertEqual((entity?.data as? [String: Any])?["max_y_offset"] as? Int, 50) + XCTAssertEqual((entity?.data as? [String: Any])?["max_x_offset"] as? Int, 30) + XCTAssertEqual((entity?.data as? [String: Any])?["content_height"] as? Int, 100) + XCTAssertEqual((entity?.data as? [String: Any])?["content_width"] as? Int, 200) + expectScreenEnd.fulfill() + } + } + + let tracker = createTracker([eventSink]) + + _ = tracker.track(ScreenView(name: "Screen 1")) + _ = tracker.track(ScrollChanged(yOffset: 10, viewHeight: 20, contentHeight: 100)) + _ = tracker.track(ScrollChanged(xOffset: 15, yOffset: 30, viewWidth: 15, viewHeight: 20, contentWidth: 200, contentHeight: 100)) + _ = tracker.track(ScrollChanged(yOffset: 20, viewHeight: 20, contentHeight: 100)) + _ = tracker.track(ScreenView(name: "Screen 2")) + + wait(for: [expectScreenEnd], timeout: 10) + } + + private func createTracker(_ configurations: [ConfigurationProtocol]) -> TrackerController { + let networkConfig = NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)) + let trackerConfig = TrackerConfiguration() + trackerConfig.installAutotracking = false + trackerConfig.lifecycleAutotracking = false + let namespace = "testScreenSummary" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: configurations + [trackerConfig]) + } +} diff --git a/Tests/TestScreenViewModifier.swift b/Tests/ScreenViewTracking/TestScreenViewModifier.swift similarity index 100% rename from Tests/TestScreenViewModifier.swift rename to Tests/ScreenViewTracking/TestScreenViewModifier.swift diff --git a/Tests/TestStateManager.swift b/Tests/TestStateManager.swift index 10bad847e..101ff11d5 100644 --- a/Tests/TestStateManager.swift +++ b/Tests/TestStateManager.swift @@ -35,6 +35,14 @@ class MockStateMachine: StateMachineProtocol { self.identifier = identifier } + var subscribedEventSchemasForEventsBefore: [String] { + return [] + } + + func eventsBefore(event: SnowplowTracker.Event) -> [SnowplowTracker.Event]? { + return nil + } + var subscribedEventSchemasForTransitions: [String] { return ["inc", "dec"] } diff --git a/Tests/Utils/TimeTraveler.swift b/Tests/Utils/TimeTraveler.swift index 30fcac2bf..34b3ea605 100644 --- a/Tests/Utils/TimeTraveler.swift +++ b/Tests/Utils/TimeTraveler.swift @@ -23,4 +23,8 @@ class TimeTraveler { func generateDate() -> Date { return date } + + func generateTimeInterval() -> TimeInterval { + return date.timeIntervalSince1970 + } }