diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 38d8a174a..2f5d20219 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,11 +5,14 @@ on: [push] jobs: podspec: name: Lint Podspec for ${{ matrix.platform }} - runs-on: macos-11 + runs-on: macos-13 strategy: matrix: platform: [ios, ios, osx, tvos, watchos] steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable - uses: actions/checkout@v3 - name: Lint Podspec run: pod lib lint --platforms=${{ matrix.platform }} @@ -21,36 +24,36 @@ jobs: fail-fast: false matrix: include: - - name: "Integration (iOS 15.2, Xcode 13.2.1)" + - name: "Integration (iOS, Xcode 13.4)" os: macos-12 - xcode-version: "13.2.1" - sdk: iphonesimulator15.2 - destination: "platform=iOS Simulator,OS=15.2,name=iPhone 13" + xcode-version: 13.4 + destination: "platform=iOS Simulator,name=iPhone 13" target: IntegrationTests - - name: "Unit (iOS 15.2, Xcode 13.2.1)" + + - name: "Unit (iOS, Xcode 13.4)" os: macos-12 - xcode-version: "13.2.1" - sdk: iphonesimulator15.2 - destination: "platform=iOS Simulator,OS=15.2,name=iPhone 13" + xcode-version: 13.4 + destination: "platform=iOS Simulator,name=iPhone 13" target: Tests - - name: "Unit (macOS 12.1, Xcode 13.2.1)" + + - name: "Unit (macOS, Xcode 13.4)" os: macos-12 - xcode-version: "13.2.1" - sdk: macosx12.1 - destination: "platform=OS X" + xcode-version: 13.4 + destination: "platform=macOS" target: Tests - - name: "Unit (watchOS 8.3, Xcode 13.2.1)" + + - name: "Unit (watchOS, Xcode 13.4)" os: macos-12 - xcode-version: "13.2.1" - sdk: watchos8.3 - destination: "platform=watchOS Simulator,OS=8.3,name=Apple Watch Series 7 - 45mm" + xcode-version: 13.4 + destination: "platform=watchOS Simulator,name=Apple Watch Series 7 - 45mm" target: Tests - - name: "Unit (tvOS 15.2, Xcode 13.2.1)" + + - name: "Unit (tvOS, Xcode 13.4)" os: macos-12 - xcode-version: "13.2.1" - sdk: appletvsimulator15.2 - destination: "platform=tvOS Simulator,OS=15.2,name=Apple TV" + xcode-version: 13.4 + destination: "platform=tvOS Simulator,name=Apple TV" target: Tests + steps: - name: Checkout uses: actions/checkout@v3 @@ -77,18 +80,26 @@ jobs: run: while ! nc -z '0.0.0.0' 9090; do sleep 1; done # -- Micro -- - - name: Select Xcode Version - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode-version }}.app/Contents/Developer + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.xcode-version }} - name: Build & Test run: | set -o pipefail && xcodebuild \ -scheme SnowplowTracker \ - -sdk "${{ matrix.sdk }}" \ -destination "${{ matrix.destination }}" \ -only-testing ${{ matrix.target }} \ + -resultBundlePath TestResults \ clean test | xcpretty + - name: Create test results + uses: kishikawakatsumi/xcresulttool@v1 + with: + path: TestResults.xcresult + title: "Test results: ${{ matrix.name }}" + if: success() || failure() + build_objc_demo_app: name: "ObjC demo (iOS ${{ matrix.version.ios }})" needs: test_framework @@ -100,8 +111,22 @@ jobs: fail-fast: false matrix: version: - - {ios: 15.5, iphone: iPhone 12 Pro, watchos: 8.5, watch: Apple Watch Series 5 - 44mm, macos: '12', xcode: 13.4} - - {ios: 14.4, iphone: iPhone 8, watchos: 7.2, watch: Apple Watch Series 4 - 40mm, macos: '11', xcode: 12.4} + - { + ios: 15.5, + iphone: iPhone 12 Pro, + watchos: 8.5, + watch: Apple Watch Series 5 - 44mm, + macos: "12", + xcode: 13.4, + } + - { + ios: 14.4, + iphone: iPhone 8, + watchos: 7.2, + watch: Apple Watch Series 4 - 40mm, + macos: "11", + xcode: 12.4, + } steps: - name: Checkout @@ -131,7 +156,14 @@ jobs: fail-fast: false matrix: version: - - {ios: '14.4', iphone: iPhone 12 Pro, watchos: '7.2', watch: Apple Watch Series 5 - 44mm, macos: '11', xcode: 12.4} + - { + ios: "14.4", + iphone: iPhone 12 Pro, + watchos: "7.2", + watch: Apple Watch Series 5 - 44mm, + macos: "11", + xcode: 12.4, + } steps: - name: Checkout @@ -161,7 +193,14 @@ jobs: fail-fast: false matrix: version: - - {ios: '14.4', iphone: iPhone 11 Pro, watchos: '7.2', watch: Apple Watch Series 5 - 44mm, macos: '11', xcode: 12.4} + - { + ios: "14.4", + iphone: iPhone 11 Pro, + watchos: "7.2", + watch: Apple Watch Series 5 - 44mm, + macos: "11", + xcode: 12.4, + } steps: - name: Checkout @@ -207,7 +246,7 @@ jobs: fail-fast: false matrix: version: - - {ios: 15.5, iphone: iPhone 12 Pro, macos: '12', xcode: 13.4} + - { ios: 15.5, iphone: iPhone 12 Pro, macos: "12", xcode: 13.4 } steps: - name: Checkout diff --git a/CHANGELOG b/CHANGELOG index a7808edb5..acdf8625a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,30 @@ +Version 6.0.0 (2024-02-01) +-------------------------- +- Add screen engagement tracking of time spent and list items scrolled on a screen (#851) +- Enable lifecycle autotracking by default (#852) +- Add support for visionOS (#830) +- Add VisionOS events and entities (#857) +- Improve concurrency model using a single internal dispatch queue (#820) +- Process tracked events on a serial background queue (#822) +- Add API to decorate link with user/session info (#819) +- Add configurable limit for the maximum age and number of events in the event store and remove old events before sending (#860) +- Add request timeout to network connection and configuration (#836) thanks to @danigutierrezayuso +- Expose event store from emitter controller to be able to remove all events from database (#834) thanks to @danigutierrezayuso +- Make network requests serially in network connection (#846) +- Change default buffer option to single (#849) +- Flush events only when the buffer is full (#827) thanks to @danigutierrezayuso +- Add SDK privacy manifest file (#811) +- Remove available storage and total storage from platform context (#824) +- Add an option to override platform context properties (#865) +- Remove the use of the FMDB dependency in SQLiteEventStore (#823) +- Return non-optional TrackerController instance from `createTracker` (#847) thanks to @Kymer +- Enable representing self-describing data using Codable structs (#844) +- Match BaseEvent entities API with Android tracker (#867) +- Fix bundle path check to handle symbolic links (#858) thanks to @mylifeasdog +- Set the platform event property to tv on tvOS and mobile on watchOS (#872) +- Update copyright notices (#868) +- Update CI build (#856) + Version 5.6.0 (2023-10-12) -------------------------- Add configuration to send requests with user ID to a Focal Meter endpoint (#745) diff --git a/Examples b/Examples index 3d383f24e..1f45399ac 160000 --- a/Examples +++ b/Examples @@ -1 +1 @@ -Subproject commit 3d383f24e908727f709ae45cf408db0929c3c156 +Subproject commit 1f45399ac1076b930d5e0b58a7e82733a9025204 diff --git a/Integrationtests/TestTrackEventsToMicro.swift b/Integrationtests/TestTrackEventsToMicro.swift index e5e4d8e9d..c57caffc2 100644 --- a/Integrationtests/TestTrackEventsToMicro.swift +++ b/Integrationtests/TestTrackEventsToMicro.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -21,11 +21,12 @@ class TestTrackEventsToMicro: XCTestCase { super.setUp() let trackerConfig = TrackerConfiguration() + .screenEngagementAutotracking(false) .logLevel(.debug) tracker = Snowplow.createTracker(namespace: "testMicro-" + UUID().uuidString, network: NetworkConfiguration(endpoint: Micro.endpoint), - configurations: [trackerConfig])! + configurations: [trackerConfig]) wait(for: [Micro.reset()], timeout: Micro.timeout) } diff --git a/Integrationtests/Utils/Micro.swift b/Integrationtests/Utils/Micro.swift index 878f8a629..973011973 100644 --- a/Integrationtests/Utils/Micro.swift +++ b/Integrationtests/Utils/Micro.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/LICENSE b/LICENSE index 8e001cd71..2d0a17745 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2023 Snowplow Analytics Ltd. + Copyright 2013-present Snowplow Analytics Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -199,4 +199,4 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - \ No newline at end of file + diff --git a/Package.resolved b/Package.resolved index 90cd82c2e..4fc33c6bc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,34 +1,23 @@ { - "object": { - "pins": [ - { - "package": "FMDB", - "repositoryURL": "https://github.com/ccgus/fmdb", - "state": { - "branch": null, - "revision": "61e51fde7f7aab6554f30ab061cc588b28a97d04", - "version": "2.7.7" - } - }, - { - "package": "Mocker", - "repositoryURL": "https://github.com/WeTransfer/Mocker.git", - "state": { - "branch": null, - "revision": "5d86f27a8f80d4ba388bc1a379a3c2289a1f3d18", - "version": "2.6.0" - } - }, - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": null, - "revision": "3303b164430d9a7055ba484c8ead67a52f7b74f6", - "version": "1.0.0" - } + "pins" : [ + { + "identity" : "mocker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/WeTransfer/Mocker.git", + "state" : { + "revision" : "5d86f27a8f80d4ba388bc1a379a3c2289a1f3d18", + "version" : "2.6.0" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index e6d82bb48..2d7a1c745 100644 --- a/Package.swift +++ b/Package.swift @@ -1,9 +1,10 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.3.0 import PackageDescription let package = Package( name: "SnowplowTracker", + defaultLocalization: "en", platforms: [ .macOS("10.13"), .iOS("11.0"), @@ -16,13 +17,11 @@ let package = Package( targets: ["SnowplowTracker"]), ], dependencies: [ - .package(name: "FMDB", url: "https://github.com/ccgus/fmdb", from: "2.7.6"), .package(name: "Mocker", url: "https://github.com/WeTransfer/Mocker.git", from: "2.5.4"), ], targets: [ .target( name: "SnowplowTracker", - dependencies: ["FMDB"], path: "./Sources"), .testTarget( name: "Tests", diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 000000000..1db62ef7d --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,46 @@ +// swift-tools-version:5.9 + +import PackageDescription + +let package = Package( + name: "SnowplowTracker", + defaultLocalization: "en", + platforms: [ + .macOS("10.13"), + .iOS("11.0"), + .tvOS("12.0"), + .watchOS("6.0"), + .visionOS("1.0") + ], + products: [ + .library( + name: "SnowplowTracker", + targets: ["SnowplowTracker"]), + ], + dependencies: [ + .package(name: "Mocker", url: "https://github.com/WeTransfer/Mocker.git", from: "2.5.4"), + ], + targets: [ + .target( + name: "SnowplowTracker", + path: "./Sources"), + .testTarget( + name: "Tests", + dependencies: [ + "SnowplowTracker", + "Mocker" + ], + path: "Tests"), + .testTarget( + name: "IntegrationTests", + dependencies: [ + "SnowplowTracker" + ], + path: "IntegrationTests") + ] +) +#if swift(>=5.6) +package.dependencies += [ + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), +] +#endif diff --git a/PrivacyInfo.xcprivacy b/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..d1aa6938c --- /dev/null +++ b/PrivacyInfo.xcprivacy @@ -0,0 +1,92 @@ + + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePreciseLocation + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeUserID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeCrashData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeOtherDiagnosticData + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/README.md b/README.md index 5b5d507d0..accfc392c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Some examples of demo apps instrumented with our iOS Tracker can be found in the ## Copyright and license -The Snowplow iOS/macOS/tvOS/watchOS Tracker is copyright 2013-2023 Snowplow Analytics Ltd. +The Snowplow iOS/macOS/tvOS/watchOS Tracker is copyright 2013-present Snowplow Analytics Ltd. Licensed under the **[Apache License, Version 2.0][license]** (the "License"); you may not use this software except in compliance with the License. diff --git a/SnowplowTracker.podspec b/SnowplowTracker.podspec index 1d6f8435e..6703f926d 100644 --- a/SnowplowTracker.podspec +++ b/SnowplowTracker.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SnowplowTracker" - s.version = "5.6.0" + s.version = "6.0.0" s.summary = "Snowplow event tracker for iOS, macOS, tvOS, watchOS for apps and games." s.description = <<-DESC Snowplow is a mobile and event analytics platform with a difference: rather than tell our users how they should analyze their data, we deliver their event-level data in their own data warehouse, on their own Amazon Redshift or Postgres database, so they can analyze it any way they choose. Snowplow mobile is used by data-savvy games companies and app developers to better understand their users and how they engage with their games and applications. Snowplow is open source using the business-friendly Apache License, Version 2.0 and scales horizontally to many billions of events. @@ -24,8 +24,11 @@ Pod::Spec.new do |s| s.ios.frameworks = 'CoreTelephony', 'UIKit', 'Foundation' s.osx.frameworks = 'AppKit', 'Foundation' s.tvos.frameworks = 'UIKit', 'Foundation' + + if s.respond_to?(:visionos) + s.visionos.deployment_target = '1.0' + s.visionos.frameworks = 'UIKit', 'Foundation' + end s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" } - - s.dependency 'FMDB', '~> 2.7' end diff --git a/Sources/Core/Emitter/Emitter.swift b/Sources/Core/Emitter/Emitter.swift index 70e6d0add..c3e0218db 100644 --- a/Sources/Core/Emitter/Emitter.swift +++ b/Sources/Core/Emitter/Emitter.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,107 +16,79 @@ import Foundation /// This class sends events to the collector. let POST_WRAPPER_BYTES = 88 -class Emitter: NSObject, EmitterEventProcessing { +class Emitter: EmitterEventProcessing { - private var timer: Timer? - private var dataOperationQueue: OperationQueue = OperationQueue() - private var builderFinished = false + private var timer: InternalQueueTimer? + + private var pausedEmit = false + + /// Custom NetworkConnection instance to handle connection outside the emitter. + private let networkConnection: NetworkConnection + + /// Tracker namespace – required by SQLiteEventStore to name the database + let namespace: String + + let eventStore: EventStore - private var sendingCheck = SendingCheck() /// Whether the emitter is currently sending. - var isSending: Bool { return sendingCheck.sending } + var isSending: Bool = false - private var _urlEndpoint: String? /// Collector endpoint. var urlEndpoint: String? { get { - if builderFinished { - return networkConnection?.urlEndpoint?.absoluteString + if let networkConnection = networkConnection as? DefaultNetworkConnection { + return networkConnection.urlEndpoint?.absoluteString } - return _urlEndpoint + return nil } set { - _urlEndpoint = newValue - if builderFinished { - setupNetworkConnection() + if let urlString = newValue, + let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.urlString = urlString } } } - - private var _namespace: String? - var namespace: String? { + + /// Security of requests - ProtocolHttp or ProtocolHttps. + var `protocol`: ProtocolOptions { get { - return _namespace + if let networkConnection = networkConnection as? DefaultNetworkConnection { + return networkConnection.protocol + } + return EmitterDefaults.httpProtocol } - set(namespace) { - _namespace = namespace - if builderFinished && eventStore == nil { - #if os(tvOS) || os(watchOS) - eventStore = MemoryEventStore() - #else - eventStore = SQLiteEventStore(namespace: _namespace) - #endif + set { + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.protocol = newValue } } } - - private var _method: HttpMethodOptions = EmitterDefaults.httpMethod + /// Chosen HTTP method - .get or .post. var method: HttpMethodOptions { get { - return _method - } - set(method) { - _method = method - if builderFinished && networkConnection != nil { - setupNetworkConnection() + if let networkConnection = networkConnection as? DefaultNetworkConnection { + return networkConnection.httpMethod } + return EmitterDefaults.httpMethod } - } - - private var _protocol: ProtocolOptions = EmitterDefaults.httpProtocol - /// Security of requests - ProtocolHttp or ProtocolHttps. - var `protocol`: ProtocolOptions { - get { - return _protocol - } - set(`protocol`) { - _protocol = `protocol` - if builderFinished && networkConnection != nil { - setupNetworkConnection() + set(method) { + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.httpMethod = method } } } - private var _bufferOption: BufferOption = EmitterDefaults.bufferOption + /// Buffer option - var bufferOption: BufferOption { - get { - return _bufferOption - } - set(bufferOption) { - if !isSending { - _bufferOption = bufferOption - } - } - } + var bufferOption: BufferOption = EmitterDefaults.bufferOption - private weak var _callback: RequestCallback? /// Callbacks supplied with number of failures and successes of sent events. - var callback: RequestCallback? { - get { - return _callback - } - set(callback) { - _callback = callback - } - } + weak var callback: RequestCallback? private var _emitRange = EmitterDefaults.emitRange /// Number of events retrieved from the database when needed. var emitRange: Int { - get { - return _emitRange - } + get { return _emitRange } set(emitRange) { if emitRange > 0 { _emitRange = emitRange @@ -124,35 +96,31 @@ class Emitter: NSObject, EmitterEventProcessing { } } - private var _emitThreadPoolSize = EmitterDefaults.emitThreadPoolSize /// Number of threads used for emitting events. var emitThreadPoolSize: Int { get { - return _emitThreadPoolSize + if let networkConnection = networkConnection as? DefaultNetworkConnection { + return networkConnection.emitThreadPoolSize + } + return EmitterDefaults.emitThreadPoolSize } set(emitThreadPoolSize) { if emitThreadPoolSize > 0 { - _emitThreadPoolSize = emitThreadPoolSize - if dataOperationQueue.maxConcurrentOperationCount != emitThreadPoolSize { - dataOperationQueue.maxConcurrentOperationCount = _emitThreadPoolSize - } - if builderFinished && networkConnection != nil { - setupNetworkConnection() + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.emitThreadPoolSize = emitThreadPoolSize } } } } - private var _byteLimitGet = EmitterDefaults.byteLimitGet /// Byte limit for GET requests. + private var _byteLimitGet = EmitterDefaults.byteLimitGet var byteLimitGet: Int { - get { - return _byteLimitGet - } + get { return _byteLimitGet } set(byteLimitGet) { _byteLimitGet = byteLimitGet - if builderFinished && networkConnection != nil { - setupNetworkConnection() + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.byteLimitGet = byteLimitGet } } } @@ -160,81 +128,56 @@ class Emitter: NSObject, EmitterEventProcessing { private var _byteLimitPost = EmitterDefaults.byteLimitPost /// Byte limit for POST requests. var byteLimitPost: Int { - get { - return _byteLimitPost - } + get { return _byteLimitPost } set(byteLimitPost) { _byteLimitPost = byteLimitPost - if builderFinished && networkConnection != nil { - setupNetworkConnection() + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.byteLimitPost = byteLimitPost } } } - private var _serverAnonymisation = EmitterDefaults.serverAnonymisation /// Whether to anonymise server-side user identifiers including the `network_userid` and `user_ipaddress`. var serverAnonymisation: Bool { get { - return _serverAnonymisation + if let networkConnection = networkConnection as? DefaultNetworkConnection { + return networkConnection.serverAnonymisation + } + return EmitterDefaults.serverAnonymisation } set(serverAnonymisation) { - _serverAnonymisation = serverAnonymisation - if builderFinished && networkConnection != nil { - setupNetworkConnection() + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.serverAnonymisation = serverAnonymisation } } } - private var _customPostPath: String? /// Custom endpoint path for POST requests. var customPostPath: String? { get { - return _customPostPath + if let networkConnection = networkConnection as? DefaultNetworkConnection { + return networkConnection.customPostPath + } + return nil } set(customPath) { - _customPostPath = customPath - if builderFinished && networkConnection != nil { - setupNetworkConnection() + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.customPostPath = customPath } } } /// Custom header requests. - private var _requestHeaders: [String : String]? var requestHeaders: [String : String]? { get { - return _requestHeaders - } - set(requestHeaders) { - _requestHeaders = requestHeaders - if builderFinished && networkConnection != nil { - setupNetworkConnection() + if let networkConnection = networkConnection as? DefaultNetworkConnection { + return networkConnection.requestHeaders } + return nil } - } - - private var _networkConnection: NetworkConnection? - /// Custom NetworkConnection istance to handle connection outside the emitter. - var networkConnection: NetworkConnection? { - get { - return _networkConnection - } - set(networkConnection) { - _networkConnection = networkConnection - if builderFinished && _networkConnection != nil { - setupNetworkConnection() - } - } - } - - private var _eventStore: EventStore? - var eventStore: EventStore? { - get { - return _eventStore - } - set(eventStore) { - if !builderFinished || self.eventStore == nil || self.eventStore?.count() == 0 { - _eventStore = eventStore + set(requestHeaders) { + if let networkConnection = networkConnection as? DefaultNetworkConnection { + networkConnection.requestHeaders = requestHeaders } } } @@ -242,12 +185,8 @@ class Emitter: NSObject, EmitterEventProcessing { /// Custom retry rules for HTTP status codes. private var _customRetryForStatusCodes: [Int : Bool] = [:] var customRetryForStatusCodes: [Int : Bool]? { - get { - return _customRetryForStatusCodes - } - set(customRetryForStatusCodes) { - _customRetryForStatusCodes = customRetryForStatusCodes ?? [:] - } + get { return _customRetryForStatusCodes } + set { _customRetryForStatusCodes = newValue ?? [:] } } /// Whether retrying failed requests is allowed @@ -255,73 +194,75 @@ class Emitter: NSObject, EmitterEventProcessing { /// Returns the number of events in the DB. var dbCount: Int { - return Int(eventStore?.count() ?? 0) + return Int(eventStore.count()) } + /// Limit for the maximum number of unsent events to keep in the event store. + var maxEventStoreSize: Int64 = EmitterDefaults.maxEventStoreSize + + /// Limit for the maximum duration of how long events should be kept in the event store if they fail to be sent. + var maxEventStoreAge: TimeInterval = EmitterDefaults.maxEventStoreAge + // MARK: - Initialization - init(urlEndpoint: String, - builder: ((Emitter) -> (Void))) { - super.init() - self._urlEndpoint = urlEndpoint + init(namespace: String, + urlEndpoint: String, + method: HttpMethodOptions? = nil, + protocol: ProtocolOptions? = nil, + customPostPath: String? = nil, + requestHeaders: [String: String]? = nil, + serverAnonymisation: Bool? = nil, + eventStore: EventStore? = nil, + timeout: TimeInterval = EmitterDefaults.emitTimeout, + builder: ((Emitter) -> (Void))? = nil) { + self.namespace = namespace + self.eventStore = eventStore ?? Emitter.defaultEventStore(namespace: namespace) + + let defaultNetworkConnection = DefaultNetworkConnection( + urlString: urlEndpoint, + httpMethod: method ?? EmitterDefaults.httpMethod, + customPostPath: customPostPath, + timeout: timeout + ) + defaultNetworkConnection.requestHeaders = requestHeaders + defaultNetworkConnection.serverAnonymisation = serverAnonymisation ?? EmitterDefaults.serverAnonymisation + networkConnection = defaultNetworkConnection - builder(self) - setup() - } + builder?(self) + resumeTimer() + } init(networkConnection: NetworkConnection, - builder: ((Emitter) -> (Void))) { - super.init() - self._networkConnection = networkConnection + namespace: String, + eventStore: EventStore? = nil, + builder: ((Emitter) -> (Void))? = nil) { + self.networkConnection = networkConnection + self.namespace = namespace + self.eventStore = eventStore ?? Emitter.defaultEventStore(namespace: namespace) - builder(self) - setup() - } - - private func setup() { - dataOperationQueue.maxConcurrentOperationCount = emitThreadPoolSize - setupNetworkConnection() + builder?(self) resumeTimer() - builderFinished = true } - - private func setupNetworkConnection() { - if !builderFinished && networkConnection != nil { - return - } - if let url = _urlEndpoint { - var endpoint = "\(url)" - if !endpoint.hasPrefix("http") { - let `protocol` = self.protocol == .https ? "https://" : "http://" - endpoint = `protocol` + endpoint - } - let defaultNetworkConnection = DefaultNetworkConnection( - urlString: endpoint, - httpMethod: method, - customPostPath: customPostPath - ) - defaultNetworkConnection.requestHeaders = requestHeaders - defaultNetworkConnection.emitThreadPoolSize = emitThreadPoolSize - defaultNetworkConnection.byteLimitGet = byteLimitGet - defaultNetworkConnection.byteLimitPost = byteLimitPost - defaultNetworkConnection.serverAnonymisation = serverAnonymisation - _networkConnection = defaultNetworkConnection - } + + deinit { + pauseTimer() + } + + private static func defaultEventStore(namespace: String) -> EventStore { +#if os(tvOS) || os(watchOS) + return MemoryEventStore() +#else + return SQLiteEventStore(namespace: namespace) +#endif } // MARK: - Pause/Resume methods func resumeTimer() { - weak var weakSelf = self - - if timer != nil { - pauseTimer() - } + pauseTimer() - DispatchQueue.main.async { - weakSelf?.timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(kSPDefaultBufferTimeout), repeats: true) { [weak self] timer in - self?.flush() - } + self.timer = InternalQueue.startTimer(TimeInterval(kSPDefaultBufferTimeout)) { [weak self] in + self?.flush() } } @@ -333,177 +274,171 @@ class Emitter: NSObject, EmitterEventProcessing { /// Allows sending events to collector. func resumeEmit() { - sendingCheck.pausedEmit = false + pausedEmit = false flush() } /// Suspends sending events to collector. func pauseEmit() { - sendingCheck.pausedEmit = true + pausedEmit = true } /// Insert a Payload object into the buffer to be sent to collector. - /// This method will add the payload to the database and flush (send all events). + /// This method will add the payload to the database and flush (send all events) when the buffer is full. /// - Parameter eventPayload: A Payload containing a completed event to be added into the buffer. func addPayload(toBuffer eventPayload: Payload) { - DispatchQueue.global(qos: .default).async { [weak self] in - self?.eventStore?.addEvent(eventPayload) - self?.flush() + self.eventStore.addEvent(eventPayload) + + if self.eventStore.count() >= self.bufferOption.rawValue { + self.flush() } } /// Empties the buffer of events using the respective HTTP request method. func flush() { - if Thread.isMainThread { - DispatchQueue.global(qos: .default).async { [self] in - sendGuard() - } - } else { - sendGuard() + if requestToStartSending() { + self.removeOldEvents() + self.attemptEmit() } } // MARK: - Control methods - - private func sendGuard() { - if sendingCheck.requestToStartSending() { - objc_sync_enter(self) - attemptEmit() - objc_sync_exit(self) - sendingCheck.sending = false - } - } + private func removeOldEvents() { + eventStore.removeOldEvents( + maxSize: maxEventStoreSize, + maxAge: maxEventStoreAge + ) + } + private func attemptEmit() { - guard let eventStore = eventStore else { return } - if eventStore.count() == 0 { + InternalQueue.onQueuePrecondition() + + let events = eventStore.emittableEvents(withQueryLimit: UInt(emitRange)) + if events.isEmpty { logDebug(message: "Database empty. Returning.") + stopSending() return } - - let events = eventStore.emittableEvents(withQueryLimit: UInt(emitRange)) + let requests = buildRequests(fromEvents: events) - let sendResults = networkConnection?.sendRequests(requests) - - logVerbose(message: "Processing emitter results.") - - var successCount = 0 - var failedWillRetryCount = 0 - var failedWontRetryCount = 0 - var removableEvents: [Int64] = [] - - for result in sendResults ?? [] { - let resultIndexArray = result.storeIds - if result.isSuccessful { - successCount += resultIndexArray?.count ?? 0 - if let array = resultIndexArray { - removableEvents.append(contentsOf: array) + + let processResults: ([RequestResult]) -> Void = { sendResults in + logVerbose(message: "Processing emitter results.") + + var successCount = 0 + var failedWillRetryCount = 0 + var failedWontRetryCount = 0 + var removableEvents: [Int64] = [] + + for result in sendResults { + let resultIndexArray = result.storeIds + if result.isSuccessful { + successCount += resultIndexArray?.count ?? 0 + if let array = resultIndexArray { + removableEvents.append(contentsOf: array) + } + } else if result.shouldRetry(self.customRetryForStatusCodes, retryAllowed: self.retryFailedRequests) { + failedWillRetryCount += resultIndexArray?.count ?? 0 + } else { + failedWontRetryCount += resultIndexArray?.count ?? 0 + if let array = resultIndexArray { + removableEvents.append(contentsOf: array) + } + logError(message: String(format: "Sending events to Collector failed with status %ld. Events will be dropped.", result.statusCode ?? -1)) } - } else if result.shouldRetry(customRetryForStatusCodes, retryAllowed: retryFailedRequests) { - failedWillRetryCount += resultIndexArray?.count ?? 0 - } else { - failedWontRetryCount += resultIndexArray?.count ?? 0 - if let array = resultIndexArray { - removableEvents.append(contentsOf: array) + } + let allFailureCount = failedWillRetryCount + failedWontRetryCount + + _ = self.eventStore.removeEvents(withIds: removableEvents) + + logDebug(message: String(format: "Success Count: %d", successCount)) + logDebug(message: String(format: "Failure Count: %d", allFailureCount)) + + if let callback = self.callback { + if allFailureCount == 0 { + callback.onSuccess(withCount: successCount) + } else { + callback.onFailure(withCount: allFailureCount, successCount: successCount) } - logError(message: String(format: "Sending events to Collector failed with status %ld. Events will be dropped.", result.statusCode ?? -1)) } - } - let allFailureCount = failedWillRetryCount + failedWontRetryCount - - let _ = eventStore.removeEvents(withIds: removableEvents) - - logDebug(message: String(format: "Success Count: %d", successCount)) - logDebug(message: String(format: "Failure Count: %d", allFailureCount)) - - if callback != nil { - if allFailureCount == 0 { - callback?.onSuccess(withCount: successCount) + + if failedWillRetryCount > 0 && successCount == 0 { + logDebug(message: "Ending emitter run as all requests failed.") + + self.scheduleStopSending() } else { - callback?.onFailure(withCount: allFailureCount, successCount: successCount) + self.attemptEmit() } } - - if failedWillRetryCount > 0 && successCount == 0 { - logDebug(message: "Ending emitter run as all requests failed.") - Thread.sleep(forTimeInterval: 5) - return - } else { - self.attemptEmit() + + emitAsync { + let sendResults = self.networkConnection.sendRequests(requests) + + InternalQueue.async { + processResults(sendResults) + } } } private func buildRequests(fromEvents events: [EmitterEvent]) -> [Request] { var requests: [Request] = [] - guard let networkConnection = networkConnection else { return requests } let sendingTime = Utilities.getTimestamp() - let httpMethod = networkConnection.httpMethod + let byteLimit = method == .get ? byteLimitGet : byteLimitPost - if httpMethod == .get { + if method == .get { for event in events { let payload = event.payload addSendingTime(to: payload, timestamp: sendingTime) - let oversize = isOversize(payload) + let oversize = isOversize(payload, byteLimit: byteLimit) let request = Request(payload: payload, emitterEventId: event.storeId, oversize: oversize) requests.append(request) } } else { - var i = 0 - while i < events.count { - var eventArray: [Payload] = [] - var indexArray: [Int64] = [] - - let iUntil = min(i + bufferOption.rawValue, events.count) - for j in i.. separate requests + if isOversize(payload, byteLimit: byteLimit) { + let request = Request(payload: payload, emitterEventId: emitterEventId, oversize: true) + requests.append(request) } - - // Check if all payloads have been processed - if eventArray.count != 0 { - let request = Request(payloads: eventArray, emitterEventIds: indexArray) + // Events up to this one are oversize -> create request for them + else if isOversize(payload, byteLimit: byteLimit, previousPayloads: eventPayloads) { + let request = Request(payloads: eventPayloads, emitterEventIds: eventIds) requests.append(request) + + // Clear collection and build a new POST + eventPayloads = [] + eventIds = [] + + // Build and store the request + eventPayloads.append(payload) + eventIds.append(emitterEventId) + } + // Add to the list of events for the request + else { + eventPayloads.append(payload) + eventIds.append(emitterEventId) } - i += bufferOption.rawValue + } + + // Check if there are any remaining events not in a request + if !eventPayloads.isEmpty { + let request = Request(payloads: eventPayloads, emitterEventIds: eventIds) + requests.append(request) } } return requests } - private func isOversize(_ payload: Payload) -> Bool { - return isOversize(payload, previousPayloads: []) - } - - private func isOversize(_ payload: Payload, previousPayloads: [Payload]) -> Bool { - let byteLimit = networkConnection?.httpMethod == .get ? byteLimitGet : byteLimitPost - return isOversize(payload, byteLimit: byteLimit, previousPayloads: previousPayloads) - } - - private func isOversize(_ payload: Payload, byteLimit: Int, previousPayloads: [Payload]) -> Bool { + private func isOversize(_ payload: Payload, byteLimit: Int, previousPayloads: [Payload] = []) -> Bool { var totalByteSize = payload.byteSize for previousPayload in previousPayloads { totalByteSize += previousPayload.byteSize @@ -512,50 +447,34 @@ class Emitter: NSObject, EmitterEventProcessing { return totalByteSize + wrapperBytes > byteLimit } - func addSendingTime(to payload: Payload, timestamp: NSNumber) { + private func addSendingTime(to payload: Payload, timestamp: NSNumber) { payload.addValueToPayload(String(format: "%lld", timestamp.int64Value), forKey: kSPSentTimestamp) } - - deinit { - pauseTimer() - } -} - -fileprivate class SendingCheck { - private var _sending = false - var sending: Bool { - get { - return lock { return _sending } - } - set { - lock { _sending = newValue } + + private func requestToStartSending() -> Bool { + if !isSending && !pausedEmit { + isSending = true + return true + } else { + return false } } - private var _pausedEmit = false - var pausedEmit: Bool { - get { - return lock { return _pausedEmit } - } - set { - lock { _pausedEmit = newValue } + private func scheduleStopSending() { + InternalQueue.asyncAfter(TimeInterval(5)) { [weak self] in + self?.stopSending() } } - func requestToStartSending() -> Bool { - return lock { - if !_sending && !_pausedEmit { - _sending = true - return true - } else { - return false - } - } + private func stopSending() { + isSending = false } - private func lock(closure: () -> T) -> T { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - return closure() + // MARK: - dispatch queues + + private let emitQueue = DispatchQueue(label: "snowplow.emitter") + + private func emitAsync(_ callback: @escaping () -> Void) { + emitQueue.async(execute: callback) } } diff --git a/Sources/Core/Emitter/EmitterControllerImpl.swift b/Sources/Core/Emitter/EmitterControllerImpl.swift index 58b9baf56..da9414065 100644 --- a/Sources/Core/Emitter/EmitterControllerImpl.swift +++ b/Sources/Core/Emitter/EmitterControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -115,6 +115,26 @@ class EmitterControllerImpl: Controller, EmitterController { emitter.retryFailedRequests = newValue } } + + var maxEventStoreSize: Int64 { + get { return emitter.maxEventStoreSize } + set { + dirtyConfig.maxEventStoreSize = newValue + emitter.maxEventStoreSize = newValue + } + } + + var maxEventStoreAge: TimeInterval { + get { return emitter.maxEventStoreAge } + set { + dirtyConfig.maxEventStoreAge = newValue + emitter.maxEventStoreAge = newValue + } + } + + var eventStore: EventStore { + return emitter.eventStore + } // MARK: - Methods diff --git a/Sources/Core/Emitter/EmitterEventProcessing.swift b/Sources/Core/Emitter/EmitterEventProcessing.swift index c980f696d..a09a4d0b0 100644 --- a/Sources/Core/Emitter/EmitterEventProcessing.swift +++ b/Sources/Core/Emitter/EmitterEventProcessing.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Events/ScreenEnd.swift b/Sources/Core/Events/ScreenEnd.swift new file mode 100644 index 000000000..efcde1cf8 --- /dev/null +++ b/Sources/Core/Events/ScreenEnd.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2013-present 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/GDPR/GDPRContext.swift b/Sources/Core/GDPR/GDPRContext.swift index a65f5939b..61f086ad7 100644 --- a/Sources/Core/GDPR/GDPRContext.swift +++ b/Sources/Core/GDPR/GDPRContext.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/GDPR/GDPRControllerImpl.swift b/Sources/Core/GDPR/GDPRControllerImpl.swift index 47f6b8cc3..50b2ff909 100644 --- a/Sources/Core/GDPR/GDPRControllerImpl.swift +++ b/Sources/Core/GDPR/GDPRControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/GlobalContexts/GlobalContextPluginConfiguration.swift b/Sources/Core/GlobalContexts/GlobalContextPluginConfiguration.swift index 86c3c4b07..166ebf811 100644 --- a/Sources/Core/GlobalContexts/GlobalContextPluginConfiguration.swift +++ b/Sources/Core/GlobalContexts/GlobalContextPluginConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/GlobalContexts/GlobalContextsControllerImpl.swift b/Sources/Core/GlobalContexts/GlobalContextsControllerImpl.swift index bf60a991f..9986ac787 100644 --- a/Sources/Core/GlobalContexts/GlobalContextsControllerImpl.swift +++ b/Sources/Core/GlobalContexts/GlobalContextsControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/GlobalContexts/SchemaRule.swift b/Sources/Core/GlobalContexts/SchemaRule.swift index 286520ddd..31575665d 100644 --- a/Sources/Core/GlobalContexts/SchemaRule.swift +++ b/Sources/Core/GlobalContexts/SchemaRule.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/InternalQueue/EcommerceControllerIQWrapper.swift b/Sources/Core/InternalQueue/EcommerceControllerIQWrapper.swift new file mode 100644 index 000000000..6a7ac0604 --- /dev/null +++ b/Sources/Core/InternalQueue/EcommerceControllerIQWrapper.swift @@ -0,0 +1,39 @@ +// Copyright (c) 2013-present 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 EcommerceControllerIQWrapper: EcommerceController { + + private let controller: EcommerceController + + init(controller: EcommerceController) { + self.controller = controller + } + + func setEcommerceScreen(_ screen: EcommerceScreenEntity) { + InternalQueue.sync { controller.setEcommerceScreen(screen) } + } + + func setEcommerceUser(_ user: EcommerceUserEntity) { + InternalQueue.sync { controller.setEcommerceUser(user) } + } + + func removeEcommerceScreen() { + InternalQueue.sync { controller.removeEcommerceScreen() } + } + + func removeEcommerceUser() { + InternalQueue.sync { controller.removeEcommerceUser() } + } +} diff --git a/Sources/Core/InternalQueue/EmitterControllerIQWrapper.swift b/Sources/Core/InternalQueue/EmitterControllerIQWrapper.swift new file mode 100644 index 000000000..06af6ca03 --- /dev/null +++ b/Sources/Core/InternalQueue/EmitterControllerIQWrapper.swift @@ -0,0 +1,107 @@ +// Copyright (c) 2013-present 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 EmitterControllerIQWrapper: EmitterController { + + private let controller: EmitterController + + init(controller: EmitterController) { + self.controller = controller + } + + // MARK: - Properties + + var bufferOption: BufferOption { + get { return InternalQueue.sync { controller.bufferOption } } + set { InternalQueue.sync { controller.bufferOption = newValue } } + } + + var byteLimitGet: Int { + get { return InternalQueue.sync { controller.byteLimitGet } } + set { InternalQueue.sync { controller.byteLimitGet = newValue } } + } + + var byteLimitPost: Int { + get { return InternalQueue.sync { controller.byteLimitPost } } + set { InternalQueue.sync { controller.byteLimitPost = newValue } } + } + + var serverAnonymisation: Bool { + get { return InternalQueue.sync { controller.serverAnonymisation } } + set { InternalQueue.sync { controller.serverAnonymisation = newValue } } + } + + var emitRange: Int { + get { return InternalQueue.sync { controller.emitRange } } + set { InternalQueue.sync { controller.emitRange = newValue } } + } + + var threadPoolSize: Int { + get { return InternalQueue.sync { controller.threadPoolSize } } + set { InternalQueue.sync { controller.threadPoolSize = newValue } } + } + + var requestCallback: RequestCallback? { + get { return InternalQueue.sync { controller.requestCallback } } + set { InternalQueue.sync { controller.requestCallback = newValue } } + } + + var dbCount: Int { + return InternalQueue.sync { controller.dbCount } + } + + var isSending: Bool { + return InternalQueue.sync { controller.isSending } + } + + var customRetryForStatusCodes: [Int : Bool]? { + get { return InternalQueue.sync { controller.customRetryForStatusCodes } } + set { InternalQueue.sync { controller.customRetryForStatusCodes = newValue } } + } + + var retryFailedRequests: Bool { + get { return InternalQueue.sync { controller.retryFailedRequests } } + set { InternalQueue.sync { controller.retryFailedRequests = newValue } } + } + + var maxEventStoreSize: Int64 { + get { return InternalQueue.sync { controller.maxEventStoreSize } } + set { InternalQueue.sync { controller.maxEventStoreSize = newValue } } + } + + var maxEventStoreAge: TimeInterval { + get { return InternalQueue.sync { controller.maxEventStoreAge } } + set { InternalQueue.sync { controller.maxEventStoreAge = newValue } } + } + + var eventStore: EventStore { + get { return InternalQueue.sync { EventStoreIQWrapper(eventStore: controller.eventStore) } } + } + + // MARK: - Methods + + func flush() { + InternalQueue.sync { controller.flush() } + } + + func pause() { + InternalQueue.sync { controller.pause() } + } + + func resume() { + InternalQueue.sync { controller.resume() } + } + +} diff --git a/Sources/Core/InternalQueue/EventStoreIQWrapper.swift b/Sources/Core/InternalQueue/EventStoreIQWrapper.swift new file mode 100644 index 000000000..5a5204e37 --- /dev/null +++ b/Sources/Core/InternalQueue/EventStoreIQWrapper.swift @@ -0,0 +1,52 @@ +// Copyright (c) 2013-present 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 EventStoreIQWrapper: NSObject, EventStore { + + private let eventStore: EventStore + + init(eventStore: EventStore) { + self.eventStore = eventStore + } + + func addEvent(_ payload: Payload) { + InternalQueue.sync { eventStore.addEvent(payload) } + } + + func removeEvent(withId storeId: Int64) -> Bool { + return InternalQueue.sync { eventStore.removeEvent(withId: storeId) } + } + + func removeEvents(withIds storeIds: [Int64]) -> Bool { + return InternalQueue.sync { eventStore.removeEvents(withIds: storeIds) } + } + + func removeAllEvents() -> Bool { + return InternalQueue.sync { eventStore.removeAllEvents() } + } + + func count() -> UInt { + return InternalQueue.sync { eventStore.count() } + } + + func emittableEvents(withQueryLimit queryLimit: UInt) -> [EmitterEvent] { + return InternalQueue.sync { eventStore.emittableEvents(withQueryLimit: queryLimit) } + } + + func removeOldEvents(maxSize: Int64, maxAge: TimeInterval) { + return InternalQueue.sync { eventStore.removeOldEvents(maxSize: maxSize, maxAge: maxAge) } + } + +} diff --git a/Sources/Core/InternalQueue/GDPRControllerIQWrapper.swift b/Sources/Core/InternalQueue/GDPRControllerIQWrapper.swift new file mode 100644 index 000000000..8301c4f81 --- /dev/null +++ b/Sources/Core/InternalQueue/GDPRControllerIQWrapper.swift @@ -0,0 +1,60 @@ +// Copyright (c) 2013-present 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 GDPRControllerIQWrapper: GDPRController { + + private let controller: GDPRController + + init(controller: GDPRController) { + self.controller = controller + } + + // MARK: - Methods + + func reset(basis: GDPRProcessingBasis, documentId: String?, documentVersion: String?, documentDescription: String?) { + InternalQueue.sync { + controller.reset(basis: basis, documentId: documentId, documentVersion: documentVersion, documentDescription: documentDescription) + } + } + + func disable() { + InternalQueue.sync { controller.disable() } + } + + var isEnabled: Bool { + return InternalQueue.sync { controller.isEnabled } + } + + func enable() -> Bool { + InternalQueue.sync { controller.enable() } + } + + var basisForProcessing: GDPRProcessingBasis { + InternalQueue.sync { controller.basisForProcessing } + } + + var documentId: String? { + InternalQueue.sync { controller.documentId } + } + + var documentVersion: String? { + InternalQueue.sync { controller.documentVersion } + } + + var documentDescription: String? { + InternalQueue.sync { controller.documentDescription } + } + +} diff --git a/Sources/Core/InternalQueue/GlobalContextsControllerIQWrapper.swift b/Sources/Core/InternalQueue/GlobalContextsControllerIQWrapper.swift new file mode 100644 index 000000000..0ed4ed609 --- /dev/null +++ b/Sources/Core/InternalQueue/GlobalContextsControllerIQWrapper.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2013-present 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 GlobalContextsControllerIQWrapper: GlobalContextsController { + + private let controller: GlobalContextsController + + init(controller: GlobalContextsController) { + self.controller = controller + } + + var contextGenerators: [String : GlobalContext] { + get { InternalQueue.sync { controller.contextGenerators } } + set { InternalQueue.sync { controller.contextGenerators = newValue } } + } + + func add(tag: String, contextGenerator generator: GlobalContext) -> Bool { + return InternalQueue.sync { controller.add(tag: tag, contextGenerator: generator) } + } + + func remove(tag: String) -> GlobalContext? { + return InternalQueue.sync { controller.remove(tag: tag) } + } + + var tags: [String] { + return InternalQueue.sync { controller.tags } + } + +} diff --git a/Sources/Core/InternalQueue/InternalQueue.swift b/Sources/Core/InternalQueue/InternalQueue.swift new file mode 100644 index 000000000..8265c97e6 --- /dev/null +++ b/Sources/Core/InternalQueue/InternalQueue.swift @@ -0,0 +1,56 @@ +// Copyright (c) 2013-present 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 InternalQueue { + static func sync(_ callback: () -> T) -> T { + dispatchPrecondition(condition: .notOnQueue(serialQueue)) + + return serialQueue.sync(execute: callback) + } + + static func async(_ callback: @escaping () -> Void) { + serialQueue.async(execute: callback) + } + + static func asyncAfter(_ interval: TimeInterval, _ callback: @escaping () -> Void) { + serialQueue.asyncAfter(deadline: .now() + interval, execute: callback) + } + + static func startTimer(_ interval: TimeInterval, _ callback: @escaping () -> Void) -> InternalQueueTimer { + let timer = InternalQueueTimer() + + asyncAfter(interval) { + timerFired(timer: timer, interval: interval, callback: callback) + } + + return timer + } + + static private func timerFired(timer: InternalQueueTimer, interval: TimeInterval, callback: @escaping () -> Void) { + if timer.active { + asyncAfter(interval) { + timerFired(timer: timer, interval: interval, callback: callback) + } + + callback() + } + } + + static func onQueuePrecondition() { + dispatchPrecondition(condition: .onQueue(serialQueue)) + } + + private static let serialQueue = DispatchQueue(label: "snowplow") +} diff --git a/Sources/Core/InternalQueue/InternalQueueTimer.swift b/Sources/Core/InternalQueue/InternalQueueTimer.swift new file mode 100644 index 000000000..dc8356f1f --- /dev/null +++ b/Sources/Core/InternalQueue/InternalQueueTimer.swift @@ -0,0 +1,22 @@ +// Copyright (c) 2013-present 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 InternalQueueTimer { + var active = true + + func invalidate() { + active = false + } +} diff --git a/Sources/Core/InternalQueue/MediaControllerIQWrapper.swift b/Sources/Core/InternalQueue/MediaControllerIQWrapper.swift new file mode 100644 index 000000000..541bcaf22 --- /dev/null +++ b/Sources/Core/InternalQueue/MediaControllerIQWrapper.swift @@ -0,0 +1,59 @@ +// Copyright (c) 2013-present 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 +#if !os(watchOS) +import AVKit +#endif + +class MediaControllerIQWrapper: MediaController { + + private let controller: MediaController + + init(controller: MediaController) { + self.controller = controller + } + + func startMediaTracking(id: String) -> MediaTracking { + return InternalQueue.sync { + MediaTrackingIQWrapper(tracking: controller.startMediaTracking(id: id)) + } + } + + func startMediaTracking(id: String, player: MediaPlayerEntity? = nil) -> MediaTracking { + return InternalQueue.sync { + MediaTrackingIQWrapper(tracking: controller.startMediaTracking(id: id, player: player)) + } + } + + func startMediaTracking(configuration: MediaTrackingConfiguration) -> MediaTracking { + return InternalQueue.sync { + MediaTrackingIQWrapper(tracking: controller.startMediaTracking(configuration: configuration)) + } + } + +#if !os(watchOS) + func startMediaTracking(player: AVPlayer, + configuration: MediaTrackingConfiguration) -> MediaTracking { + return InternalQueue.sync { controller.startMediaTracking(player: player, configuration: configuration) } + } +#endif + + func mediaTracking(id: String) -> MediaTracking? { + return InternalQueue.sync { controller.mediaTracking(id: id) } + } + + func endMediaTracking(id: String) { + InternalQueue.sync { controller.endMediaTracking(id: id) } + } +} diff --git a/Sources/Core/InternalQueue/MediaTrackingIQWrapper.swift b/Sources/Core/InternalQueue/MediaTrackingIQWrapper.swift new file mode 100644 index 000000000..4993b04ec --- /dev/null +++ b/Sources/Core/InternalQueue/MediaTrackingIQWrapper.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-present 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 MediaTrackingIQWrapper: MediaTracking { + + private let tracking: MediaTracking + + init(tracking: MediaTracking) { + self.tracking = tracking + } + + var id: String { + return InternalQueue.sync { tracking.id } + } + + // MARK: Update methods overloads + + func update(player: MediaPlayerEntity?) { + return InternalQueue.sync { tracking.update(player: player) } + } + + func update(player: MediaPlayerEntity?, ad: MediaAdEntity?, adBreak: MediaAdBreakEntity?) { + return InternalQueue.sync { tracking.update(player: player, ad: ad, adBreak: adBreak) } + } + + // MARK: Track methods overloads + + func track(_ event: Event) { + InternalQueue.sync { tracking.track(event) } + } + + func track(_ event: Event, player: MediaPlayerEntity?) { + InternalQueue.sync { tracking.track(event, player: player) } + } + + func track(_ event: Event, ad: MediaAdEntity?) { + InternalQueue.sync { tracking.track(event, ad: ad) } + } + + func track(_ event: Event, player: MediaPlayerEntity?, ad: MediaAdEntity?) { + InternalQueue.sync { tracking.track(event, player: player, ad: ad) } + } + + func track(_ event: Event, adBreak: MediaAdBreakEntity?) { + InternalQueue.sync { tracking.track(event, adBreak: adBreak) } + } + + func track(_ event: Event, player: MediaPlayerEntity?, adBreak: MediaAdBreakEntity?) { + InternalQueue.sync { tracking.track(event, player: player, adBreak: adBreak) } + } + + func track(_ event: Event, player: MediaPlayerEntity?, ad: MediaAdEntity?, adBreak: MediaAdBreakEntity?) { + InternalQueue.sync { tracking.track(event, player: player, ad: ad, adBreak: adBreak) } + } + +} diff --git a/Sources/Core/InternalQueue/NetworkControllerIQWrapper.swift b/Sources/Core/InternalQueue/NetworkControllerIQWrapper.swift new file mode 100644 index 000000000..e5032f60d --- /dev/null +++ b/Sources/Core/InternalQueue/NetworkControllerIQWrapper.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2013-present 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 NetworkControllerIQWrapper: NetworkController { + + private let controller: NetworkController + + init(controller: NetworkController) { + self.controller = controller + } + + // MARK: - Properties + + var endpoint: String? { + get { return InternalQueue.sync { controller.endpoint } } + set { InternalQueue.sync { controller.endpoint = newValue } } + } + + var method: HttpMethodOptions { + get { return InternalQueue.sync { controller.method } } + set { InternalQueue.sync { controller.method = newValue } } + } + + var customPostPath: String? { + get { return InternalQueue.sync { controller.customPostPath } } + set { InternalQueue.sync { controller.customPostPath = newValue } } + } + + var requestHeaders: [String : String]? { + get { return InternalQueue.sync { controller.requestHeaders } } + set { InternalQueue.sync { controller.requestHeaders = newValue } } + } + +} diff --git a/Sources/Core/InternalQueue/PluginsControllerIQWrapper.swift b/Sources/Core/InternalQueue/PluginsControllerIQWrapper.swift new file mode 100644 index 000000000..dfce0ebfb --- /dev/null +++ b/Sources/Core/InternalQueue/PluginsControllerIQWrapper.swift @@ -0,0 +1,35 @@ +// Copyright (c) 2013-present 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 PluginsControllerIQWrapper: PluginsController { + + private let controller: PluginsController + + init(controller: PluginsController) { + self.controller = controller + } + + var identifiers: [String] { + return InternalQueue.sync { controller.identifiers } + } + + func add(plugin: PluginIdentifiable) { + InternalQueue.sync { controller.add(plugin: plugin) } + } + + func remove(identifier: String) { + InternalQueue.sync { controller.remove(identifier: identifier) } + } +} diff --git a/Sources/Core/InternalQueue/SessionControllerIQWrapper.swift b/Sources/Core/InternalQueue/SessionControllerIQWrapper.swift new file mode 100644 index 000000000..b4917e1da --- /dev/null +++ b/Sources/Core/InternalQueue/SessionControllerIQWrapper.swift @@ -0,0 +1,87 @@ +// Copyright (c) 2013-present 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 SessionControllerIQWrapper: SessionController { + + private let controller: SessionController + + init(controller: SessionController) { + self.controller = controller + } + + func pause() { + InternalQueue.sync { controller.pause() } + } + + func resume() { + InternalQueue.sync { controller.resume() } + } + + func startNewSession() { + InternalQueue.sync { controller.startNewSession() } + } + + // MARK: - Properties + + var foregroundTimeout: Measurement { + get { InternalQueue.sync { controller.foregroundTimeout } } + set { InternalQueue.sync { controller.foregroundTimeout = newValue } } + } + + var foregroundTimeoutInSeconds: Int { + get { InternalQueue.sync { controller.foregroundTimeoutInSeconds } } + set { InternalQueue.sync { controller.foregroundTimeoutInSeconds = newValue } } + } + + var backgroundTimeout: Measurement { + get { InternalQueue.sync { controller.backgroundTimeout } } + set { InternalQueue.sync { controller.backgroundTimeout = newValue } } + } + + var backgroundTimeoutInSeconds: Int { + get { InternalQueue.sync { controller.backgroundTimeoutInSeconds } } + set { InternalQueue.sync { controller.backgroundTimeoutInSeconds = newValue } } + } + + var onSessionStateUpdate: ((_ sessionState: SessionState) -> Void)? { + get { InternalQueue.sync { controller.onSessionStateUpdate } } + set { InternalQueue.sync { controller.onSessionStateUpdate = newValue } } + } + + var sessionIndex: Int { + InternalQueue.sync { controller.sessionIndex } + } + + var sessionId: String? { + InternalQueue.sync { controller.sessionId } + } + + var userId: String? { + InternalQueue.sync { controller.userId } + } + + var isInBackground: Bool { + InternalQueue.sync { controller.isInBackground } + } + + var backgroundIndex: Int { + InternalQueue.sync { controller.backgroundIndex } + } + + var foregroundIndex: Int { + InternalQueue.sync { controller.foregroundIndex } + } + +} diff --git a/Sources/Core/InternalQueue/SubjectControllerIQWrapper.swift b/Sources/Core/InternalQueue/SubjectControllerIQWrapper.swift new file mode 100644 index 000000000..75df4a202 --- /dev/null +++ b/Sources/Core/InternalQueue/SubjectControllerIQWrapper.swift @@ -0,0 +1,118 @@ +// Copyright (c) 2013-present 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 SubjectControllerIQWrapper: SubjectController { + + private let controller: SubjectController + + init(controller: SubjectController) { + self.controller = controller + } + + // MARK: - Properties + + var userId: String? { + get { return InternalQueue.sync { controller.userId } } + set { InternalQueue.sync { controller.userId = newValue } } + } + + var networkUserId: String? { + get { return InternalQueue.sync { controller.networkUserId } } + set { InternalQueue.sync { controller.networkUserId = newValue } } + } + + var domainUserId: String? { + get { return InternalQueue.sync { controller.domainUserId } } + set { InternalQueue.sync { controller.domainUserId = newValue } } + } + + var useragent: String? { + get { return InternalQueue.sync { controller.useragent } } + set { InternalQueue.sync { controller.useragent = newValue } } + } + + var ipAddress: String? { + get { return InternalQueue.sync { controller.ipAddress } } + set { InternalQueue.sync { controller.ipAddress = newValue } } + } + + var timezone: String? { + get { return InternalQueue.sync { controller.timezone } } + set { InternalQueue.sync { controller.timezone = newValue } } + } + + var language: String? { + get { return InternalQueue.sync { controller.language } } + set { InternalQueue.sync { controller.language = newValue } } + } + + var screenResolution: SPSize? { + get { return InternalQueue.sync { controller.screenResolution } } + set { InternalQueue.sync { controller.screenResolution = newValue } } + } + + var screenViewPort: SPSize? { + get { return InternalQueue.sync { controller.screenViewPort } } + set { InternalQueue.sync { controller.screenViewPort = newValue } } + } + + var colorDepth: NSNumber? { + get { return InternalQueue.sync { controller.colorDepth } } + set { InternalQueue.sync { controller.colorDepth = newValue } } + } + + // MARK: - GeoLocalization + + var geoLatitude: NSNumber? { + get { return InternalQueue.sync { controller.geoLatitude } } + set { InternalQueue.sync { controller.geoLatitude = newValue } } + } + + var geoLongitude: NSNumber? { + get { return InternalQueue.sync { controller.geoLongitude } } + set { InternalQueue.sync { controller.geoLongitude = newValue } } + } + + var geoLatitudeLongitudeAccuracy: NSNumber? { + get { return InternalQueue.sync { controller.geoLatitudeLongitudeAccuracy } } + set { InternalQueue.sync { controller.geoLatitudeLongitudeAccuracy = newValue } } + } + + var geoAltitude: NSNumber? { + get { return InternalQueue.sync { controller.geoAltitude } } + set { InternalQueue.sync { controller.geoAltitude = newValue } } + } + + var geoAltitudeAccuracy: NSNumber? { + get { return InternalQueue.sync { controller.geoAltitudeAccuracy } } + set { InternalQueue.sync { controller.geoAltitudeAccuracy = newValue } } + } + + var geoSpeed: NSNumber? { + get { return InternalQueue.sync { controller.geoSpeed } } + set { InternalQueue.sync { controller.geoSpeed = newValue } } + } + + var geoBearing: NSNumber? { + get { return InternalQueue.sync { controller.geoBearing } } + set { InternalQueue.sync { controller.geoBearing = newValue } } + } + + var geoTimestamp: NSNumber? { + get { return InternalQueue.sync { controller.geoTimestamp } } + set { InternalQueue.sync { controller.geoTimestamp = newValue } } + } + +} diff --git a/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift new file mode 100644 index 000000000..9ab205425 --- /dev/null +++ b/Sources/Core/InternalQueue/TrackerControllerIQWrapper.swift @@ -0,0 +1,251 @@ +// Copyright (c) 2013-present 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 TrackerControllerIQWrapper: TrackerController { + + private let controller: TrackerControllerImpl + + init(controller: TrackerControllerImpl) { + self.controller = controller + } + + // MARK: - Controllers + + var network: NetworkController? { + return InternalQueue.sync { + if let network = controller.network { + return NetworkControllerIQWrapper(controller: network) + } else { + return nil + } + } + } + + var emitter: EmitterController? { + return InternalQueue.sync { + if let emitter = controller.emitter { + return EmitterControllerIQWrapper(controller: emitter) + } else { + return nil + } + } + } + + var gdpr: GDPRController? { + return InternalQueue.sync { + if let gdpr = controller.gdpr { + return GDPRControllerIQWrapper(controller: gdpr) + } else { + return nil + } + } + } + + var globalContexts: GlobalContextsController? { + return InternalQueue.sync { + if let globalContexts = controller.globalContexts { + return GlobalContextsControllerIQWrapper(controller: globalContexts) + } else { + return nil + } + } + } + + var subject: SubjectController? { + return InternalQueue.sync { + if let subject = controller.subject { + return SubjectControllerIQWrapper(controller: subject) + } else { + return nil + } + } + } + + var session: SessionController? { + return InternalQueue.sync { + if let session = controller.session { + return SessionControllerIQWrapper(controller: session) + } else { + return nil + } + } + } + + var plugins: PluginsController { + return InternalQueue.sync { PluginsControllerIQWrapper(controller: controller.plugins) } + } + + var media: MediaController { + return InternalQueue.sync { MediaControllerIQWrapper(controller: controller.media) } + } + + var ecommerce: EcommerceController { + return InternalQueue.sync { EcommerceControllerIQWrapper(controller: controller.ecommerce) } + } + + // MARK: - Control methods + + func pause() { + InternalQueue.sync { controller.pause() } + } + + func resume() { + InternalQueue.sync { controller.resume() } + } + + func track(_ event: Event) -> UUID { + let eventId = UUID() + InternalQueue.async { self.controller.track(event, eventId: eventId) } + return eventId + } + + func decorateLink(_ url: URL) -> URL? { + return InternalQueue.sync { controller.decorateLink(url) } + } + + func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL? { + return InternalQueue.sync { controller.decorateLink(url, extendedParameters: extendedParameters) } + } + + // MARK: - Properties' setters and getters + + var appId: String { + get { return InternalQueue.sync { controller.appId } } + set { InternalQueue.sync { controller.appId = newValue } } + } + + var namespace: String { + return InternalQueue.sync { controller.namespace } + } + + var devicePlatform: DevicePlatform { + get { return InternalQueue.sync { controller.devicePlatform } } + set { InternalQueue.sync { controller.devicePlatform = newValue } } + } + + var base64Encoding: Bool { + get { return InternalQueue.sync { controller.base64Encoding } } + set { InternalQueue.sync { controller.base64Encoding = newValue } } + } + + var logLevel: LogLevel { + get { return InternalQueue.sync { controller.logLevel } } + set { InternalQueue.sync { controller.logLevel = newValue } } + } + + var loggerDelegate: LoggerDelegate? { + get { return InternalQueue.sync { controller.loggerDelegate } } + set { InternalQueue.sync { controller.loggerDelegate = newValue } } + } + + var applicationContext: Bool { + get { return InternalQueue.sync { controller.applicationContext } } + set { InternalQueue.sync { controller.applicationContext = newValue } } + } + + var platformContext: Bool { + get { return InternalQueue.sync { controller.platformContext } } + set { InternalQueue.sync { controller.platformContext = newValue } } + } + + var platformContextProperties: [PlatformContextProperty]? { + get { return InternalQueue.sync { controller.platformContextProperties } } + set { InternalQueue.sync { controller.platformContextProperties = newValue } } + } + + var platformContextRetriever: PlatformContextRetriever? { + get { return InternalQueue.sync { controller.platformContextRetriever } } + set { InternalQueue.sync { controller.platformContextRetriever = newValue } } + } + + var geoLocationContext: Bool { + get { return InternalQueue.sync { controller.geoLocationContext } } + set { InternalQueue.sync { controller.geoLocationContext = newValue } } + } + + var diagnosticAutotracking: Bool { + get { return InternalQueue.sync { controller.diagnosticAutotracking } } + set { InternalQueue.sync { controller.diagnosticAutotracking = newValue } } + } + + var exceptionAutotracking: Bool { + get { return InternalQueue.sync { controller.exceptionAutotracking } } + set { InternalQueue.sync { controller.exceptionAutotracking = newValue } } + } + + var installAutotracking: Bool { + get { return InternalQueue.sync { controller.installAutotracking } } + set { InternalQueue.sync { controller.installAutotracking = newValue } } + } + + var lifecycleAutotracking: Bool { + get { return InternalQueue.sync { controller.lifecycleAutotracking } } + set { InternalQueue.sync { controller.lifecycleAutotracking = newValue } } + } + + var deepLinkContext: Bool { + get { return InternalQueue.sync { controller.deepLinkContext } } + set { InternalQueue.sync { controller.deepLinkContext = newValue } } + } + + var screenContext: Bool { + get { return InternalQueue.sync { controller.screenContext } } + set { InternalQueue.sync { controller.screenContext = newValue } } + } + + var screenViewAutotracking: Bool { + 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 } } + set { InternalQueue.sync { controller.trackerVersionSuffix = newValue } } + } + + var sessionContext: Bool { + get { return InternalQueue.sync { controller.sessionContext } } + set { InternalQueue.sync { controller.sessionContext = newValue } } + } + + var userAnonymisation: Bool { + 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 } } + set { InternalQueue.sync { controller.advertisingIdentifierRetriever = newValue } } + } + + var isTracking: Bool { + return InternalQueue.sync { controller.isTracking } + } + + var version: String { + return InternalQueue.sync { controller.version } + } + +} diff --git a/Sources/Core/Logger/Logger.swift b/Sources/Core/Logger/Logger.swift index c593efc8e..e566b5ff7 100644 --- a/Sources/Core/Logger/Logger.swift +++ b/Sources/Core/Logger/Logger.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Controllers/AVPlayerSubscription.swift b/Sources/Core/Media/Controllers/AVPlayerSubscription.swift index a5c319570..dc950f76f 100644 --- a/Sources/Core/Media/Controllers/AVPlayerSubscription.swift +++ b/Sources/Core/Media/Controllers/AVPlayerSubscription.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -47,21 +47,23 @@ class AVPlayerSubscription { // add a playback rate observer to find out when the user plays or pauses the videos rateObserver = player.observe(\.rate, options: [.old, .new]) { [weak self] player, change in - guard let oldRate = change.oldValue else { return } - guard let newRate = change.newValue else { return } - - if oldRate != 0 && newRate == 0 { // paused - self?.lastPauseTime = player.currentTime() - self?.track(MediaPauseEvent()) - } else if oldRate == 0 && newRate != 0 { // started playing - // when the current time diverges significantly, i.e. more than 1 second, from what it was when last paused, track a seek event - if let lastPauseTime = self?.lastPauseTime { - if abs(player.currentTime().seconds - lastPauseTime.seconds) > 1 { - self?.track(MediaSeekEndEvent()) + InternalQueue.async { + guard let oldRate = change.oldValue else { return } + guard let newRate = change.newValue else { return } + + if oldRate != 0 && newRate == 0 { // paused + self?.lastPauseTime = player.currentTime() + self?.track(MediaPauseEvent()) + } else if oldRate == 0 && newRate != 0 { // started playing + // when the current time diverges significantly, i.e. more than 1 second, from what it was when last paused, track a seek event + if let lastPauseTime = self?.lastPauseTime { + if abs(player.currentTime().seconds - lastPauseTime.seconds) > 1 { + self?.track(MediaSeekEndEvent()) + } } + self?.lastPauseTime = nil + self?.track(MediaPlayEvent()) } - self?.lastPauseTime = nil - self?.track(MediaPlayEvent()) } } @@ -92,15 +94,17 @@ class AVPlayerSubscription { /// Handles notifications from the notification center subscriptions @objc private func handleNotification(_ notification: Notification) { - switch notification.name { - case .AVPlayerItemPlaybackStalled: - track(MediaBufferStartEvent()) - case .AVPlayerItemDidPlayToEndTime: - track(MediaEndEvent()) - case .AVPlayerItemFailedToPlayToEndTime: - track(MediaErrorEvent(errorDescription: player.error?.localizedDescription)) - default: - return + InternalQueue.async { + switch notification.name { + case .AVPlayerItemPlaybackStalled: + self.track(MediaBufferStartEvent()) + case .AVPlayerItemDidPlayToEndTime: + self.track(MediaEndEvent()) + case .AVPlayerItemFailedToPlayToEndTime: + self.track(MediaErrorEvent(errorDescription: self.player.error?.localizedDescription)) + default: + return + } } } @@ -135,7 +139,9 @@ class AVPlayerSubscription { positionObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] _ in - self?.update() + InternalQueue.async { + self?.update() + } } } diff --git a/Sources/Core/Media/Controllers/MediaAdTracking.swift b/Sources/Core/Media/Controllers/MediaAdTracking.swift index d06e69077..bbb00bf8e 100644 --- a/Sources/Core/Media/Controllers/MediaAdTracking.swift +++ b/Sources/Core/Media/Controllers/MediaAdTracking.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Controllers/MediaControllerImpl.swift b/Sources/Core/Media/Controllers/MediaControllerImpl.swift index 1f28ea991..f1ee02a5e 100644 --- a/Sources/Core/Media/Controllers/MediaControllerImpl.swift +++ b/Sources/Core/Media/Controllers/MediaControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Controllers/MediaPingInterval.swift b/Sources/Core/Media/Controllers/MediaPingInterval.swift index 03e8369b7..ad3236037 100644 --- a/Sources/Core/Media/Controllers/MediaPingInterval.swift +++ b/Sources/Core/Media/Controllers/MediaPingInterval.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,8 +16,8 @@ import Foundation class MediaPingInterval { var pingInterval: Int - private var timer: Timer? - private var timerProvider: Timer.Type + private var timer: InternalQueueTimer? + private var startTimer: (TimeInterval, @escaping () -> Void) -> InternalQueueTimer private var paused: Bool? private var numPausedPings: Int = 0 private var maxPausedPings: Int = 1 @@ -25,12 +25,12 @@ class MediaPingInterval { init(pingInterval: Int? = nil, maxPausedPings: Int? = nil, - timerProvider: Timer.Type = Timer.self) { + startTimer: @escaping (TimeInterval, @escaping () -> Void) -> InternalQueueTimer = InternalQueue.startTimer) { if let maxPausedPings = maxPausedPings { self.maxPausedPings = maxPausedPings } self.pingInterval = pingInterval ?? 30 - self.timerProvider = timerProvider + self.startTimer = startTimer } func update(player: MediaPlayerEntity) { @@ -41,8 +41,8 @@ class MediaPingInterval { func subscribe(callback: @escaping () -> ()) { end() - timer = timerProvider.scheduledTimer(withTimeInterval: TimeInterval(pingInterval), - repeats: true) { _ in + timer = startTimer(TimeInterval(pingInterval)) { [weak self] in + guard let self = self else { return } if !self.isPaused || self.numPausedPings < self.maxPausedPings { if self.isPaused { self.numPausedPings += 1 diff --git a/Sources/Core/Media/Controllers/MediaSessionTracking.swift b/Sources/Core/Media/Controllers/MediaSessionTracking.swift index ba272bd01..222f10ac5 100644 --- a/Sources/Core/Media/Controllers/MediaSessionTracking.swift +++ b/Sources/Core/Media/Controllers/MediaSessionTracking.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Controllers/MediaSessionTrackingStats.swift b/Sources/Core/Media/Controllers/MediaSessionTrackingStats.swift index cff3f79c8..a821ccf2d 100644 --- a/Sources/Core/Media/Controllers/MediaSessionTrackingStats.swift +++ b/Sources/Core/Media/Controllers/MediaSessionTrackingStats.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Controllers/MediaTrackingImpl.swift b/Sources/Core/Media/Controllers/MediaTrackingImpl.swift index c71f06429..59079b4e1 100644 --- a/Sources/Core/Media/Controllers/MediaTrackingImpl.swift +++ b/Sources/Core/Media/Controllers/MediaTrackingImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -115,8 +115,8 @@ class MediaTrackingImpl: MediaTracking { player: MediaPlayerEntity? = nil, ad: MediaAdEntity? = nil, adBreak: MediaAdBreakEntity? = nil) { - objc_sync_enter(self) - + InternalQueue.onQueuePrecondition() + // update state if let player = player { self.player.update(from: player) @@ -143,8 +143,6 @@ class MediaTrackingImpl: MediaTracking { if let event = event { adTracking.updateForNextEvent(event: event) } - - objc_sync_exit(self) } private func addEntitiesAndTrack(event: Event) { diff --git a/Sources/Core/Media/Entities/MediaSessionEntity.swift b/Sources/Core/Media/Entities/MediaSessionEntity.swift index 13bf4797e..b2de7e5c6 100644 --- a/Sources/Core/Media/Entities/MediaSessionEntity.swift +++ b/Sources/Core/Media/Entities/MediaSessionEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Events/MediaPercentProgressEvent.swift b/Sources/Core/Media/Events/MediaPercentProgressEvent.swift index 102224836..b46447f1b 100644 --- a/Sources/Core/Media/Events/MediaPercentProgressEvent.swift +++ b/Sources/Core/Media/Events/MediaPercentProgressEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Events/MediaPingEvent.swift b/Sources/Core/Media/Events/MediaPingEvent.swift index 8d952c565..103142814 100644 --- a/Sources/Core/Media/Events/MediaPingEvent.swift +++ b/Sources/Core/Media/Events/MediaPingEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/Events/MediaPlayerUpdatingEvent.swift b/Sources/Core/Media/Events/MediaPlayerUpdatingEvent.swift index 681e118e5..19796c37a 100644 --- a/Sources/Core/Media/Events/MediaPlayerUpdatingEvent.swift +++ b/Sources/Core/Media/Events/MediaPlayerUpdatingEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Media/MediaSchemata.swift b/Sources/Core/Media/MediaSchemata.swift index 1e6338315..4e110c628 100644 --- a/Sources/Core/Media/MediaSchemata.swift +++ b/Sources/Core/Media/MediaSchemata.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/NetworkConnection/NetworkControllerImpl.swift b/Sources/Core/NetworkConnection/NetworkControllerImpl.swift index 9d026540a..ac7ec82bc 100644 --- a/Sources/Core/NetworkConnection/NetworkControllerImpl.swift +++ b/Sources/Core/NetworkConnection/NetworkControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,10 +16,6 @@ import Foundation class NetworkControllerImpl: Controller, NetworkController { private var requestCallback: RequestCallback? - var isCustomNetworkConnection: Bool { - return emitter.networkConnection != nil && !(emitter.networkConnection is DefaultNetworkConnection) - } - // MARK: - Properties var endpoint: String? { diff --git a/Sources/Core/RemoteConfiguration/RemoteConfigurationBundle.swift b/Sources/Core/RemoteConfiguration/RemoteConfigurationBundle.swift index eef89efb2..6b9c34a1a 100644 --- a/Sources/Core/RemoteConfiguration/RemoteConfigurationBundle.swift +++ b/Sources/Core/RemoteConfiguration/RemoteConfigurationBundle.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/RemoteConfiguration/RemoteConfigurationCache.swift b/Sources/Core/RemoteConfiguration/RemoteConfigurationCache.swift index 6f66ee2af..171c76f5a 100644 --- a/Sources/Core/RemoteConfiguration/RemoteConfigurationCache.swift +++ b/Sources/Core/RemoteConfiguration/RemoteConfigurationCache.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -68,7 +68,23 @@ class RemoteConfigurationCache: NSObject { let data = try? Data(contentsOf: cacheFileUrl) else { return } if #available(iOS 12, tvOS 12, watchOS 5, macOS 10.14, *) { do { - configuration = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? RemoteConfigurationBundle + configuration = try NSKeyedUnarchiver.unarchivedObject( + ofClasses: [ + ConfigurationBundle.self, + RemoteConfigurationBundle.self, + NetworkConfiguration.self, + TrackerConfiguration.self, + SubjectConfiguration.self, + SessionConfiguration.self, + EmitterConfiguration.self, + SPSize.self, + NSString.self, + NSArray.self, + NSDictionary.self, + NSNumber.self + ], + from: data + ) as? RemoteConfigurationBundle } catch let error { logError(message: String(format: "Exception on getting configuration from cache: %@", error.localizedDescription)) configuration = nil diff --git a/Sources/Core/RemoteConfiguration/RemoteConfigurationFetcher.swift b/Sources/Core/RemoteConfiguration/RemoteConfigurationFetcher.swift index 48da6cefc..fda14cf70 100644 --- a/Sources/Core/RemoteConfiguration/RemoteConfigurationFetcher.swift +++ b/Sources/Core/RemoteConfiguration/RemoteConfigurationFetcher.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/RemoteConfiguration/RemoteConfigurationProvider.swift b/Sources/Core/RemoteConfiguration/RemoteConfigurationProvider.swift index d2221a1fe..f7dfd13f8 100644 --- a/Sources/Core/RemoteConfiguration/RemoteConfigurationProvider.swift +++ b/Sources/Core/RemoteConfiguration/RemoteConfigurationProvider.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/ScreenViewTracking/ListItemViewModifier.swift b/Sources/Core/ScreenViewTracking/ListItemViewModifier.swift new file mode 100644 index 000000000..6dac60176 --- /dev/null +++ b/Sources/Core/ScreenViewTracking/ListItemViewModifier.swift @@ -0,0 +1,54 @@ +// Copyright (c) 2013-present 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/ScreenState.swift b/Sources/Core/ScreenViewTracking/ScreenState.swift index 93ae5b4f5..b3178b19c 100644 --- a/Sources/Core/ScreenViewTracking/ScreenState.swift +++ b/Sources/Core/ScreenViewTracking/ScreenState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift b/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift index c6a184b22..a77629df2 100644 --- a/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift +++ b/Sources/Core/ScreenViewTracking/ScreenStateMachine.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -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..2074ca5b2 --- /dev/null +++ b/Sources/Core/ScreenViewTracking/ScreenSummaryState.swift @@ -0,0 +1,95 @@ +// Copyright (c) 2013-present 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..fe3388cf2 --- /dev/null +++ b/Sources/Core/ScreenViewTracking/ScreenSummaryStateMachine.swift @@ -0,0 +1,93 @@ +// Copyright (c) 2013-present 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/ScreenViewTracking/ScreenViewModifier.swift b/Sources/Core/ScreenViewTracking/ScreenViewModifier.swift index d721a1b95..b9fe09350 100644 --- a/Sources/Core/ScreenViewTracking/ScreenViewModifier.swift +++ b/Sources/Core/ScreenViewTracking/ScreenViewModifier.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/ScreenViewTracking/UIKitScreenViewTracking.swift b/Sources/Core/ScreenViewTracking/UIKitScreenViewTracking.swift index 389cec01f..34ec8e806 100644 --- a/Sources/Core/ScreenViewTracking/UIKitScreenViewTracking.swift +++ b/Sources/Core/ScreenViewTracking/UIKitScreenViewTracking.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -69,9 +69,15 @@ extension UIViewController { @objc func sp_viewDidAppear(_ animated: Bool) { sp_viewDidAppear(animated) - let bundle = Bundle(for: self.classForCoder) - if !bundle.bundlePath.hasPrefix(Bundle.main.bundlePath) { - // Ignore view controllers that don't start with the main bundle path + let bundleURL = Bundle(for: self.classForCoder).bundleURL + let mainBundleURL = Bundle.main.bundleURL + + // Resolve any symbolic links and standardize the file paths + let resolvedBundlePath = bundleURL.resolvingSymlinksInPath().path + let resolvedMainBundlePath = mainBundleURL.resolvingSymlinksInPath().path + + // Ignore view controllers that don't start with the main bundle path + guard resolvedBundlePath.hasPrefix(resolvedMainBundlePath) else { return } @@ -81,7 +87,7 @@ extension UIViewController { // Construct userInfo var userInfo: [AnyHashable : Any] = [:] userInfo["viewControllerClassName"] = String(describing: self.classForCoder) - userInfo["topViewControllerClassName"] = String(describing: top.self.classForCoder) + userInfo["topViewControllerClassName"] = String(describing: top.classForCoder) // `name` is set to snowplowId class instance variable if it exists (hence no @"id" in userInfo) userInfo["name"] = _SP_getName(self) ?? _SP_getName(top) ?? "Unknown" diff --git a/Sources/Core/Session/Session.swift b/Sources/Core/Session/Session.swift index 85a51863c..10daa9b65 100644 --- a/Sources/Core/Session/Session.swift +++ b/Sources/Core/Session/Session.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -17,41 +17,42 @@ import UIKit #endif class Session { - /// Whether the application is in the background or foreground - private(set) var inBackground = false + + // MARK: - Private properties + + private var dataPersistence: DataPersistence? + /// The event index + private var eventIndex = 0 + private var isNewSession = true + private var isSessionCheckerEnabled = false + private var lastSessionCheck: NSNumber = Utilities.getTimestamp() + /// Returns the current session state + private var state: SessionState? + /// The current tracker associated with the session + private weak var tracker: Tracker? + + // MARK: - Properties + /// The session's userId - private(set) var userId: String + let userId: String + /// Whether the application is in the background or foreground + var inBackground: Bool = false /// The foreground index count - private(set) var foregroundIndex = 0 + var foregroundIndex = 0 /// The background index count - private(set) var backgroundIndex = 0 - /// The event index - private(set) var eventIndex = 0 - /// The current tracker associated with the session - private(set) weak var tracker: Tracker? - /// Returns the current session state - private(set) var state: SessionState? + var backgroundIndex = 0 /// Callback to be called when the session is updated - public var onSessionStateUpdate: ((_ sessionState: SessionState) -> Void)? - + var onSessionStateUpdate: ((_ sessionState: SessionState) -> Void)? /// The currently set Foreground Timeout in milliseconds - public var foregroundTimeout = TrackerDefaults.foregroundTimeout + var foregroundTimeout = TrackerDefaults.foregroundTimeout /// The currently set Background Timeout in milliseconds - public var backgroundTimeout = TrackerDefaults.backgroundTimeout - - private var isNewSession = true - private var isSessionCheckerEnabled = false - private var lastSessionCheck: NSNumber = Utilities.getTimestamp() - private var dataPersistence: DataPersistence? - - /// Initializes a newly allocated SnowplowSession - /// - Parameters: - /// - foregroundTimeout: the session timeout while it is in the foreground - /// - backgroundTimeout: the session timeout while it is in the background - /// - Returns: a SnowplowSession - convenience init(foregroundTimeout: Int, andBackgroundTimeout backgroundTimeout: Int) { - self.init(foregroundTimeout: foregroundTimeout, andBackgroundTimeout: backgroundTimeout, andTracker: nil) - } + var backgroundTimeout = TrackerDefaults.backgroundTimeout + var sessionIndex: Int? { return state?.sessionIndex } + var sessionId: String? { return state?.sessionId } + var previousSessionId: String? { return state?.previousSessionId } + var firstEventId: String? { return state?.firstEventId } + + // MARK: - Constructor and destructor /// Initializes a newly allocated SnowplowSession /// - Parameters: @@ -59,12 +60,12 @@ class Session { /// - backgroundTimeout: the session timeout while it is in the background /// - tracker: reference to the associated tracker of the session /// - Returns: a SnowplowSession - init(foregroundTimeout: Int, andBackgroundTimeout backgroundTimeout: Int, andTracker tracker: Tracker?) { + init(foregroundTimeout: Int, backgroundTimeout: Int, trackerNamespace: String? = nil, tracker: Tracker? = nil) { self.foregroundTimeout = foregroundTimeout * 1000 self.backgroundTimeout = backgroundTimeout * 1000 self.tracker = tracker - if let namespace = tracker?.trackerNamespace { + if let namespace = trackerNamespace { dataPersistence = DataPersistence.getFor(namespace: namespace) } let storedSessionDict = dataPersistence?.session @@ -96,6 +97,12 @@ class Session { #endif } + deinit { +#if os(iOS) || os(tvOS) + NotificationCenter.default.removeObserver(self) +#endif + } + // MARK: - Public /// Starts the recurring timer check for sessions @@ -122,7 +129,7 @@ class Session { /// - Returns: a SnowplowPayload containing the session dictionary func getDictWithEventId(_ eventId: String?, eventTimestamp: Int64, userAnonymisation: Bool) -> [String : Any]? { var context: [String : Any]? = nil - objc_sync_enter(self) + if isSessionCheckerEnabled { if shouldUpdate() { update(eventId: eventId, eventTimestamp: eventTimestamp) @@ -134,12 +141,11 @@ class Session { } lastSessionCheck = Utilities.getTimestamp() } - + eventIndex += 1 - + context = state?.sessionContext context?[kSPSessionEventIndex] = NSNumber(value: eventIndex) - objc_sync_exit(self) if userAnonymisation { // mask the user identifier @@ -207,40 +213,38 @@ class Session { dataPersistence?.session = sessionToPersist eventIndex = 0 } + + // MARK: - background and foreground notifications @objc func updateInBackground() { - if !inBackground && tracker?.lifecycleEvents ?? false { - backgroundIndex += 1 - sendBackgroundEvent() - inBackground = true + InternalQueue.async { + if self.tracker?.lifecycleEvents ?? false { + guard let backgroundIndex = self.incrementBackgroundIndexIfNotInBackground() else { return } + _ = self.tracker?.track(Background(index: backgroundIndex)) + self.inBackground = true + } } } @objc func updateInForeground() { - if inBackground && tracker?.lifecycleEvents ?? false { - foregroundIndex += 1 - sendForegroundEvent() - inBackground = false - } - } - - func sendBackgroundEvent() { - if let tracker = tracker { - let backgroundEvent = Background(index: backgroundIndex) - let _ = tracker.track(backgroundEvent) + InternalQueue.async { + if self.tracker?.lifecycleEvents ?? false { + guard let foregroundIndex = self.incrementForegroundIndexIfInBackground() else { return } + _ = self.tracker?.track(Foreground(index: foregroundIndex)) + self.inBackground = false + } } } - - func sendForegroundEvent() { - if let tracker = tracker { - let foregroundEvent = Foreground(index: foregroundIndex) - let _ = tracker.track(foregroundEvent) - } + + private func incrementBackgroundIndexIfNotInBackground() -> Int? { + if self.inBackground { return nil } + self.backgroundIndex += 1 + return self.backgroundIndex } - - deinit { - #if os(iOS) - NotificationCenter.default.removeObserver(self) - #endif + + private func incrementForegroundIndexIfInBackground() -> Int? { + if !self.inBackground { return nil } + self.foregroundIndex += 1 + return self.foregroundIndex } } diff --git a/Sources/Core/Session/SessionControllerImpl.swift b/Sources/Core/Session/SessionControllerImpl.swift index 0a8eade38..702c953c0 100644 --- a/Sources/Core/Session/SessionControllerImpl.swift +++ b/Sources/Core/Session/SessionControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -107,7 +107,7 @@ class SessionControllerImpl: Controller, SessionController { logDiagnostic(message: "Attempt to access SessionController fields when disabled") return -1 } - return session?.state?.sessionIndex ?? -1 + return session?.sessionIndex ?? -1 } var sessionId: String? { @@ -115,7 +115,7 @@ class SessionControllerImpl: Controller, SessionController { logDiagnostic(message: "Attempt to access SessionController fields when disabled") return nil } - return session?.state?.sessionId + return session?.sessionId } var userId: String? { diff --git a/Sources/Core/StateMachine/DeepLinkState.swift b/Sources/Core/StateMachine/DeepLinkState.swift index e6540ffee..c787faf0c 100644 --- a/Sources/Core/StateMachine/DeepLinkState.swift +++ b/Sources/Core/StateMachine/DeepLinkState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/StateMachine/DeepLinkStateMachine.swift b/Sources/Core/StateMachine/DeepLinkStateMachine.swift index 9f551eb1f..0d956ebfc 100644 --- a/Sources/Core/StateMachine/DeepLinkStateMachine.swift +++ b/Sources/Core/StateMachine/DeepLinkStateMachine.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -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/ImmersiveSpaceState.swift b/Sources/Core/StateMachine/ImmersiveSpaceState.swift new file mode 100644 index 000000000..3c4a06e4a --- /dev/null +++ b/Sources/Core/StateMachine/ImmersiveSpaceState.swift @@ -0,0 +1,30 @@ +// Copyright (c) 2013-present 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..f7a9555b7 --- /dev/null +++ b/Sources/Core/StateMachine/ImmersiveSpaceStateMachine.swift @@ -0,0 +1,107 @@ +// Copyright (c) 2013-present 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/StateMachine/LifecycleState.swift b/Sources/Core/StateMachine/LifecycleState.swift index 79f67d05c..87f342497 100644 --- a/Sources/Core/StateMachine/LifecycleState.swift +++ b/Sources/Core/StateMachine/LifecycleState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/StateMachine/LifecycleStateMachine.swift b/Sources/Core/StateMachine/LifecycleStateMachine.swift index 8bf143295..774041d0b 100644 --- a/Sources/Core/StateMachine/LifecycleStateMachine.swift +++ b/Sources/Core/StateMachine/LifecycleStateMachine.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -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..3edb365ad 100644 --- a/Sources/Core/StateMachine/PluginStateMachine.swift +++ b/Sources/Core/StateMachine/PluginStateMachine.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -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/State.swift b/Sources/Core/StateMachine/State.swift index e9ffbce97..fb0f8f96d 100644 --- a/Sources/Core/StateMachine/State.swift +++ b/Sources/Core/StateMachine/State.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/StateMachine/StateFuture.swift b/Sources/Core/StateMachine/StateFuture.swift index c6d9f73ff..dce138afa 100644 --- a/Sources/Core/StateMachine/StateFuture.swift +++ b/Sources/Core/StateMachine/StateFuture.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -31,8 +31,6 @@ class StateFuture { } func computeState() -> State? { - objc_sync_enter(self) - defer { objc_sync_exit(self) } if computedState == nil { if let stateMachine = stateMachine, let event = event { computedState = stateMachine.transition(from: event, state: previousState?.computeState()) diff --git a/Sources/Core/StateMachine/StateMachineEvent.swift b/Sources/Core/StateMachine/StateMachineEvent.swift index 0ce2eceab..35752be6a 100644 --- a/Sources/Core/StateMachine/StateMachineEvent.swift +++ b/Sources/Core/StateMachine/StateMachineEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/StateMachine/StateMachineProtocol.swift b/Sources/Core/StateMachine/StateMachineProtocol.swift index 7dbff9499..93982b564 100644 --- a/Sources/Core/StateMachine/StateMachineProtocol.swift +++ b/Sources/Core/StateMachine/StateMachineProtocol.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -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 4e5e910b0..cd30414a0 100644 --- a/Sources/Core/StateMachine/StateManager.swift +++ b/Sources/Core/StateMachine/StateManager.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -20,12 +20,10 @@ 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) { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - if let previousStateMachine = identifierToStateMachine[stateMachine.identifier] { if type(of: stateMachine) == type(of: previousStateMachine) { return @@ -53,12 +51,13 @@ class StateManager { toSchemaRegistry: &eventSchemaToFilter, schemas: stateMachine.subscribedEventSchemasForFiltering, stateMachine: stateMachine) + add( + toSchemaRegistry: &eventSchemaToEventsBefore, + schemas: stateMachine.subscribedEventSchemasForEventsBefore, + stateMachine: stateMachine) } func removeStateMachine(_ stateMachineIdentifier: String) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - guard let stateMachine = identifierToStateMachine[stateMachineIdentifier] else { return false } @@ -84,13 +83,14 @@ class StateManager { fromSchemaRegistry: &eventSchemaToFilter, schemas: stateMachine.subscribedEventSchemasForFiltering, stateMachine: stateMachine) + remove( + fromSchemaRegistry: &eventSchemaToEventsBefore, + schemas: stateMachine.subscribedEventSchemasForEventsBefore, + stateMachine: stateMachine) return true } func trackerState(forProcessedEvent event: Event) -> TrackerStateSnapshot? { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - if let sdEvent = event as? SelfDescribingAbstract { var stateMachines = Array(eventSchemaToStateMachine[sdEvent.schema] ?? []) stateMachines.append(contentsOf: eventSchemaToStateMachine["*"] ?? []) @@ -121,9 +121,6 @@ class StateManager { } func filter(event: InspectableEvent & StateMachineEvent) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - guard let schema = event.schema ?? event.eventName else { return true } var stateMachines = eventSchemaToFilter[schema] ?? [] stateMachines.append(contentsOf: eventSchemaToFilter["*"] ?? []) @@ -136,11 +133,24 @@ 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] { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - guard let schema = event.schema ?? event.eventName else { return [] } var result: [SelfDescribingJson] = [] var stateMachines = eventSchemaToEntitiesGenerator[schema] ?? [] @@ -156,9 +166,6 @@ class StateManager { } func addPayloadValues(to event: InspectableEvent & StateMachineEvent) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - guard let schema = event.schema else { return true } var failures = 0 var stateMachines = eventSchemaToPayloadUpdater[schema] ?? [] @@ -177,10 +184,8 @@ class StateManager { func afterTrack(event: InspectableEvent & StateMachineEvent) { guard let schema = event.schema ?? event.eventName else { return } - objc_sync_enter(self) var stateMachines = eventSchemaToAfterTrackCallback[schema] ?? [] stateMachines.append(contentsOf: eventSchemaToAfterTrackCallback["*"] ?? []) - objc_sync_exit(self) if !stateMachines.isEmpty { DispatchQueue.global(qos: .default).async { diff --git a/Sources/Core/StateMachine/TrackerState.swift b/Sources/Core/StateMachine/TrackerState.swift index 3157260d8..9ab222332 100644 --- a/Sources/Core/StateMachine/TrackerState.swift +++ b/Sources/Core/StateMachine/TrackerState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -19,29 +19,20 @@ class TrackerState: TrackerStateSnapshot { /// Set a future computable state with a specific state identifier func setStateFuture(_ state: StateFuture, identifier stateIdentifier: String) { - objc_sync_enter(self) trackerState[stateIdentifier] = state - objc_sync_exit(self) } /// Get a future computable state associated with a state identifier func stateFuture(withIdentifier stateIdentifier: String) -> StateFuture? { - objc_sync_enter(self) - defer { objc_sync_exit(self) } return trackerState[stateIdentifier] } func remove(withIdentifier stateIdentifer: String) { - objc_sync_enter(self) trackerState.removeValue(forKey: stateIdentifer) - objc_sync_exit(self) } /// Get an immutable copy of the whole tracker state func snapshot() -> TrackerStateSnapshot? { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - let newTrackerState = TrackerState() newTrackerState.trackerState = trackerState return newTrackerState diff --git a/Sources/Core/StateMachine/TrackerStateSnapshot.swift b/Sources/Core/StateMachine/TrackerStateSnapshot.swift index d178aef08..7a7fb1742 100644 --- a/Sources/Core/StateMachine/TrackerStateSnapshot.swift +++ b/Sources/Core/StateMachine/TrackerStateSnapshot.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Storage/Database.swift b/Sources/Core/Storage/Database.swift new file mode 100644 index 000000000..ab711aef1 --- /dev/null +++ b/Sources/Core/Storage/Database.swift @@ -0,0 +1,197 @@ +// Copyright (c) 2013-present 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 os(iOS) || os(macOS) || os(visionOS) + +import Foundation +import SQLite3 + +class Database { + private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + private let dbPath: String + + static func dbPath(namespace: String) -> String { + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] + + // Create snowplow subdirectory if it doesn't exist + let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path + try? FileManager.default.createDirectory(atPath: snowplowDirPath, withIntermediateDirectories: true, attributes: nil) + + // Create path for the database + let regex: NSRegularExpression? = try? NSRegularExpression(pattern: "[^a-zA-Z0-9_]+", options: []) + + let sqliteSuffix = regex?.stringByReplacingMatches(in: namespace, options: [], range: NSRange(location: 0, length: namespace.count), withTemplate: "-") + let sqliteFilename = "snowplowEvents-\(sqliteSuffix ?? "").sqlite" + return URL(fileURLWithPath: snowplowDirPath).appendingPathComponent(sqliteFilename).path + } + + init(namespace: String) { + dbPath = Database.dbPath(namespace: namespace) + + createTable() + } + + private func createTable() { + let sql = """ + CREATE TABLE IF NOT EXISTS 'events' + (id INTEGER PRIMARY KEY, eventData BLOB, dateCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP) + """ + + _ = execute(sql: sql, name: "Create table") + } + + func insertRow(_ dict: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: dict) else { + logError(message: "Failed to serialize event to save in database") + return + } + + let insertString = "INSERT INTO 'events' (eventData) VALUES (?)" + data.withUnsafeBytes { rawBuffer in + if let pointer = rawBuffer.baseAddress { + prepare(sql: insertString, name: "Insert row") { insertStatement, db in + sqlite3_bind_blob(insertStatement, 1, pointer, Int32(rawBuffer.count), SQLITE_TRANSIENT) + + if sqlite3_step(insertStatement) == SQLITE_DONE { + logDebug(message: "Event stored in database") + } else { + logSqlError(message: "Failed to insert event to database", connection: db) + } + } + } + } + } + + func deleteRows(ids: [Int64]? = nil) -> Bool { + var sql = "DELETE FROM 'events'" + if let ids = ids { + sql += " WHERE id IN \(idsSqlString(ids))" + } + return execute(sql: sql, name: "Delete rows") + } + + func countRows() -> Int64? { + var count: Int64? = nil + let sql = "SELECT COUNT(*) AS count FROM 'events'" + + prepare(sql: sql, name: "Count rows") { selectStatement, _ in + if sqlite3_step(selectStatement) == SQLITE_ROW { + count = sqlite3_column_int64(selectStatement, 0) + } + } + return count + } + + private func idsSqlString(_ ids: [Int64] = []) -> String { + return "(" + ids.map { "\($0)" }.joined(separator: ",") + ")" + } + + func readRows(numRows: Int) -> [(id: Int64, data: [String: Any])] { + var rows: [(id: Int64, data: [String: Any])] = [] + let sql = "SELECT id, eventData FROM 'events' LIMIT \(numRows)" + + var rowsRead: Int = 0 + prepare(sql: sql, name: "Select rows") { selectStatement, db in + while sqlite3_step(selectStatement) == SQLITE_ROW { + if let blob = sqlite3_column_blob(selectStatement, 1) { + let blobLength = sqlite3_column_bytes(selectStatement, 1) + let data = Data(bytes: blob, count: Int(blobLength)) + let id = sqlite3_column_int64(selectStatement, 0) + + if let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + rows.append((id: id, data: dict)) + + rowsRead += 1 + } + } else { + logSqlError(message: "No data found for row in events", connection: db) + } + } + if rowsRead > 0 { + logDebug(message: "Read \(rowsRead) events from database") + } + } + return rows + } + + func removeOldEvents(maxSize: Int64, maxAge: TimeInterval) { + let sql = """ + DELETE FROM 'events' + WHERE id NOT IN ( + SELECT id FROM events + WHERE dateCreated >= datetime('now','-\(maxAge) seconds') + ORDER BY dateCreated DESC, id DESC + LIMIT \(maxSize) + ) + """ + + _ = execute(sql: sql, name: "Delete old events") + } + + private func prepare(sql: String, name: String, closure: (OpaquePointer?, OpaquePointer?) -> ()) { + withConnection { db in + var statement: OpaquePointer? + if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { + closure(statement, db) + } else { + logSqlError(message: "\(name) failed to prepare", connection: db) + } + sqlite3_finalize(statement) + } + } + + private func execute(sql: String, name: String) -> Bool { + var success = false + prepare(sql: sql, name: name) { statement, db in + if sqlite3_step(statement) == SQLITE_DONE { + logDebug(message: "\(name) successful") + success = true + } else { + logSqlError(message: "\(name) failed", connection: db) + } + } + return success + } + + private func logSqlError(message: String? = nil, connection: OpaquePointer? = nil) { + if let msg = message { + logError(message: msg) + } + if let db = connection { + let sqlError = String(cString: sqlite3_errmsg(db)!) + logError(message: sqlError) + } + } + + private func withConnection(closure: (OpaquePointer) -> T) -> T? { + if let connection = open() { + defer { close(connection) } + return closure(connection) + } + return nil + } + + private func open() -> OpaquePointer? { + var connection: OpaquePointer? + if sqlite3_open_v2(dbPath, &connection, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) != SQLITE_OK { + logSqlError(message: "Failed to open database: \(dbPath)") + } + return connection + } + + private func close(_ connection: OpaquePointer) { + sqlite3_close(connection) + } +} + +#endif diff --git a/Sources/Core/Storage/MemoryEventStore.swift b/Sources/Core/Storage/MemoryEventStore.swift index 1e0f569b7..f885401e3 100644 --- a/Sources/Core/Storage/MemoryEventStore.swift +++ b/Sources/Core/Storage/MemoryEventStore.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -15,17 +15,15 @@ import Foundation class MemoryEventStore: NSObject, EventStore { - var sendLimit: UInt - var index: Int64 - var orderedSet: NSMutableOrderedSet - + private var sendLimit: UInt + private var index: Int64 + private var eventBuffer: [EmitterEvent] = [] convenience override init() { self.init(limit: 250) } init(limit: UInt) { - orderedSet = NSMutableOrderedSet() sendLimit = limit index = 0 } @@ -33,67 +31,63 @@ class MemoryEventStore: NSObject, EventStore { // Interface methods func addEvent(_ payload: Payload) { - objc_sync_enter(self) + InternalQueue.onQueuePrecondition() + let item = EmitterEvent(payload: payload, storeId: index) - orderedSet.add(item) - objc_sync_exit(self) + eventBuffer.append(item) index += 1 } func count() -> UInt { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - return UInt(orderedSet.count) + InternalQueue.onQueuePrecondition() + + return UInt(eventBuffer.count) } func emittableEvents(withQueryLimit queryLimit: UInt) -> [EmitterEvent] { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - let setCount = (orderedSet).count - if setCount <= 0 { - return [] - } - let len = min(Int(queryLimit), setCount) - _ = NSRange(location: 0, length: len) - var count = 0 - let indexes = orderedSet.indexes { _, _, _ in - count += 1 - return count <= queryLimit - } - let objects = orderedSet.objects(at: indexes) - var result: [EmitterEvent] = [] - for object in objects { - if let event = object as? EmitterEvent { - result.append(event) - } - } - return result + InternalQueue.onQueuePrecondition() + + let limit = min(queryLimit, sendLimit) + + return Array(eventBuffer.prefix(Int(limit))) } func removeAllEvents() -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - orderedSet.removeAllObjects() + InternalQueue.onQueuePrecondition() + + eventBuffer.removeAll() return true } func removeEvent(withId storeId: Int64) -> Bool { + InternalQueue.onQueuePrecondition() + return removeEvents(withIds: [storeId]) } func removeEvents(withIds storeIds: [Int64]) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - var itemsToRemove: [EmitterEvent] = [] - for item in orderedSet { - guard let item = item as? EmitterEvent else { - continue - } - if storeIds.contains(item.storeId) { - itemsToRemove.append(item) + InternalQueue.onQueuePrecondition() + + eventBuffer = eventBuffer.filter { !storeIds.contains($0.storeId) } + return true + } + + func removeOldEvents(maxSize: Int64, maxAge: TimeInterval) { + InternalQueue.onQueuePrecondition() + + let currentTimestamp = Date().timeIntervalSince1970 + + // remove old events by age + eventBuffer = eventBuffer.filter { emitterEvent in + if let timestampString = emitterEvent.payload[kSPTimestamp] as? String, + let timestamp = Double(timestampString) { + let timestampSecs = timestamp / 1000.0 + return currentTimestamp - timestampSecs <= maxAge } + return true } - orderedSet.removeObjects(in: itemsToRemove) - return true + + // remove old events by size limit + eventBuffer = Array(eventBuffer.suffix(Int(maxSize))) } } diff --git a/Sources/Core/Storage/SQLiteEventStore.swift b/Sources/Core/Storage/SQLiteEventStore.swift index 101badb09..e88b4a0b6 100644 --- a/Sources/Core/Storage/SQLiteEventStore.swift +++ b/Sources/Core/Storage/SQLiteEventStore.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -11,273 +11,79 @@ // express or implied. See the Apache License Version 2.0 for the specific // language governing permissions and limitations there under. -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(visionOS) -import FMDB import Foundation -let _queryCreateTable = "CREATE TABLE IF NOT EXISTS 'events' (id INTEGER PRIMARY KEY, eventData BLOB, dateCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP)" -let _querySelectAll = "SELECT * FROM 'events'" -let _querySelectCount = "SELECT Count(*) FROM 'events'" -let _queryInsertEvent = "INSERT INTO 'events' (eventData) VALUES (?)" -let _querySelectId = "SELECT * FROM 'events' WHERE id=?" -let _queryDeleteId = "DELETE FROM 'events' WHERE id=?" -let _queryDeleteIds = "DELETE FROM 'events' WHERE id IN (%@)" -let _queryDeleteAll = "DELETE FROM 'events'" - class SQLiteEventStore: NSObject, EventStore { - var namespace: String - var sqliteFilename: String - var dbPath: String - var queue: FMDatabaseQueue? - var sendLimit: Int - - /// IMPORTANT: This method is for internal use only. It's signature and behaviour might change in any - /// future tracker release. - /// - /// Clears all the EventStores not associated at any of the namespaces passed as parameter. - /// - /// - Parameter allowedNamespaces: The namespace allowed. All the EventStores not associated at any of - /// the allowedNamespaces will be cleared. - /// - Returns: The list of namespaces that have been found with EventStores and have been cleared out. - class func removeUnsentEventsExcept(forNamespaces allowedNamespaces: [String]?) -> [String]? { - #if os(tvOS) - let libraryPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).map(\.path)[0] - #else - let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] - #endif - let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path - var files: [String]? = nil - do { - files = try FileManager.default.contentsOfDirectory(atPath: snowplowDirPath) - } catch { - } - var allowedFiles: [String]? = [] - for namespace in allowedNamespaces ?? [] { - var regex: NSRegularExpression? = nil - do { - regex = try NSRegularExpression(pattern: "[^a-zA-Z0-9_]+", options: []) - } catch { - } - let sqliteSuffix = regex?.stringByReplacingMatches(in: namespace, options: [], range: NSRange(location: 0, length: namespace.count), withTemplate: "-") - let sqliteFilename = "snowplowEvents-\(sqliteSuffix ?? "").sqlite" - allowedFiles?.append(sqliteFilename) - } - var removedFiles: [String]? = [] - for file in files ?? [] { - if !(allowedFiles?.contains(file) ?? false) { - let pathToRemove = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent(file).path - try? FileManager.default.removeItem(atPath: pathToRemove) - removedFiles?.append(file) - } - } - return removedFiles - } - - /// Basic initializer that creates a database event table (if one does not exist) and then closes the connection. - convenience init(namespace: String?) { - self.init(namespace: namespace, limit: 250) - } - - init(namespace: String?, limit: Int) { - self.namespace = namespace ?? "" - sendLimit = limit - - #if os(tvOS) - let libraryPath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).map(\.path)[0] - #else - let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] - #endif - // Create snowplow subdirectory if it doesn't exist - let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path - try? FileManager.default.createDirectory(atPath: snowplowDirPath, withIntermediateDirectories: true, attributes: nil) - - // Create path for the database - var regex: NSRegularExpression? = nil - do { - regex = try NSRegularExpression(pattern: "[^a-zA-Z0-9_]+", options: []) - } catch { - } - let sqliteSuffix = regex?.stringByReplacingMatches(in: self.namespace, options: [], range: NSRange(location: 0, length: namespace?.count ?? 0), withTemplate: "-") - sqliteFilename = "snowplowEvents-\(sqliteSuffix ?? "").sqlite" - dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent(sqliteFilename).path + private let database: Database + private var sendLimit: Int + init(namespace: String?, limit: Int = 250) { + let namespace = namespace ?? "" + // Migrate old database if it exists + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] let oldDbPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplowEvents.sqlite").path if FileManager.default.fileExists(atPath: oldDbPath) { - try? FileManager.default.moveItem(atPath: oldDbPath, toPath: dbPath) + let newDbPath = Database.dbPath(namespace: namespace) + try? FileManager.default.moveItem(atPath: oldDbPath, toPath: newDbPath) } - - // Create database - queue = FMDatabaseQueue(path: dbPath) - super.init() - _ = createTable() - } - - deinit { - queue?.close() + + database = Database(namespace: namespace) + sendLimit = limit } // MARK: SPEventStore implementation methods - func addEvent(_ payload: Payload) { - _ = insertDictionaryData(payload.dictionary) + func addEvent(_ data: Payload) { + InternalQueue.onQueuePrecondition() + + self.database.insertRow(data.dictionary) } func removeEvent(withId storeId: Int64) -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() { - logDebug(message: String(format: "Removing %d from database now.", storeId)) - res = db.executeUpdate(_queryDeleteId, withArgumentsIn: [storeId]) - } - }) - return res + InternalQueue.onQueuePrecondition() + + return database.deleteRows(ids: [storeId]) } func removeEvents(withIds storeIds: [Int64]) -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() && storeIds.count != 0 { - let ids = storeIds.map { String(describing: $0) }.joined(separator: ",") - logDebug(message: String(format: "Removing [%@] from database now.", ids)) - let query = String(format: _queryDeleteIds, ids) - res = db.executeUpdate(query, withArgumentsIn: []) - } - }) - return res + InternalQueue.onQueuePrecondition() + + return database.deleteRows(ids: storeIds) } func removeAllEvents() -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() { - logDebug(message: "Removing all events from database now.") - res = db.executeUpdate(_queryDeleteAll, withArgumentsIn: []) - } - }) - return res + InternalQueue.onQueuePrecondition() + + return database.deleteRows() } func count() -> UInt { - var num: UInt = 0 - queue?.inDatabase({ db in - if db.open() { - if let s = db.executeQuery(_querySelectCount, withArgumentsIn: []) { - while s.next() { - num = NSNumber(value: s.int(forColumnIndex: 0)).uintValue - } - s.close() - } - } - }) - return num + InternalQueue.onQueuePrecondition() + + if let count = database.countRows() { + return UInt(count) + } + return 0 } func emittableEvents(withQueryLimit queryLimit: UInt) -> [EmitterEvent] { - return getAllEventsLimited(min(queryLimit, UInt(sendLimit))) ?? [] - } - - // MARK: SPSQLiteEventStore methods - - func createTable() -> Bool { - var res = false - queue?.inDatabase({ db in - if db.open() { - res = db.executeStatements(_queryCreateTable) - } - }) - return res - } - - /// Inserts events into the sqlite table for the app identified with it's bundleId (appId). - /// - Parameter payload: A SnowplowPayload instance to be inserted into the database. - /// - Returns: If the insert was successful, we return the rowId of the inserted entry, otherwise -1. We explicitly do this in the case of an error, sqlite would return the previous successful insert leading to incorrect data removals. - func insertEvent(_ payload: Payload?) -> Int64 { - return insertDictionaryData(payload?.dictionary) - } - - func insertDictionaryData(_ dict: [AnyHashable : Any]?) -> Int64 { - var res: Int64 = -1 - if dict == nil { - return res + InternalQueue.onQueuePrecondition() + + let limit = min(Int(queryLimit), sendLimit) + let rows = database.readRows(numRows: limit) + return rows.map { row in + let payload = Payload(dictionary: row.data) + return EmitterEvent(payload: payload, storeId: row.id) } - queue?.inDatabase({ db in - if db.open() { - if let dict = dict, - let data = try? JSONSerialization.data(withJSONObject: dict) { - try? db.executeUpdate(_queryInsertEvent, values: [data]) - res = db.lastInsertRowId - } - } - }) - return res } - - /// Finds the row in the event table with the supplied ID. - /// - Parameter id_: Unique ID of the row in the events table to be returned. - /// - Returns: A dictionary containing data with keys: 'ID', 'eventData', and 'dateCreated'. - func getEventWithId(_ id_: Int64) -> EmitterEvent? { - var event: EmitterEvent? = nil - queue?.inDatabase({ db in - if db.open() { - if let s = try? db.executeQuery(_querySelectId, values: [id_]) { - while s.next() { - if let data = s.data(forColumn: "eventData"), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let payload = Payload(dictionary: dict) - event = EmitterEvent(payload: payload, storeId: id_) - } - } - s.close() - } - } - }) - return event - } - - /// Returns all the events in an array of dictionaries. - /// - Returns: An array with each dictionary element containing key-value pairs of 'date', 'data', 'ID'. - func getAllEvents() -> [EmitterEvent]? { - return self.getAllEvents(withQuery: _querySelectAll) - } - - /// Returns limited number the events that are NOT pending in an array of dictionaries. - /// - Returns: An array with each dictionary element containing key-value pairs of 'date', 'data', 'ID'. - func getAllEventsLimited(_ limit: UInt) -> [EmitterEvent]? { - let query = "\(_querySelectAll) LIMIT \((NSNumber(value: limit)).stringValue)" - return getAllEvents(withQuery: query) - } - - func getAllEvents(withQuery query: String) -> [EmitterEvent]? { - var res: [EmitterEvent] = [] - queue?.inDatabase({ db in - if db.open() { - if let s = try? db.executeQuery(query, values: []) { - while s.next() { - let index = s.longLongInt(forColumn: "ID") - if let data = s.data(forColumn: "eventData"), - let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - let payload = Payload(dictionary: dict) - let event = EmitterEvent(payload: payload, storeId: index) - res.append(event) - } - } - s.close() - } - } - }) - return res - } - - /// The row ID of the last insert made. - /// - Returns: The row ID of the last insert made. - func getLastInsertedRowId() -> Int64 { - var res: Int64 = -1 - queue?.inDatabase({ db in - res = db.lastInsertRowId - }) - return res + + func removeOldEvents(maxSize: Int64, maxAge: TimeInterval) { + InternalQueue.onQueuePrecondition() + + return database.removeOldEvents(maxSize: maxSize, maxAge: maxAge) } } diff --git a/Sources/Core/Subject/PlatformContext.swift b/Sources/Core/Subject/PlatformContext.swift index db59ecd39..1c2c8044a 100644 --- a/Sources/Core/Subject/PlatformContext.swift +++ b/Sources/Core/Subject/PlatformContext.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -12,7 +12,7 @@ // language governing permissions and limitations there under. import Foundation -#if os(iOS) +#if os(iOS) || os(visionOS) import UIKit #endif @@ -26,25 +26,31 @@ class PlatformContext { private var lastUpdatedEphemeralNetworkDict: TimeInterval = 0.0 private var deviceInfoMonitor: DeviceInfoMonitor + /// Overrides for retrieving property values + var platformContextRetriever: PlatformContextRetriever + /// List of properties of the platform context to track var platformContextProperties: [PlatformContextProperty]? /// Initializes a newly allocated PlatformContext object with custom update frequency for mobile and network properties and a custom device info monitor /// - Parameters: /// - platformContextProperties: List of properties of the platform context to track + /// - platformContextRetriever: Overrides for the property retrieving behavior /// - mobileDictUpdateFrequency: Minimal gap between subsequent updates of mobile platform information /// - networkDictUpdateFrequency: Minimal gap between subsequent updates of network platform information /// - deviceInfoMonitor: Device monitor for fetching platform information /// - Returns: a PlatformContext object init(platformContextProperties: [PlatformContextProperty]? = nil, + platformContextRetriever: PlatformContextRetriever? = nil, mobileDictUpdateFrequency: TimeInterval = 1.0, networkDictUpdateFrequency: TimeInterval = 10.0, deviceInfoMonitor: DeviceInfoMonitor = DeviceInfoMonitor()) { self.platformContextProperties = platformContextProperties + self.platformContextRetriever = platformContextRetriever ?? PlatformContextRetriever() self.mobileDictUpdateFrequency = mobileDictUpdateFrequency self.networkDictUpdateFrequency = networkDictUpdateFrequency self.deviceInfoMonitor = deviceInfoMonitor - #if os(iOS) + #if os(iOS) || os(visionOS) UIDevice.current.isBatteryMonitoringEnabled = true #endif setPlatformDict() @@ -52,9 +58,8 @@ class PlatformContext { /// Updates and returns payload dictionary with device context information. /// - Parameter userAnonymisation: Whether to anonymise user identifiers (IDFA values) - func fetchPlatformDict(userAnonymisation: Bool, advertisingIdentifierRetriever: (() -> UUID?)?) -> Payload { - #if os(iOS) - objc_sync_enter(self) + func fetchPlatformDict(userAnonymisation: Bool) -> Payload { + #if os(iOS) || os(visionOS) let now = Date().timeIntervalSince1970 if now - lastUpdatedEphemeralMobileDict >= mobileDictUpdateFrequency { setEphemeralMobileDict() @@ -62,7 +67,6 @@ class PlatformContext { if now - lastUpdatedEphemeralNetworkDict >= networkDictUpdateFrequency { setEphemeralNetworkDict() } - objc_sync_exit(self) #endif if userAnonymisation { // mask user identifiers @@ -71,10 +75,8 @@ class PlatformContext { copy[kSPMobileAppleIdfv] = nil return copy } else { - if let retriever = advertisingIdentifierRetriever { - if shouldTrack(.appleIdfa) && platformDict.dictionary[kSPMobileAppleIdfa] == nil { - platformDict[kSPMobileAppleIdfa] = retriever()?.uuidString - } + if shouldTrack(.appleIdfa) && platformDict.dictionary[kSPMobileAppleIdfa] == nil { + platformDict[kSPMobileAppleIdfa] = platformContextRetriever.appleIdfa?()?.uuidString } return platformDict } @@ -84,35 +86,63 @@ class PlatformContext { func setPlatformDict() { platformDict = Payload() - platformDict[kSPPlatformOsType] = deviceInfoMonitor.osType - platformDict[kSPPlatformOsVersion] = deviceInfoMonitor.osVersion - platformDict[kSPPlatformDeviceManu] = deviceInfoMonitor.deviceVendor - platformDict[kSPPlatformDeviceModel] = deviceInfoMonitor.deviceModel + platformDict[kSPPlatformOsType] = ( + platformContextRetriever.osType == nil ? + deviceInfoMonitor.osType : platformContextRetriever.osType?() + ) + platformDict[kSPPlatformOsVersion] = ( + platformContextRetriever.osVersion == nil ? + deviceInfoMonitor.osVersion : platformContextRetriever.osVersion?() + ) + platformDict[kSPPlatformDeviceManu] = ( + platformContextRetriever.deviceVendor == nil ? + deviceInfoMonitor.deviceVendor : platformContextRetriever.deviceVendor?() + ) + platformDict[kSPPlatformDeviceModel] = ( + platformContextRetriever.deviceModel == nil ? + deviceInfoMonitor.deviceModel : platformContextRetriever.deviceModel?() + ) - #if os(iOS) + #if os(iOS) || os(visionOS) setMobileDict() #endif } func setMobileDict() { if shouldTrack(.resolution) { - platformDict[kSPMobileResolution] = deviceInfoMonitor.resolution + platformDict[kSPMobileResolution] = ( + platformContextRetriever.resolution == nil ? + deviceInfoMonitor.resolution : platformContextRetriever.resolution?() + ) } if shouldTrack(.language) { // the schema has a max-length 8 for language which iOS exceeds sometimes - if let language = deviceInfoMonitor.language { platformDict[kSPMobileLanguage] = String(language.prefix(8)) } + let language = ( + platformContextRetriever.language == nil ? + deviceInfoMonitor.language : platformContextRetriever.language?() + ) + if let language = language { platformDict[kSPMobileLanguage] = String(language.prefix(8)) } } if shouldTrack(.scale) { - platformDict[kSPMobileScale] = deviceInfoMonitor.scale + platformDict[kSPMobileScale] = ( + platformContextRetriever.scale == nil ? + deviceInfoMonitor.scale : platformContextRetriever.scale?() + ) } if shouldTrack(.carrier) { - platformDict[kSPMobileCarrier] = deviceInfoMonitor.carrierName + platformDict[kSPMobileCarrier] = ( + platformContextRetriever.carrier == nil ? + deviceInfoMonitor.carrierName : platformContextRetriever.carrier?() + ) } if shouldTrack(.totalStorage) { - platformDict[kSPMobileTotalStorage] = deviceInfoMonitor.totalStorage + platformDict[kSPMobileTotalStorage] = platformContextRetriever.totalStorage?() } if shouldTrack(.physicalMemory) { - platformDict[kSPMobilePhysicalMemory] = deviceInfoMonitor.physicalMemory + platformDict[kSPMobilePhysicalMemory] = ( + platformContextRetriever.physicalMemory == nil ? + deviceInfoMonitor.physicalMemory : platformContextRetriever.physicalMemory?() + ) } setEphemeralMobileDict() @@ -123,26 +153,44 @@ class PlatformContext { lastUpdatedEphemeralMobileDict = Date().timeIntervalSince1970 if shouldTrack(.appleIdfv) && platformDict[kSPMobileAppleIdfv] == nil { - platformDict[kSPMobileAppleIdfv] = deviceInfoMonitor.appleIdfv + platformDict[kSPMobileAppleIdfv] = ( + platformContextRetriever.appleIdfv == nil ? + deviceInfoMonitor.appleIdfv : platformContextRetriever.appleIdfv?() + ) } if shouldTrack(.batteryLevel) { - platformDict[kSPMobileBatteryLevel] = deviceInfoMonitor.batteryLevel + platformDict[kSPMobileBatteryLevel] = ( + platformContextRetriever.batteryLevel == nil ? + deviceInfoMonitor.batteryLevel : platformContextRetriever.batteryLevel?() + ) } if shouldTrack(.batteryState) { - platformDict[kSPMobileBatteryState] = deviceInfoMonitor.batteryState + platformDict[kSPMobileBatteryState] = ( + platformContextRetriever.batteryState == nil ? + deviceInfoMonitor.batteryState : platformContextRetriever.batteryState?() + ) } if shouldTrack(.lowPowerMode) { - platformDict[kSPMobileLowPowerMode] = deviceInfoMonitor.isLowPowerModeEnabled + platformDict[kSPMobileLowPowerMode] = ( + platformContextRetriever.lowPowerMode == nil ? + deviceInfoMonitor.isLowPowerModeEnabled : platformContextRetriever.lowPowerMode?() + ) } if shouldTrack(.availableStorage) { - platformDict[kSPMobileAvailableStorage] = deviceInfoMonitor.availableStorage + platformDict[kSPMobileAvailableStorage] = platformContextRetriever.availableStorage?() } if shouldTrack(.appAvailableMemory) { - platformDict[kSPMobileAppAvailableMemory] = deviceInfoMonitor.appAvailableMemory + platformDict[kSPMobileAppAvailableMemory] = ( + platformContextRetriever.appAvailableMemory == nil ? + deviceInfoMonitor.appAvailableMemory : platformContextRetriever.appAvailableMemory?() + ) } if shouldTrack(.isPortrait) { - platformDict[kSPMobileIsPortrait] = deviceInfoMonitor.isPortrait + platformDict[kSPMobileIsPortrait] = ( + platformContextRetriever.isPortrait == nil ? + deviceInfoMonitor.isPortrait : platformContextRetriever.isPortrait?() + ) } } @@ -150,10 +198,16 @@ class PlatformContext { lastUpdatedEphemeralNetworkDict = Date().timeIntervalSince1970 if shouldTrack(.networkTechnology) { - platformDict[kSPMobileNetworkTech] = deviceInfoMonitor.networkTechnology + platformDict[kSPMobileNetworkTech] = ( + platformContextRetriever.networkTechnology == nil ? + deviceInfoMonitor.networkTechnology : platformContextRetriever.networkTechnology?() + ) } if shouldTrack(.networkType) { - platformDict[kSPMobileNetworkType] = deviceInfoMonitor.networkType + platformDict[kSPMobileNetworkType] = ( + platformContextRetriever.networkType == nil ? + deviceInfoMonitor.networkType : platformContextRetriever.networkType?() + ) } } diff --git a/Sources/Core/Subject/Subject.swift b/Sources/Core/Subject/Subject.swift index 48dd09735..90588cce9 100644 --- a/Sources/Core/Subject/Subject.swift +++ b/Sources/Core/Subject/Subject.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -17,19 +17,30 @@ import Foundation /// This class is used to access and persist user information, it represents the current user being tracked. class Subject : NSObject { private var standardDict: [String : String] = [:] - private var platformContextManager: PlatformContext private var geoDict: [String : NSObject] = [:] + + var geoLocationContext = false + + // MARK: - Platform context + private var platformContextManager: PlatformContext + var platformContext = false + var platformContextProperties: [PlatformContextProperty]? { - get { - platformContextManager.platformContextProperties - } - set { - platformContextManager.platformContextProperties = newValue - } + get { return platformContextManager.platformContextProperties } + set { platformContextManager.platformContextProperties = newValue } + } + + var platformContextRetriever: PlatformContextRetriever { + get { return platformContextManager.platformContextRetriever } + set { platformContextManager.platformContextRetriever = newValue } + } + + var advertisingIdentifierRetriever: (() -> UUID?)? { + get { return platformContextManager.platformContextRetriever.appleIdfa } + set { platformContextManager.platformContextRetriever.appleIdfa = newValue } } - var geoLocationContext = false // MARK: - Standard Dictionary @@ -38,9 +49,7 @@ class Subject : NSObject { private var _userId: String? /// The user's ID. var userId: String? { - get { - _userId - } + get { return _userId } set(uid) { _userId = uid standardDict[kSPUid] = uid @@ -49,9 +58,7 @@ class Subject : NSObject { private var _networkUserId: String? var networkUserId: String? { - get { - _networkUserId - } + get { return _networkUserId } set(nuid) { _networkUserId = nuid standardDict[kSPNetworkUid] = nuid @@ -61,9 +68,7 @@ class Subject : NSObject { private var _domainUserId: String? /// The domain UID. var domainUserId: String? { - get { - _domainUserId - } + get { return _domainUserId } set(duid) { _domainUserId = duid standardDict[kSPDomainUid] = duid @@ -73,9 +78,7 @@ class Subject : NSObject { private var _useragent: String? /// The user agent (also known as browser string). var useragent: String? { - get { - _useragent - } + get { return _useragent } set(useragent) { _useragent = useragent standardDict[kSPUseragent] = useragent @@ -85,9 +88,7 @@ class Subject : NSObject { private var _ipAddress: String? /// The user's IP address. var ipAddress: String? { - get { - _ipAddress - } + get { return _ipAddress } set(ip) { _ipAddress = ip standardDict[kSPIpAddress] = ip @@ -97,9 +98,7 @@ class Subject : NSObject { private var _timezone: String? /// The user's timezone. var timezone: String? { - get { - _timezone - } + get { return _timezone } set(timezone) { _timezone = timezone standardDict[kSPTimezone] = timezone @@ -109,9 +108,7 @@ class Subject : NSObject { private var _language: String? /// The user's language. var language: String? { - get { - _language - } + get { return _language } set(lang) { _language = lang standardDict[kSPLanguage] = lang @@ -121,9 +118,7 @@ class Subject : NSObject { private var _colorDepth: NSNumber? /// The user's color depth. var colorDepth: NSNumber? { - get { - _colorDepth - } + get { return _colorDepth } set(depth) { _colorDepth = depth let res = "\(depth?.stringValue ?? "")" @@ -133,9 +128,7 @@ class Subject : NSObject { var _screenResolution: SPSize? var screenResolution: SPSize? { - get { - _screenResolution - } + get { return _screenResolution } set { _screenResolution = newValue if let size = newValue { @@ -149,9 +142,7 @@ class Subject : NSObject { var _screenViewPort: SPSize? var screenViewPort: SPSize? { - get { - _screenViewPort - } + get { return _screenViewPort } set { _screenViewPort = newValue if let size = newValue { @@ -160,7 +151,6 @@ class Subject : NSObject { } else { standardDict.removeValue(forKey: kSPViewPort) } - } } @@ -170,92 +160,64 @@ class Subject : NSObject { /// Latitude value for the geolocation context. var geoLatitude: NSNumber? { - get { - return geoDict[kSPGeoLatitude] as? NSNumber - } - set(latitude) { - geoDict[kSPGeoLatitude] = latitude - } + get { return geoDict[kSPGeoLatitude] as? NSNumber } + set(latitude) { geoDict[kSPGeoLatitude] = latitude } } /// Longitude value for the geo context. var geoLongitude: NSNumber? { - get { - return geoDict[kSPGeoLongitude] as? NSNumber - } - set(longitude) { - geoDict[kSPGeoLongitude] = longitude - } + get { return geoDict[kSPGeoLongitude] as? NSNumber } + set(longitude) { geoDict[kSPGeoLongitude] = longitude } } /// LatitudeLongitudeAccuracy value for the geolocation context. var geoLatitudeLongitudeAccuracy: NSNumber? { - get { - return geoDict[kSPGeoLatLongAccuracy] as? NSNumber - } - set(latitudeLongitudeAccuracy) { - geoDict[kSPGeoLatLongAccuracy] = latitudeLongitudeAccuracy - } + get { return geoDict[kSPGeoLatLongAccuracy] as? NSNumber } + set { geoDict[kSPGeoLatLongAccuracy] = newValue } } /// Altitude value for the geolocation context. var geoAltitude: NSNumber? { - get { - return geoDict[kSPGeoAltitude] as? NSNumber - } - set(altitude) { - geoDict[kSPGeoAltitude] = altitude - } + get { return geoDict[kSPGeoAltitude] as? NSNumber } + set(altitude) { geoDict[kSPGeoAltitude] = altitude } } /// AltitudeAccuracy value for the geolocation context. var geoAltitudeAccuracy: NSNumber? { - get { - return geoDict[kSPGeoAltitudeAccuracy] as? NSNumber - } - set(altitudeAccuracy) { - geoDict[kSPGeoAltitudeAccuracy] = altitudeAccuracy - } + get { return geoDict[kSPGeoAltitudeAccuracy] as? NSNumber } + set(altitudeAccuracy) { geoDict[kSPGeoAltitudeAccuracy] = altitudeAccuracy } } var geoBearing: NSNumber? { - get { - return geoDict[kSPGeoBearing] as? NSNumber - } - set(bearing) { - geoDict[kSPGeoBearing] = bearing - } + get { return geoDict[kSPGeoBearing] as? NSNumber } + set(bearing) { geoDict[kSPGeoBearing] = bearing } } /// Speed value for the geolocation context. var geoSpeed: NSNumber? { - get { - return geoDict[kSPGeoSpeed] as? NSNumber - } - set(speed) { - geoDict[kSPGeoSpeed] = speed - } + get { return geoDict[kSPGeoSpeed] as? NSNumber } + set(speed) { geoDict[kSPGeoSpeed] = speed } } /// Timestamp value for the geolocation context. var geoTimestamp: NSNumber? { - get { - return geoDict[kSPGeoTimestamp] as? NSNumber - } - set(timestamp) { - geoDict[kSPGeoTimestamp] = timestamp - } + get { return geoDict[kSPGeoTimestamp] as? NSNumber } + set(timestamp) { geoDict[kSPGeoTimestamp] = timestamp } } init(platformContext: Bool = false, platformContextProperties: [PlatformContextProperty]? = nil, + platformContextRetriever: PlatformContextRetriever? = nil, geoLocationContext geoContext: Bool = false, subjectConfiguration config: SubjectConfiguration? = nil) { - self.platformContextManager = PlatformContext(platformContextProperties: platformContextProperties) + self.platformContextManager = PlatformContext( + platformContextProperties: platformContextProperties, + platformContextRetriever: platformContextRetriever + ) super.init() - self.platformContextProperties = platformContextProperties + platformContextManager.platformContextProperties = platformContextProperties self.platformContext = platformContext - geoLocationContext = geoContext + self.geoLocationContext = geoContext screenResolution = Utilities.resolution screenViewPort = Utilities.viewPort @@ -294,24 +256,22 @@ class Subject : NSObject { func standardDict(userAnonymisation: Bool) -> [String : String] { if userAnonymisation { - var copy = standardDict + var copy = self.standardDict copy.removeValue(forKey: kSPUid) copy.removeValue(forKey: kSPDomainUid) copy.removeValue(forKey: kSPNetworkUid) copy.removeValue(forKey: kSPIpAddress) return copy } - return standardDict + return self.standardDict } /// Gets all platform dictionary pairs to decorate event with. Returns nil if not enabled. /// - Parameter userAnonymisation: Whether to anonymise user identifiers /// - Returns: A SPPayload with all platform specific pairs. - func platformDict(userAnonymisation: Bool, advertisingIdentifierRetriever: (() -> UUID?)?) -> Payload? { + func platformDict(userAnonymisation: Bool) -> Payload? { if platformContext { - return platformContextManager.fetchPlatformDict( - userAnonymisation: userAnonymisation, - advertisingIdentifierRetriever: advertisingIdentifierRetriever) + return platformContextManager.fetchPlatformDict(userAnonymisation: userAnonymisation) } else { return nil } @@ -331,4 +291,5 @@ class Subject : NSObject { return nil } } + } diff --git a/Sources/Core/Subject/SubjectControllerImpl.swift b/Sources/Core/Subject/SubjectControllerImpl.swift index 4768ccd91..4cb8b8f16 100644 --- a/Sources/Core/Subject/SubjectControllerImpl.swift +++ b/Sources/Core/Subject/SubjectControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Tracker/EcommerceControllerImpl.swift b/Sources/Core/Tracker/EcommerceControllerImpl.swift index 16d4b2e77..a7348e238 100644 --- a/Sources/Core/Tracker/EcommerceControllerImpl.swift +++ b/Sources/Core/Tracker/EcommerceControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Tracker/InstallTracker.swift b/Sources/Core/Tracker/InstallTracker.swift index 1898a1619..c31fbb7aa 100644 --- a/Sources/Core/Tracker/InstallTracker.swift +++ b/Sources/Core/Tracker/InstallTracker.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Tracker/PluginsControllerImpl.swift b/Sources/Core/Tracker/PluginsControllerImpl.swift index 60c913354..6a89ab9cb 100644 --- a/Sources/Core/Tracker/PluginsControllerImpl.swift +++ b/Sources/Core/Tracker/PluginsControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Tracker/ServiceProvider.swift b/Sources/Core/Tracker/ServiceProvider.swift index 4fd828af1..b3ed43bc1 100644 --- a/Sources/Core/Tracker/ServiceProvider.swift +++ b/Sources/Core/Tracker/ServiceProvider.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -14,7 +14,7 @@ import Foundation class ServiceProvider: NSObject, ServiceProviderProtocol { - private(set) var namespace: String + let namespace: String var isTrackerInitialized: Bool { return _tracker != nil } @@ -227,33 +227,46 @@ class ServiceProvider: NSObject, ServiceProviderProtocol { return Subject( platformContext: trackerConfiguration.platformContext, platformContextProperties: trackerConfiguration.platformContextProperties, + platformContextRetriever: trackerConfiguration.platformContextRetriever, geoLocationContext: trackerConfiguration.geoLocationContext, subjectConfiguration: subjectConfiguration) } func makeEmitter() -> Emitter { let builder = { (emitter: Emitter) in - emitter.method = self.networkConfiguration.method - emitter.protocol = self.networkConfiguration.protocol - emitter.customPostPath = self.networkConfiguration.customPostPath - emitter.requestHeaders = self.networkConfiguration.requestHeaders emitter.emitThreadPoolSize = self.emitterConfiguration.threadPoolSize emitter.byteLimitGet = self.emitterConfiguration.byteLimitGet emitter.byteLimitPost = self.emitterConfiguration.byteLimitPost - emitter.serverAnonymisation = self.emitterConfiguration.serverAnonymisation emitter.emitRange = self.emitterConfiguration.emitRange emitter.bufferOption = self.emitterConfiguration.bufferOption - emitter.eventStore = self.emitterConfiguration.eventStore emitter.callback = self.emitterConfiguration.requestCallback emitter.customRetryForStatusCodes = self.emitterConfiguration.customRetryForStatusCodes emitter.retryFailedRequests = self.emitterConfiguration.retryFailedRequests + emitter.maxEventStoreSize = self.emitterConfiguration.maxEventStoreSize + emitter.maxEventStoreAge = self.emitterConfiguration.maxEventStoreAge } let emitter: Emitter if let networkConnection = networkConfiguration.networkConnection { - emitter = Emitter(networkConnection: networkConnection, builder: builder) + emitter = Emitter( + networkConnection: networkConnection, + namespace: self.namespace, + eventStore: self.emitterConfiguration.eventStore, + builder: builder + ) } else { - emitter = Emitter(urlEndpoint: networkConfiguration.endpoint ?? "", builder: builder) + emitter = Emitter( + namespace: self.namespace, + urlEndpoint: networkConfiguration.endpoint ?? "", + method: self.networkConfiguration.method, + protocol: self.networkConfiguration.protocol, + customPostPath: self.networkConfiguration.customPostPath, + requestHeaders: self.networkConfiguration.requestHeaders, + serverAnonymisation: self.emitterConfiguration.serverAnonymisation, + eventStore: self.emitterConfiguration.eventStore, + timeout: self.networkConfiguration.timeout, + builder: builder + ) } if emitterConfiguration.isPaused { @@ -286,12 +299,13 @@ 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 tracker.trackerDiagnostic = trackerConfiguration.diagnosticAutotracking tracker.userAnonymisation = trackerConfiguration.userAnonymisation - tracker.advertisingIdentifierRetriever = trackerConfiguration.advertisingIdentifierRetriever + tracker.immersiveSpaceContext = trackerConfiguration.immersiveSpaceContext if gdprConfiguration.sourceConfig != nil { tracker.gdprContext = GDPRContext( basis: gdprConfiguration.basisForProcessing, diff --git a/Sources/Core/Tracker/ServiceProviderProtocol.swift b/Sources/Core/Tracker/ServiceProviderProtocol.swift index 6435d79a7..cfd07a51c 100644 --- a/Sources/Core/Tracker/ServiceProviderProtocol.swift +++ b/Sources/Core/Tracker/ServiceProviderProtocol.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Core/Tracker/Tracker.swift b/Sources/Core/Tracker/Tracker.swift index ee9dffa75..bf0ea3441 100644 --- a/Sources/Core/Tracker/Tracker.swift +++ b/Sources/Core/Tracker/Tracker.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -40,10 +40,8 @@ func uncaughtExceptionHandler(_ exception: NSException) { class Tracker: NSObject { private var platformContextSchema: String = "" private var dataCollection = true - private var builderFinished = false - /// The object used for sessionization, i.e. it characterizes user activity. private(set) var session: Session? /// Previous screen view state. @@ -51,8 +49,7 @@ class Tracker: NSObject { /// Current screen view state. private(set) var currentScreenState: ScreenState? - private var trackerData: [String : String]? = nil - func setTrackerData() { + private func trackerPayloadData() -> [String : String] { var trackerVersion = kSPVersion if trackerVersionSuffix.count != 0 { var allowedCharSet = CharacterSet.alphanumerics @@ -62,25 +59,17 @@ class Tracker: NSObject { trackerVersion = "\(trackerVersion) \(suffix)" } } - trackerData = [ - kSPTrackerVersion : trackerVersion, - kSPNamespace : trackerNamespace, - kSPAppId : appId + return [ + kSPTrackerVersion: trackerVersion, + kSPNamespace: trackerNamespace, + kSPAppId: appId ] } // MARK: - Setter - private var _emitter: Emitter /// The emitter used to send events. - var emitter: Emitter { - get { - return _emitter - } - set(emitter) { - _emitter = emitter - } - } + let emitter: Emitter /// The subject used to represent the current user and persist user information. var subject: Subject? @@ -89,46 +78,13 @@ class Tracker: NSObject { var base64Encoded = TrackerDefaults.base64Encoded /// A unique identifier for an application. - private var _appId: String - var appId: String { - get { - return _appId - } - set(appId) { - _appId = appId - if builderFinished && trackerData != nil { - setTrackerData() - } - } - } + var appId: String - private(set) var _trackerNamespace: String /// The identifier for the current tracker. - var trackerNamespace: String { - get { - return _trackerNamespace - } - set(trackerNamespace) { - _trackerNamespace = trackerNamespace - if builderFinished && trackerData != nil { - setTrackerData() - } - } - } + let trackerNamespace: String /// Version suffix for tracker wrappers. - private var _trackerVersionSuffix: String = TrackerDefaults.trackerVersionSuffix - var trackerVersionSuffix: String { - get { - return _trackerVersionSuffix - } - set(trackerVersionSuffix) { - _trackerVersionSuffix = trackerVersionSuffix - if builderFinished && trackerData != nil { - setTrackerData() - } - } - } + var trackerVersionSuffix: String = TrackerDefaults.trackerVersionSuffix var devicePlatform: DevicePlatform = TrackerDefaults.devicePlatform @@ -163,8 +119,9 @@ class Tracker: NSObject { } else if builderFinished && session == nil && sessionContext { session = Session( foregroundTimeout: foregroundTimeout, - andBackgroundTimeout: backgroundTimeout, - andTracker: self) + backgroundTimeout: backgroundTimeout, + trackerNamespace: trackerNamespace, + tracker: self) } } } @@ -175,14 +132,12 @@ class Tracker: NSObject { return _deepLinkContext } set(deepLinkContext) { - objc_sync_enter(self) - _deepLinkContext = deepLinkContext + self._deepLinkContext = deepLinkContext if deepLinkContext { - addOrReplace(stateMachine: DeepLinkStateMachine()) + self.addOrReplace(stateMachine: DeepLinkStateMachine()) } else { - _ = stateManager.removeStateMachine(DeepLinkStateMachine.identifier) + _ = self.stateManager.removeStateMachine(DeepLinkStateMachine.identifier) } - objc_sync_exit(self) } } @@ -192,14 +147,25 @@ class Tracker: NSObject { return _screenContext } set(screenContext) { - objc_sync_enter(self) - _screenContext = screenContext + self._screenContext = screenContext if screenContext { - addOrReplace(stateMachine: ScreenStateMachine()) + self.addOrReplace(stateMachine: ScreenStateMachine()) } else { - _ = stateManager.removeStateMachine(ScreenStateMachine.identifier) + _ = self.stateManager.removeStateMachine(ScreenStateMachine.identifier) + } + } + } + + 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) } - objc_sync_exit(self) } } @@ -241,14 +207,12 @@ class Tracker: NSObject { return _lifecycleEvents } set(lifecycleEvents) { - objc_sync_enter(self) - _lifecycleEvents = lifecycleEvents + self._lifecycleEvents = lifecycleEvents if lifecycleEvents { - addOrReplace(stateMachine: LifecycleStateMachine()) + self.addOrReplace(stateMachine: LifecycleStateMachine()) } else { - _ = stateManager.removeStateMachine(LifecycleStateMachine.identifier) + _ = self.stateManager.removeStateMachine(LifecycleStateMachine.identifier) } - objc_sync_exit(self) } } @@ -268,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 @@ -282,21 +261,19 @@ class Tracker: NSObject { var isTracking: Bool { return dataCollection } - - var advertisingIdentifierRetriever: (() -> UUID?)? init(trackerNamespace: String, appId: String?, emitter: Emitter, builder: ((Tracker) -> (Void))) { - self._emitter = emitter - self._appId = appId ?? "" - self._trackerNamespace = trackerNamespace + self.emitter = emitter + self.appId = appId ?? "" + self.trackerNamespace = trackerNamespace super.init() builder(self) - #if os(iOS) + #if os(iOS) || os(visionOS) platformContextSchema = kSPMobileContextSchema #else platformContextSchema = kSPDesktopContextSchema @@ -307,14 +284,12 @@ class Tracker: NSObject { } private func setup() { - emitter.namespace = trackerNamespace // Needed to correctly send events to the right EventStore - setTrackerData() - if sessionContext { session = Session( foregroundTimeout: foregroundTimeout, - andBackgroundTimeout: backgroundTimeout, - andTracker: self) + backgroundTimeout: backgroundTimeout, + trackerNamespace: trackerNamespace, + tracker: self) } UIKitScreenViewTracking.setup() @@ -345,7 +320,9 @@ class Tracker: NSObject { private func checkInstall() { if installEvent { - DispatchQueue.global(qos: .default).async { [weak self] in + InternalQueue.async { [weak self] in + guard let self = self else { return } + let installTracker = InstallTracker() let previousTimestamp = installTracker.previousInstallTimestamp installTracker.clearPreviousInstallTimestamp() @@ -356,7 +333,7 @@ class Tracker: NSObject { let installEvent = SelfDescribingJson(schema: kSPApplicationInstallSchema, andDictionary: data) let event = SelfDescribing(eventData: installEvent) event.trueTimestamp = previousTimestamp // it can be nil - let _ = self?.track(event) + let _ = self.track(event) } } } @@ -399,13 +376,15 @@ class Tracker: NSObject { let topViewControllerClassName = notification.userInfo?["topViewControllerClassName"] as? String let viewControllerClassName = notification.userInfo?["viewControllerClassName"] as? String - - if autotrackScreenViews { - let event = ScreenView(name: name, screenId: nil) - event.type = type - event.viewControllerClassName = viewControllerClassName - event.topViewControllerClassName = topViewControllerClassName - let _ = track(event) + + InternalQueue.async { + if self.autotrackScreenViews { + let event = ScreenView(name: name, screenId: nil) + event.type = type + event.viewControllerClassName = viewControllerClassName + event.topViewControllerClassName = topViewControllerClassName + let _ = self.track(event) + } } } @@ -416,9 +395,11 @@ class Tracker: NSObject { let error = userInfo?["error"] as? Error let exception = userInfo?["exception"] as? NSException - if trackerDiagnostic { - let event = TrackerError(source: tag, message: message, error: error, exception: exception) - let _ = track(event) + InternalQueue.async { + if self.trackerDiagnostic { + let event = TrackerError(source: tag, message: message, error: error, exception: exception) + let _ = self.track(event) + } } } @@ -427,10 +408,12 @@ class Tracker: NSObject { guard let message = userInfo?["message"] as? String else { return } let stacktrace = userInfo?["stacktrace"] as? String - if exceptionEvents { - let event = SNOWError(message: message) - event.stackTrace = stacktrace - let _ = track(event) + InternalQueue.async { + if self.exceptionEvents { + let event = SNOWError(message: message) + event.stackTrace = stacktrace + let _ = self.track(event) + } } } @@ -438,31 +421,38 @@ class Tracker: NSObject { /// Tracks an event despite its specific type. /// - Parameter event: The event to track - /// - Returns: The event ID or nil in case tracking is paused - func track(_ event: Event) -> UUID? { - if !dataCollection { - return nil + /// - Returns: The event ID + func track(_ event: Event, eventId: UUID = UUID()) -> UUID { + InternalQueue.onQueuePrecondition() + + if dataCollection { + let events = withEventsBefore(event: event, eventId: eventId) + for (event, eventId) in events { + event.beginProcessing(withTracker: self) + self.processEvent(event, eventId) + event.endProcessing(withTracker: self) + } } - event.beginProcessing(withTracker: self) - let eventId = processEvent(event) - 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) -> UUID? { - objc_sync_enter(self) + func processEvent(_ event: Event, _ eventId: UUID) { let stateSnapshot = stateManager.trackerState(forProcessedEvent: event) - objc_sync_exit(self) - let trackerEvent = TrackerEvent(event: event, state: stateSnapshot) + let trackerEvent = TrackerEvent(event: event, eventId: eventId, state: stateSnapshot) if let payload = self.payload(with: trackerEvent) { emitter.addPayload(toBuffer: payload) stateManager.afterTrack(event: trackerEvent) - return trackerEvent.eventId + } else { + logDebug(message: "Event not tracked due to filtering") } - logDebug(message: "Event not tracked due to filtering") - return nil } func payload(with event: TrackerEvent) -> Payload? { @@ -514,9 +504,7 @@ class Tracker: NSObject { payload.addValueToPayload(String(format: "%lld", ttInMilliSeconds), forKey: kSPTrueTimestamp) } // Tracker info (version, namespace, app ID) - if let trackerData = trackerData { - payload.addDictionaryToPayload(trackerData) - } + payload.addDictionaryToPayload(trackerPayloadData()) // Subject if let subjectDict = subject?.standardDict(userAnonymisation: userAnonymisation) { payload.addDictionaryToPayload(subjectDict) @@ -568,9 +556,7 @@ class Tracker: NSObject { func addBasicContexts(event: TrackerEvent) { if subject != nil { - if let platformDict = subject?.platformDict( - userAnonymisation: userAnonymisation, - advertisingIdentifierRetriever: advertisingIdentifierRetriever)?.dictionary { + if let platformDict = subject?.platformDict(userAnonymisation: userAnonymisation)?.dictionary { event.addContextEntity(SelfDescribingJson(schema: platformContextSchema, andDictionary: platformDict)) } if let geoLocationDict = subject?.geoLocationDict { diff --git a/Sources/Core/Tracker/TrackerControllerImpl.swift b/Sources/Core/Tracker/TrackerControllerImpl.swift index afc0a5f90..20b3367a4 100644 --- a/Sources/Core/Tracker/TrackerControllerImpl.swift +++ b/Sources/Core/Tracker/TrackerControllerImpl.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -69,10 +69,64 @@ class TrackerControllerImpl: Controller, TrackerController { dirtyConfig.isPaused = false tracker.resumeEventTracking() } + + func track(_ event: Event, eventId: UUID) { + _ = tracker.track(event, eventId: eventId) + } - func track(_ event: Event) -> UUID? { + func track(_ event: Event) -> UUID { return tracker.track(event) } + + func decorateLink(_ url: URL) -> URL? { + self.decorateLink(url, extendedParameters: CrossDeviceParameterConfiguration()) + } + + func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL? { + var userId: String + switch self.session?.userId { + case .none: + logError(message: "\(url) could not be decorated as session.userId is nil") + return nil + case .some(let id): + userId = id + } + + let sessionId = extendedParameters.sessionId ? self.session?.sessionId ?? "" : "" + if (extendedParameters.sessionId && sessionId.isEmpty) { + logDebug(message: "\(decorateLinkErrorTemplate("sessionId")) Ensure an event has been tracked to generate a session before calling this method.") + } + + let sourceId = extendedParameters.sourceId ? self.appId : "" + + let sourcePlatform = extendedParameters.sourcePlatform ? devicePlatformToString(self.devicePlatform) : "" + + let subjectUserId = extendedParameters.subjectUserId ? self.subject?.userId ?? "" : "" + if (extendedParameters.subjectUserId && subjectUserId.isEmpty) { + logDebug(message: "\(decorateLinkErrorTemplate("subjectUserId")) Ensure SubjectConfiguration.userId has been set on your tracker.") + } + + let reason = extendedParameters.reason ?? "" + + let spParameters = [ + userId, + String(Int(Date().timeIntervalSince1970 * 1000)), + sessionId, + subjectUserId.toBase64(), + sourceId.toBase64(), + sourcePlatform, + reason.toBase64() + ].joined(separator: ".").trimmingCharacters(in: ["."]) + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let spQueryParam = URLQueryItem(name: "_sp", value: spParameters) + + // Modification requires exclusive access, we must make a copy + let queryItems = components?.queryItems + components?.queryItems = (queryItems?.filter { $0.name != "_sp" } ?? []) + [spQueryParam] + + return components?.url + } // MARK: - Properties' setters and getters @@ -157,6 +211,11 @@ class TrackerControllerImpl: Controller, TrackerController { tracker.subject?.platformContextProperties = newValue } } + + var platformContextRetriever: PlatformContextRetriever? { + get { return tracker.subject?.platformContextRetriever } + set { if let retriever = newValue { tracker.subject?.platformContextRetriever = retriever } } + } var geoLocationContext: Bool { get { @@ -237,6 +296,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 { @@ -269,14 +336,24 @@ 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 { - return tracker.advertisingIdentifierRetriever + return tracker.subject?.advertisingIdentifierRetriever } set { dirtyConfig.advertisingIdentifierRetriever = newValue - tracker.advertisingIdentifierRetriever = newValue + tracker.subject?.advertisingIdentifierRetriever = newValue } } @@ -301,4 +378,8 @@ class TrackerControllerImpl: Controller, TrackerController { private var dirtyConfig: TrackerConfiguration { return serviceProvider.trackerConfiguration } + + private func decorateLinkErrorTemplate(_ extendedParameterName: String) -> String { + "\(extendedParameterName) has been requested in CrossDeviceParameterConfiguration, but it is not set." + } } diff --git a/Sources/Core/Tracker/TrackerDefaults.swift b/Sources/Core/Tracker/TrackerDefaults.swift index 30b0f9a90..b5e79f7f4 100644 --- a/Sources/Core/Tracker/TrackerDefaults.swift +++ b/Sources/Core/Tracker/TrackerDefaults.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -24,11 +24,13 @@ class TrackerDefaults { private(set) static var screenContext = true private(set) static var applicationContext = true private(set) static var autotrackScreenViews = true - private(set) static var lifecycleEvents = false + private(set) static var lifecycleEvents = true private(set) static var exceptionEvents = true private(set) static var installEvent = true private(set) static var trackerDiagnostic = false private(set) static var userAnonymisation = false 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/Tracker/TrackerEvent.swift b/Sources/Core/Tracker/TrackerEvent.swift index eee18f3f4..371708464 100644 --- a/Sources/Core/Tracker/TrackerEvent.swift +++ b/Sources/Core/Tracker/TrackerEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -39,8 +39,8 @@ class TrackerEvent : InspectableEvent, StateMachineEvent { private(set) var isService: Bool - init(event: Event, state: TrackerStateSnapshot? = nil) { - eventId = UUID() + init(event: Event, eventId: UUID = UUID(), state: TrackerStateSnapshot? = nil) { + self.eventId = eventId timestamp = Int64(Date().timeIntervalSince1970 * 1000) trueTimestamp = event.trueTimestamp entities = event.entities diff --git a/Sources/Core/Tracker/WebViewMessageHandler.swift b/Sources/Core/Tracker/WebViewMessageHandler.swift index 04873c70b..93a0c79fd 100644 --- a/Sources/Core/Tracker/WebViewMessageHandler.swift +++ b/Sources/Core/Tracker/WebViewMessageHandler.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -13,7 +13,7 @@ import Foundation -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(visionOS) import WebKit /// Handler for messages from the JavaScript library embedded in Web views. diff --git a/Sources/Core/TrackerConstants.swift b/Sources/Core/TrackerConstants.swift index 253749b51..dbc1811f9 100644 --- a/Sources/Core/TrackerConstants.swift +++ b/Sources/Core/TrackerConstants.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -14,7 +14,7 @@ import Foundation // --- Version -let kSPRawVersion = "5.6.0" +let kSPRawVersion = "6.0.0" #if os(iOS) let kSPVersion = "ios-\(kSPRawVersion)" #elseif os(tvOS) @@ -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" @@ -70,6 +71,17 @@ 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" + +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" diff --git a/Sources/Core/Utils/DataPersistence.swift b/Sources/Core/Utils/DataPersistence.swift index 9d2d3b887..e854ab1d6 100644 --- a/Sources/Core/Utils/DataPersistence.swift +++ b/Sources/Core/Utils/DataPersistence.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -24,8 +24,6 @@ var sessionKey = "session" class DataPersistence { var data: [String : [String : Any]] { get { - objc_sync_enter(self) - defer { objc_sync_exit(self) } if !isStoredOnFile { return ((UserDefaults.standard.dictionary(forKey: userDefaultsKey) ?? [:]) as? [String : [String : Any]]) ?? [:] } @@ -51,13 +49,11 @@ class DataPersistence { return result ?? [:] } set(data) { - objc_sync_enter(self) if let fileUrl = fileUrl { let _ = storeDictionary(data, fileURL: fileUrl) } else { UserDefaults.standard.set(data, forKey: userDefaultsKey) } - objc_sync_exit(self) } } @@ -66,11 +62,9 @@ class DataPersistence { return (data)[sessionKey] } set(session) { - objc_sync_enter(self) var data = self.data data[sessionKey] = session self.data = data - objc_sync_exit(self) } } @@ -100,8 +94,6 @@ class DataPersistence { if escapedNamespace.count <= 0 { return nil } - objc_sync_enter(DataPersistence.self) - defer { objc_sync_exit(DataPersistence.self) } if let instances = instances { if let instance = instances[escapedNamespace] { @@ -118,9 +110,7 @@ class DataPersistence { class func remove(withNamespace namespace: String) -> Bool { if let instance = DataPersistence.getFor(namespace: namespace) { - objc_sync_enter(DataPersistence.self) instances?.removeValue(forKey: instance.escapedNamespace) - objc_sync_exit(DataPersistence.self) let _ = instance.removeAll() } return true @@ -139,9 +129,6 @@ class DataPersistence { func removeAll() -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - UserDefaults.standard.removeObject(forKey: userDefaultsKey) if let fileUrl = fileUrl { diff --git a/Sources/Core/Utils/DeviceInfoMonitor.swift b/Sources/Core/Utils/DeviceInfoMonitor.swift index 4d03ddf17..0e3809189 100644 --- a/Sources/Core/Utils/DeviceInfoMonitor.swift +++ b/Sources/Core/Utils/DeviceInfoMonitor.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -19,7 +19,7 @@ import WatchKit #if os(iOS) import CoreTelephony #endif -#if os(iOS) || os(tvOS) +#if os(iOS) || os(tvOS) || os(visionOS) import UIKit #endif @@ -28,7 +28,7 @@ class DeviceInfoMonitor { /// Returns the generated identifier for vendors. More info can be found in UIDevice's identifierForVendor documentation. /// - Returns: A string containing a formatted UUID for example E621E1F8-C36C-495A-93FC-0C247A3E6E5F. var appleIdfv: String? { - #if os(iOS) || os(tvOS) + #if os(iOS) || os(tvOS) || os(visionOS) if let idfv = UIDevice.current.identifierForVendor?.uuidString { return idfv } @@ -38,15 +38,15 @@ class DeviceInfoMonitor { /// Returns the current device's vendor in the form of a string. /// - Returns: A string with vendor, i.e. "Apple Inc." - var deviceVendor: String? { + var deviceVendor: String { return "Apple Inc." } /// Returns the current device's model in the form of a string. /// - Returns: A string with device model. - var deviceModel: String? { + var deviceModel: String { let simulatorModel = (ProcessInfo.processInfo.environment)["SIMULATOR_MODEL_IDENTIFIER"] - if simulatorModel != nil { + if let simulatorModel = simulatorModel { return simulatorModel } @@ -59,8 +59,8 @@ class DeviceInfoMonitor { /// This is to detect what the version of mobile OS of the current device. /// - Returns: The current device's OS version type as a string. - var osVersion: String? { - #if os(iOS) || os(tvOS) + var osVersion: String { + #if os(iOS) || os(tvOS) || os(visionOS) return UIDevice.current.systemVersion #elseif os(watchOS) return WKInterfaceDevice.current().systemVersion @@ -78,13 +78,15 @@ class DeviceInfoMonitor { #endif } - var osType: String? { + var osType: String { #if os(iOS) return "ios" #elseif os(tvOS) return "tvos" #elseif os(watchOS) return "watchos" + #elseif os(visionOS) + return "visionos" #else return "osx" #endif @@ -146,7 +148,7 @@ class DeviceInfoMonitor { /// Returns the Network Type the device is connected to. /// - Returns: A string containing the Network Type. var networkType: String? { - #if os(iOS) + #if os(iOS) || os(visionOS) let networkStatus = SNOWReachability.forInternetConnection()?.networkStatus switch networkStatus { case .offline: @@ -165,7 +167,7 @@ class DeviceInfoMonitor { /// Returns remaining battery level as an integer percentage of total battery capacity. /// - Returns: Battery level. var batteryLevel: Int? { - #if os(iOS) + #if os(iOS) || os(visionOS) let batteryLevel = UIDevice.current.batteryLevel if batteryLevel != Float(UIDevice.BatteryState.unknown.rawValue) && batteryLevel >= 0 { return Int(batteryLevel * 100) @@ -177,7 +179,7 @@ class DeviceInfoMonitor { /// Returns battery state for the device. /// - Returns: One of "charging", "full", "unplugged" or NULL var batteryState: String? { - #if os(iOS) + #if os(iOS) || os(visionOS) switch UIDevice.current.batteryState { case .charging: return "charging" @@ -196,7 +198,7 @@ class DeviceInfoMonitor { /// Returns whether low power mode is activated. /// - Returns: Boolean indicating the state of low power mode. var isLowPowerModeEnabled: Bool? { - #if os(iOS) + #if os(iOS) || os(visionOS) return ProcessInfo.processInfo.isLowPowerModeEnabled #else return nil @@ -221,36 +223,6 @@ class DeviceInfoMonitor { // #endif return nil } - - /// Returns number of bytes of storage remaining. The information is requested from the home directory. - /// - Returns: Bytes of storage remaining. - var availableStorage: Int64? { - #if os(iOS) - let fileURL = URL(fileURLWithPath: NSHomeDirectory() as String) - do { - let values = try fileURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) - return values.volumeAvailableCapacityForImportantUsage - } catch { - logError(message: "Failed to read available storage size: \(error.localizedDescription)") - } - #endif - return nil - } - - /// Returns the total number of bytes of storage. The information is requested from the home directory. - /// - Returns: Total size of storage in bytes. - var totalStorage: Int? { - #if os(iOS) - let fileURL = URL(fileURLWithPath: NSHomeDirectory() as String) - do { - let values = try fileURL.resourceValues(forKeys: [.volumeTotalCapacityKey]) - return values.volumeTotalCapacity - } catch { - logError(message: "Failed to read available storage size: \(error.localizedDescription)") - } - #endif - return nil - } /// Whether the device orientation is portrait (either upright or upside down) var isPortrait: Bool? { diff --git a/Sources/Core/Utils/SNOWReachability.swift b/Sources/Core/Utils/SNOWReachability.swift index 84d4d9ff1..58ab49d1f 100644 --- a/Sources/Core/Utils/SNOWReachability.swift +++ b/Sources/Core/Utils/SNOWReachability.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -11,7 +11,7 @@ // express or implied. See the Apache License Version 2.0 for the specific // language governing permissions and limitations there under. -#if os(iOS) +#if os(iOS) || os(visionOS) import Foundation import SystemConfiguration @@ -65,7 +65,7 @@ class SNOWReachability: NSObject { return .offline } - #if os(iOS) + #if os(iOS) || os(visionOS) let isWWAN = (flags.rawValue & SCNetworkReachabilityFlags.isWWAN.rawValue) == SCNetworkReachabilityFlags.isWWAN.rawValue if isWWAN { return .wwan diff --git a/Sources/Core/Utils/Stringb64.swift b/Sources/Core/Utils/Stringb64.swift new file mode 100644 index 000000000..bd22a2049 --- /dev/null +++ b/Sources/Core/Utils/Stringb64.swift @@ -0,0 +1,33 @@ +// Copyright (c) 2013-present 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 + +extension String { + func toBase64(urlSafe: Bool = true) -> String { + var encoded = Data(self.utf8).base64EncodedString() + if urlSafe { + // We need URL safe with no padding. Since there is no built-in way to do this, we transform + // the encoded payload to make it URL safe by replacing chars that are different in the URL-safe + // alphabet. Namely, 62 is - instead of +, and 63 _ instead of /. + // See: https://tools.ietf.org/html/rfc4648#section-5 + encoded = encoded + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + + // There is also no padding since the length is implicitly known. + encoded = encoded.trimmingCharacters(in: CharacterSet(charactersIn: "=")) + } + return encoded + } +} diff --git a/Sources/Core/Utils/Utilities.swift b/Sources/Core/Utils/Utilities.swift index 9dc060bda..81617904a 100644 --- a/Sources/Core/Utils/Utilities.swift +++ b/Sources/Core/Utils/Utilities.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -11,12 +11,10 @@ // express or implied. See the Apache License Version 2.0 for the specific // language governing permissions and limitations there under. -#if os(iOS) +#if os(iOS) || os(tvOS) || os(visionOS) import UIKit #elseif os(macOS) import AppKit -#elseif os(tvOS) -import UIKit #elseif os(watchOS) import WatchKit #endif @@ -38,8 +36,13 @@ class Utilities { /// Returns the platform type of the device.. /// - Returns: A string of the platform type. class var platform: DevicePlatform { - #if os(iOS) + #if os(iOS) || os(visionOS) || os(watchOS) return .mobile +// TODO: use the headset platform by default in visionOS once Enrich 4 is commonly used +// #elseif os(visionOS) +// return .headset + #elseif os(tvOS) + return .connectedTV #else return .desktop #endif diff --git a/Sources/Snowplow/Configurations/ConfigurationBuilder.swift b/Sources/Snowplow/Configurations/ConfigurationBuilder.swift index 9bc845410..17487da25 100644 --- a/Sources/Snowplow/Configurations/ConfigurationBuilder.swift +++ b/Sources/Snowplow/Configurations/ConfigurationBuilder.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/ConfigurationBundle.swift b/Sources/Snowplow/Configurations/ConfigurationBundle.swift index a792659b1..c9bdf0c46 100644 --- a/Sources/Snowplow/Configurations/ConfigurationBundle.swift +++ b/Sources/Snowplow/Configurations/ConfigurationBundle.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/ConfigurationProtocol.swift b/Sources/Snowplow/Configurations/ConfigurationProtocol.swift index 809d93471..85410beed 100644 --- a/Sources/Snowplow/Configurations/ConfigurationProtocol.swift +++ b/Sources/Snowplow/Configurations/ConfigurationProtocol.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/ConfigurationState.swift b/Sources/Snowplow/Configurations/ConfigurationState.swift index 23c86449b..bc3a9741a 100644 --- a/Sources/Snowplow/Configurations/ConfigurationState.swift +++ b/Sources/Snowplow/Configurations/ConfigurationState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/EmitterConfiguration.swift b/Sources/Snowplow/Configurations/EmitterConfiguration.swift index 2d04f1f5b..40e85500e 100644 --- a/Sources/Snowplow/Configurations/EmitterConfiguration.swift +++ b/Sources/Snowplow/Configurations/EmitterConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,7 +16,7 @@ import Foundation @objc(SPEmitterConfigurationProtocol) public protocol EmitterConfigurationProtocol: AnyObject { /// Sets whether the buffer should send events instantly or after the buffer - /// has reached it's limit. By default, this is set to BufferOption Default. + /// has reached it's limit. By default, this is set to BufferOption single. @objc var bufferOption: BufferOption { get set } /// Maximum number of events collected from the EventStore to be sent in a request. @@ -45,6 +45,14 @@ public protocol EmitterConfigurationProtocol: AnyObject { /// If disabled, events that failed to be sent will be dropped regardless of other configuration (such as the customRetryForStatusCodes). @objc var retryFailedRequests: Bool { get set } + /// Limit for the maximum number of unsent events to keep in the event store. + /// Defaults to 1000. + @objc + var maxEventStoreSize: Int64 { get set } + /// Limit for the maximum duration of how long events should be kept in the event store if they fail to be sent. + /// Defaults to 30 days. + @objc + var maxEventStoreAge: TimeInterval { get set } } /// It allows the tracker configuration from the emission perspective. @@ -136,6 +144,24 @@ public class EmitterConfiguration: SerializableConfiguration, EmitterConfigurati set { _retryFailedRequests = newValue } } + private var _maxEventStoreSize: Int64? + /// Limit for the maximum number of unsent events to keep in the event store. + /// Defaults to 1000. + @objc + public var maxEventStoreSize: Int64 { + get { return _maxEventStoreSize ?? sourceConfig?.maxEventStoreSize ?? EmitterDefaults.maxEventStoreSize } + set { _maxEventStoreSize = newValue } + } + + private var _maxEventStoreAge: TimeInterval? + /// Limit for the maximum duration of how long events should be kept in the event store if they fail to be sent. + /// Defaults to 30 days. + @objc + public var maxEventStoreAge: TimeInterval { + get { return _maxEventStoreAge ?? sourceConfig?.maxEventStoreAge ?? EmitterDefaults.maxEventStoreAge } + set { _maxEventStoreAge = newValue } + } + // MARK: - Internal /// Fallback configuration to read from in case requested values are not present in this configuraiton. @@ -258,6 +284,22 @@ public class EmitterConfiguration: SerializableConfiguration, EmitterConfigurati return self } + /// Limit for the maximum number of unsent events to keep in the event store. + /// Defaults to 1000. + @objc + public func maxEventStoreSize(_ maxEventStoreSize: Int64) -> Self { + self.maxEventStoreSize = maxEventStoreSize + return self + } + + /// Limit for the maximum duration of how long events should be kept in the event store if they fail to be sent. + /// Defaults to 30 days. + @objc + public func maxEventStoreAge(_ maxEventStoreAge: TimeInterval) -> Self { + self.maxEventStoreAge = maxEventStoreAge + return self + } + // MARK: - NSCopying @objc @@ -273,6 +315,8 @@ public class EmitterConfiguration: SerializableConfiguration, EmitterConfigurati copy.serverAnonymisation = serverAnonymisation copy.eventStore = eventStore copy.retryFailedRequests = retryFailedRequests + copy.maxEventStoreAge = maxEventStoreAge + copy.maxEventStoreSize = maxEventStoreSize return copy } @@ -291,6 +335,8 @@ public class EmitterConfiguration: SerializableConfiguration, EmitterConfigurati coder.encode(customRetryForStatusCodes, forKey: "customRetryForStatusCodes") coder.encode(serverAnonymisation, forKey: "serverAnonymisation") coder.encode(retryFailedRequests, forKey: "retryFailedRequests") + coder.encode(maxEventStoreAge, forKey: "maxEventStoreAge") + coder.encode(maxEventStoreSize, forKey: "maxEventStoreSize") } required init?(coder: NSCoder) { @@ -309,5 +355,11 @@ public class EmitterConfiguration: SerializableConfiguration, EmitterConfigurati if coder.containsValue(forKey: "retryFailedRequests") { retryFailedRequests = coder.decodeBool(forKey: "retryFailedRequests") } + if coder.containsValue(forKey: "maxEventStoreAge") { + maxEventStoreAge = coder.decodeDouble(forKey: "maxEventStoreAge") + } + if coder.containsValue(forKey: "maxEventStoreSize") { + maxEventStoreSize = coder.decodeInt64(forKey: "maxEventStoreSize") + } } } diff --git a/Sources/Snowplow/Configurations/FocalMeterConfiguration.swift b/Sources/Snowplow/Configurations/FocalMeterConfiguration.swift index 62df08806..61d423be3 100644 --- a/Sources/Snowplow/Configurations/FocalMeterConfiguration.swift +++ b/Sources/Snowplow/Configurations/FocalMeterConfiguration.swift @@ -2,7 +2,7 @@ // FocalMeterConfiguration.swift // Snowplow // -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/GDPRConfiguration.swift b/Sources/Snowplow/Configurations/GDPRConfiguration.swift index c36f75fbd..aecd4b312 100644 --- a/Sources/Snowplow/Configurations/GDPRConfiguration.swift +++ b/Sources/Snowplow/Configurations/GDPRConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/GlobalContextsConfiguration.swift b/Sources/Snowplow/Configurations/GlobalContextsConfiguration.swift index 32e6c38b9..f4b9c9474 100644 --- a/Sources/Snowplow/Configurations/GlobalContextsConfiguration.swift +++ b/Sources/Snowplow/Configurations/GlobalContextsConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/NetworkConfiguration.swift b/Sources/Snowplow/Configurations/NetworkConfiguration.swift index 3cdd8191d..4eaf94c73 100644 --- a/Sources/Snowplow/Configurations/NetworkConfiguration.swift +++ b/Sources/Snowplow/Configurations/NetworkConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -65,12 +65,18 @@ public class NetworkConfiguration: SerializableConfiguration, ConfigurationProto set { _requestHeaders = newValue } } + private var _timeout: TimeInterval? + /// The maximum timeout for emitting events to the collector. + /// Defaults to 30 seconds. + @objc var timeout: TimeInterval { + get { return _timeout ?? sourceConfig?.timeout ?? EmitterDefaults.emitTimeout } + set { _timeout = newValue } + } + // MARK: - Internal /// Fallback configuration to read from in case requested values are not present in this configuraiton. internal var sourceConfig: NetworkConfiguration? - - // TODO: add -> @property () NSInteger timeout; internal override init() { } @@ -132,6 +138,14 @@ public class NetworkConfiguration: SerializableConfiguration, ConfigurationProto self.requestHeaders = headers return self } + + /// The maximum timeout for emitting events to the collector. + /// Defaults to 30 seconds. + @objc + public func timeout(_ timeout: TimeInterval) -> Self { + self.timeout = timeout + return self + } // MARK: - NSCopying @@ -144,6 +158,7 @@ public class NetworkConfiguration: SerializableConfiguration, ConfigurationProto copy = NetworkConfiguration(endpoint: endpoint ?? "", method: method ) } copy?.customPostPath = customPostPath + copy?.timeout = timeout return copy! } @@ -159,6 +174,7 @@ public class NetworkConfiguration: SerializableConfiguration, ConfigurationProto coder.encode(method.rawValue, forKey: "method") coder.encode(customPostPath, forKey: "customPostPath") coder.encode(requestHeaders, forKey: "requestHeaders") + coder.encode(timeout, forKey: "timeout") } required init?(coder: NSCoder) { @@ -167,5 +183,6 @@ public class NetworkConfiguration: SerializableConfiguration, ConfigurationProto _method = HttpMethodOptions(rawValue: coder.decodeInteger(forKey: "method")) _customPostPath = coder.decodeObject(forKey: "customPostPath") as? String _requestHeaders = coder.decodeObject(forKey: "requestHeaders") as? [String : String] + _timeout = coder.decodeObject(forKey: "timeout") as? TimeInterval } } diff --git a/Sources/Snowplow/Configurations/PlatformContextProperty.swift b/Sources/Snowplow/Configurations/PlatformContextProperty.swift index d1efa3d63..307128a3f 100644 --- a/Sources/Snowplow/Configurations/PlatformContextProperty.swift +++ b/Sources/Snowplow/Configurations/PlatformContextProperty.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -13,39 +13,41 @@ import Foundation -/// Optional properties tracked in the platform context entity +/// Optional properties tracked in the platform context entity. public enum PlatformContextProperty: Int { - /// The carrier of the SIM inserted in the device + /// The carrier of the SIM inserted in the device. case carrier - /// Type of network the device is connected to + /// Type of network the device is connected to. case networkType - /// Radio access technology that the device is using + /// Radio access technology that the device is using. case networkTechnology - /// Advertising identifier on iOS + /// Advertising identifier on iOS. case appleIdfa - /// UUID identifier for vendors on iOS + /// UUID identifier for vendors on iOS. case appleIdfv - /// Total physical system memory in bytes + /// Total physical system memory in bytes. case physicalMemory - /// Amount of memory in bytes available to the current app + /// Amount of memory in bytes available to the current app. /// The property is not tracked in the current version of the tracker due to the tracker not being able to access the API, see the issue here: https://github.com/snowplow/snowplow-ios-tracker/issues/772 case appAvailableMemory - /// Remaining battery level as an integer percentage of total battery capacity + /// Remaining battery level as an integer percentage of total battery capacity. case batteryLevel - /// Battery state for the device + /// Battery state for the device. case batteryState - /// A Boolean indicating whether Low Power Mode is enabled + /// A Boolean indicating whether Low Power Mode is enabled. case lowPowerMode - /// Bytes of storage remaining + /// Bytes of storage remaining. + /// Note: This is not automatically assigned by the tracker as it may be considered as fingerprinting. You can assign it using the PlatformContextRetriever. case availableStorage /// Total size of storage in bytes + /// Note: This is not automatically assigned by the tracker as it may be considered as fingerprinting. You can assign it using the PlatformContextRetriever. case totalStorage - /// A Boolean indicating whether the device orientation is portrait (either upright or upside down) + /// A Boolean indicating whether the device orientation is portrait (either upright or upside down). case isPortrait - /// Screen resolution in pixels. Arrives in the form of WIDTHxHEIGHT (e.g., 1200x900). Doesn't change when device orientation changes + /// Screen resolution in pixels. Arrives in the form of WIDTHxHEIGHT (e.g., 1200x900). Doesn't change when device orientation changes. case resolution - /// Scale factor used to convert logical coordinates to device coordinates of the screen (uses UIScreen.scale on iOS) + /// Scale factor used to convert logical coordinates to device coordinates of the screen (uses UIScreen.scale on iOS). case scale - /// System language currently used on the device (ISO 639) + /// System language currently used on the device (ISO 639). case language } diff --git a/Sources/Snowplow/Configurations/PluginConfiguration.swift b/Sources/Snowplow/Configurations/PluginConfiguration.swift index e3e631c4a..fe8cd3b27 100644 --- a/Sources/Snowplow/Configurations/PluginConfiguration.swift +++ b/Sources/Snowplow/Configurations/PluginConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/RemoteConfiguration.swift b/Sources/Snowplow/Configurations/RemoteConfiguration.swift index 4f61ecd9b..e26aaf5d8 100644 --- a/Sources/Snowplow/Configurations/RemoteConfiguration.swift +++ b/Sources/Snowplow/Configurations/RemoteConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/SerializableConfiguration.swift b/Sources/Snowplow/Configurations/SerializableConfiguration.swift index b11e8a496..cc4299824 100644 --- a/Sources/Snowplow/Configurations/SerializableConfiguration.swift +++ b/Sources/Snowplow/Configurations/SerializableConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/SessionConfiguration.swift b/Sources/Snowplow/Configurations/SessionConfiguration.swift index 646983941..d2e1226ea 100644 --- a/Sources/Snowplow/Configurations/SessionConfiguration.swift +++ b/Sources/Snowplow/Configurations/SessionConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/SubjectConfiguration.swift b/Sources/Snowplow/Configurations/SubjectConfiguration.swift index b910f14d6..54e2f8953 100644 --- a/Sources/Snowplow/Configurations/SubjectConfiguration.swift +++ b/Sources/Snowplow/Configurations/SubjectConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Configurations/TrackerConfiguration.swift b/Sources/Snowplow/Configurations/TrackerConfiguration.swift index 5e940aacc..5fcf85001 100644 --- a/Sources/Snowplow/Configurations/TrackerConfiguration.swift +++ b/Sources/Snowplow/Configurations/TrackerConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -31,43 +31,51 @@ 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 enable automatic tracking of background and foreground transitions. + /// 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 to enable automatic tracking of background and foreground transitions. + /// Enabled by default. @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 @@ -82,6 +90,10 @@ public protocol PlatformContextConfigurationProtocol { /// List of properties of the platform context 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. var platformContextProperties: [PlatformContextProperty]? { get set } + + /// Set of callbacks to be used to retrieve properties of the platform context. + /// Overrides the tracker implementation for setting the properties. + var platformContextRetriever: PlatformContextRetriever? { get set } } /// This class represents the configuration of the tracker and the core tracker properties. @@ -130,7 +142,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 } @@ -138,7 +150,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 } @@ -146,7 +158,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 } @@ -154,7 +166,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 } @@ -162,7 +174,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 } @@ -170,7 +182,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 } @@ -178,15 +190,25 @@ 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 } set { _screenViewAutotracking = newValue } } + private var _screenEngagementAutotracking: Bool? + /// 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 { + get { return _screenEngagementAutotracking ?? sourceConfig?.screenEngagementAutotracking ?? TrackerDefaults.screenEngagementAutotracking } + set { _screenEngagementAutotracking = newValue } + } + private var _lifecycleAutotracking: Bool? - /// Whether enable automatic tracking of background and foreground transitions. + /// Whether to enable automatic tracking of background and foreground transitions. + /// Enabled by default. @objc public var lifecycleAutotracking: Bool { get { return _lifecycleAutotracking ?? sourceConfig?.lifecycleAutotracking ?? TrackerDefaults.lifecycleEvents } @@ -194,7 +216,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 } @@ -202,7 +224,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 } @@ -210,7 +232,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 } @@ -226,6 +248,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. @@ -240,8 +270,14 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati /// It is called repeatedly (on each tracked event) until a UUID is returned. @objc public var advertisingIdentifierRetriever: (() -> UUID?)? { - get { return _advertisingIdentifierRetriever ?? sourceConfig?.advertisingIdentifierRetriever } - set { _advertisingIdentifierRetriever = newValue } + get { return platformContextRetriever?.appleIdfa } + set { + if let retriever = platformContextRetriever { + retriever.appleIdfa = newValue + } else { + platformContextRetriever = PlatformContextRetriever(appleIdfa: newValue) + } + } } private var _platformContextProperties: [PlatformContextProperty]? @@ -252,6 +288,14 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati set { _platformContextProperties = newValue } } + private var _platformContextRetriever: PlatformContextRetriever? + /// Set of callbacks to be used to retrieve properties of the platform context. + /// Overrides the tracker implementation for setting the properties. + public var platformContextRetriever: PlatformContextRetriever? { + get { return _platformContextRetriever ?? sourceConfig?.platformContextRetriever } + set { _platformContextRetriever = newValue } + } + // MARK: - Internal /// Fallback configuration to read from in case requested values are not present in this configuraiton. @@ -313,6 +357,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 } @@ -328,6 +375,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 @@ -367,91 +417,99 @@ 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 automatic tracking of background and foreground transitions. + /// 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 to enable automatic tracking of background and foreground transitions. + /// Enabled by default. @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 { @@ -459,6 +517,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 @@ -474,6 +539,13 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati self.advertisingIdentifierRetriever = retriever return self } + + /// Set of callbacks to be used to retrieve properties of the platform context. + /// Overrides the tracker implementation for setting the properties. + public func platformContextRetriever(_ retriever: PlatformContextRetriever?) -> Self { + self.platformContextRetriever = retriever + return self + } // MARK: - NSCopying @@ -489,17 +561,19 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati copy.applicationContext = applicationContext copy.platformContext = platformContext copy.platformContextProperties = platformContextProperties + copy.platformContextRetriever = platformContextRetriever copy.geoLocationContext = geoLocationContext copy.deepLinkContext = deepLinkContext copy.screenContext = screenContext copy.screenViewAutotracking = screenViewAutotracking + copy.screenEngagementAutotracking = screenEngagementAutotracking copy.lifecycleAutotracking = lifecycleAutotracking copy.installAutotracking = installAutotracking copy.exceptionAutotracking = exceptionAutotracking copy.diagnosticAutotracking = diagnosticAutotracking copy.trackerVersionSuffix = trackerVersionSuffix copy.userAnonymisation = userAnonymisation - copy.advertisingIdentifierRetriever = advertisingIdentifierRetriever + copy.immersiveSpaceContext = immersiveSpaceContext return copy } @@ -522,12 +596,14 @@ 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") coder.encode(diagnosticAutotracking, forKey: "diagnosticAutotracking") coder.encode(trackerVersionSuffix, forKey: "trackerVersionSuffix") coder.encode(userAnonymisation, forKey: "userAnonymisation") + coder.encode(immersiveSpaceContext, forKey: "immersiveSpaceContext") } required init?(coder: NSCoder) { @@ -552,6 +628,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") @@ -560,5 +637,6 @@ public class TrackerConfiguration: SerializableConfiguration, TrackerConfigurati self.trackerVersionSuffix = trackerVersionSuffix } userAnonymisation = coder.decodeBool(forKey: "userAnonymisation") + immersiveSpaceContext = coder.decodeBool(forKey: "immersiveSpaceContext") } } diff --git a/Sources/Snowplow/Controllers/Controller.swift b/Sources/Snowplow/Controllers/Controller.swift index 9340947c4..687314fb3 100644 --- a/Sources/Snowplow/Controllers/Controller.swift +++ b/Sources/Snowplow/Controllers/Controller.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Controllers/EmitterController.swift b/Sources/Snowplow/Controllers/EmitterController.swift index c0b016416..f230bef97 100644 --- a/Sources/Snowplow/Controllers/EmitterController.swift +++ b/Sources/Snowplow/Controllers/EmitterController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -21,6 +21,9 @@ public protocol EmitterController: EmitterConfigurationProtocol { /// Whether the emitter is currently sending events. @objc var isSending: Bool { get } + /// The EventStore being used by the emitter. + @objc + var eventStore: EventStore { get } @objc func flush() /// Pause emitting events. diff --git a/Sources/Snowplow/Controllers/GDPRController.swift b/Sources/Snowplow/Controllers/GDPRController.swift index 0bc5c253d..b1b118115 100644 --- a/Sources/Snowplow/Controllers/GDPRController.swift +++ b/Sources/Snowplow/Controllers/GDPRController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Controllers/GlobalContextsController.swift b/Sources/Snowplow/Controllers/GlobalContextsController.swift index ad326c6c8..1898fe4f3 100644 --- a/Sources/Snowplow/Controllers/GlobalContextsController.swift +++ b/Sources/Snowplow/Controllers/GlobalContextsController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Controllers/NetworkController.swift b/Sources/Snowplow/Controllers/NetworkController.swift index a0511c49e..0894f53be 100644 --- a/Sources/Snowplow/Controllers/NetworkController.swift +++ b/Sources/Snowplow/Controllers/NetworkController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Controllers/PluginsController.swift b/Sources/Snowplow/Controllers/PluginsController.swift index deda80eab..288e94559 100644 --- a/Sources/Snowplow/Controllers/PluginsController.swift +++ b/Sources/Snowplow/Controllers/PluginsController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Controllers/SessionController.swift b/Sources/Snowplow/Controllers/SessionController.swift index a01a19790..35dcef822 100644 --- a/Sources/Snowplow/Controllers/SessionController.swift +++ b/Sources/Snowplow/Controllers/SessionController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Controllers/SubjectController.swift b/Sources/Snowplow/Controllers/SubjectController.swift index 7912aaaf7..fe793cbd6 100644 --- a/Sources/Snowplow/Controllers/SubjectController.swift +++ b/Sources/Snowplow/Controllers/SubjectController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Controllers/TrackerController.swift b/Sources/Snowplow/Controllers/TrackerController.swift index 3c5fcdf4c..9f929aa5b 100644 --- a/Sources/Snowplow/Controllers/TrackerController.swift +++ b/Sources/Snowplow/Controllers/TrackerController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -62,9 +62,9 @@ public protocol TrackerController: TrackerConfigurationProtocol { /// Track the event. /// The tracker will take care to process and send the event assigning `event_id` and `device_timestamp`. /// - Parameter event: The event to track. - /// - Returns: The event ID or nil in case tracking is paused + /// - Returns: The event ID @objc - func track(_ event: Event) -> UUID? + func track(_ event: Event) -> UUID /// Pause the tracker. /// The tracker will stop any new activity tracking but it will continue to send remaining events /// already tracked but not sent yet. @@ -75,4 +75,40 @@ public protocol TrackerController: TrackerConfigurationProtocol { /// The tracker will start tracking again. @objc func resume() + /// Adds user and session information to a URL. + /// + /// For example, calling decorateLink on `appSchema://path/to/page` will return: + /// + /// `appSchema://path/to/page?_sp=domainUserId.timestamp.sessionId..sourceId` + /// + /// Filled by this method: + /// - `domainUserId`: Value of ``SessionController.userId`` + /// - `timestamp`: ms precision epoch timestamp + /// - `sessionId`: Value of ``SessionController.sessionId`` + /// - `sourceId`: Value of ``Tracker.appId`` + /// + /// - Parameter uri The URI to add the query string to + /// + /// - Returns Optional URL + /// - nil if ``SnowplowTracker/SessionController/userId`` is null from `sessionContext(false)` being passed in ``TrackerConfiguration`` + /// - otherwise, decorated URL + @objc + func decorateLink(_ url: URL) -> URL? + /// Adds user and session information to a URL. + /// + /// For example, calling decorateLink on `appSchema://path/to/page` with all extended parameters enabled will return: + /// + /// `appSchema://path/to/page?_sp=domainUserId.timestamp.sessionId.subjectUserId.sourceId.platform.reason` + /// + /// - Parameter url The URL to add the query string to + /// - Parameter extendedParameters Any optional parameters to include in the query string. + /// + /// - Returns Optional URL + /// - nil if: + /// + /// - ``SnowplowTracker/SessionController/userId`` is null from `sessionContext(false)` being passed in ``TrackerConfiguration`` + /// - An enabled CrossDeviceParameter isn't set in the tracker + /// - otherwise, decorated URL + @objc + func decorateLink(_ url: URL, extendedParameters: CrossDeviceParameterConfiguration) -> URL? } diff --git a/Sources/Snowplow/Ecommerce/EcommerceController.swift b/Sources/Snowplow/Ecommerce/EcommerceController.swift index df9707cf0..c5a84c2c8 100644 --- a/Sources/Snowplow/Ecommerce/EcommerceController.swift +++ b/Sources/Snowplow/Ecommerce/EcommerceController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Entities/CartEntity.swift b/Sources/Snowplow/Ecommerce/Entities/CartEntity.swift index 408fdb941..5e7018f70 100644 --- a/Sources/Snowplow/Ecommerce/Entities/CartEntity.swift +++ b/Sources/Snowplow/Ecommerce/Entities/CartEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift b/Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift index 521c6e78a..15e696535 100644 --- a/Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift +++ b/Sources/Snowplow/Ecommerce/Entities/EcommerceScreenEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift b/Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift index daaaf11a9..bf131a923 100644 --- a/Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift +++ b/Sources/Snowplow/Ecommerce/Entities/EcommerceUserEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift b/Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift index 016d61ac7..2300e38ff 100644 --- a/Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift +++ b/Sources/Snowplow/Ecommerce/Entities/ProductEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift b/Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift index d862e593e..e84cb9b86 100644 --- a/Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift +++ b/Sources/Snowplow/Ecommerce/Entities/PromotionEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift b/Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift index e0f9ceedb..aebaa6cc6 100644 --- a/Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift +++ b/Sources/Snowplow/Ecommerce/Entities/TransactionEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift b/Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift index a7430ed8c..356a18a09 100644 --- a/Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/AddToCartEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift b/Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift index ee756b713..567c6e051 100644 --- a/Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/CheckoutStepEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift b/Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift index 3e2a6e065..0a45d6fe1 100644 --- a/Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/ProductListClickEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift b/Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift index 1c8088af2..8d80be885 100644 --- a/Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/ProductListViewEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift b/Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift index c1c4f67ec..6b9a3d3ea 100644 --- a/Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/ProductViewEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift b/Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift index f0114ebdb..4c3bf5741 100644 --- a/Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/PromotionClickEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift b/Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift index ac4071062..5d5f273a7 100644 --- a/Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/PromotionViewEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/RefundEvent.swift b/Sources/Snowplow/Ecommerce/Events/RefundEvent.swift index 736742542..6cecc5670 100644 --- a/Sources/Snowplow/Ecommerce/Events/RefundEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/RefundEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift b/Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift index 9021cd3d0..9ba501c3b 100644 --- a/Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/RemoveFromCartEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift b/Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift index 513b73ddb..4ce2d210a 100644 --- a/Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/TransactionErrorEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift b/Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift index b330c6b00..b2f26c82d 100644 --- a/Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift +++ b/Sources/Snowplow/Ecommerce/Events/TransactionEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Emitter/BufferOption.swift b/Sources/Snowplow/Emitter/BufferOption.swift index 36d12a51c..97b5c4616 100644 --- a/Sources/Snowplow/Emitter/BufferOption.swift +++ b/Sources/Snowplow/Emitter/BufferOption.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,14 +16,16 @@ import Foundation /// An enum for buffer options. @objc(SPBufferOption) public enum BufferOption : Int { - /// Sends both GET and POST requests with only a single event. Can cause a spike in - /// network traffic if used in correlation with a large amount of events. + /// Sends both GET and POST requests with only a single event. + /// This is the default setting. + /// Can cause a spike in network traffic if used in correlation with a large amount of events. case single = 1 - /// Sends POST requests in groups of 10 events. This is the default amount of events too - /// package into a POST. All GET requests will still emit one at a time. - case defaultGroup = 10 - /// Sends POST requests in groups of 25 events. Useful for situations where many events - /// need to be sent. All GET requests will still emit one at a time. + /// Sends POST requests in groups of 10 events. + /// All GET requests will still emit one at a time. + case smallGroup = 10 + /// Sends POST requests in groups of 25 events. + /// Useful for situations where many events need to be sent. + /// All GET requests will still emit one at a time. case largeGroup = 25 } @@ -32,8 +34,12 @@ extension BufferOption { switch value { case "Single": return .single + case "SmallGroup": + return .smallGroup case "DefaultGroup": - return .defaultGroup + return .smallGroup + case "LargeGroup": + return .largeGroup case "HeavyGroup": return .largeGroup default: diff --git a/Sources/Snowplow/Emitter/EmitterDefaults.swift b/Sources/Snowplow/Emitter/EmitterDefaults.swift index c4d36d060..b9b0f485f 100644 --- a/Sources/Snowplow/Emitter/EmitterDefaults.swift +++ b/Sources/Snowplow/Emitter/EmitterDefaults.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,11 +16,14 @@ import Foundation public class EmitterDefaults { public private(set) static var httpMethod: HttpMethodOptions = .post public private(set) static var httpProtocol: ProtocolOptions = .https - public private(set) static var emitRange = 150 + public private(set) static var emitRange = BufferOption.largeGroup.rawValue public private(set) static var emitThreadPoolSize = 15 public private(set) static var byteLimitGet = 40000 public private(set) static var byteLimitPost = 40000 public private(set) static var serverAnonymisation = false - public private(set) static var bufferOption: BufferOption = .defaultGroup + public private(set) static var bufferOption: BufferOption = .single public private(set) static var retryFailedRequests = true + public private(set) static var maxEventStoreSize: Int64 = 1000 // events + public private(set) static var maxEventStoreAge: TimeInterval = TimeInterval(60 * 60 * 24 * 30) // 30 days + public private(set) static var emitTimeout: TimeInterval = TimeInterval(30) // 30 seconds } diff --git a/Sources/Snowplow/Emitter/EmitterEvent.swift b/Sources/Snowplow/Emitter/EmitterEvent.swift index 40c22c395..76f444a2c 100644 --- a/Sources/Snowplow/Emitter/EmitterEvent.swift +++ b/Sources/Snowplow/Emitter/EmitterEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Emitter/EventStore.swift b/Sources/Snowplow/Emitter/EventStore.swift index 745b12945..a036c4c40 100644 --- a/Sources/Snowplow/Emitter/EventStore.swift +++ b/Sources/Snowplow/Emitter/EventStore.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -43,4 +43,10 @@ public protocol EventStore: NSObjectProtocol { /// - Returns: EmitterEvent objects containing storeIds and event payloads. @objc func emittableEvents(withQueryLimit queryLimit: UInt) -> [EmitterEvent] + /// Remove events older than `maxAge` seconds and keep only the latest `maxSize` events. + /// - Parameters: + /// - maxSize: Limit for the maximum number of unsent events to keep + /// - maxAge: Limit for the maximum duration of how long events should be kept + @objc + func removeOldEvents(maxSize: Int64, maxAge: TimeInterval) } diff --git a/Sources/Snowplow/Entities/DeepLinkEntity.swift b/Sources/Snowplow/Entities/DeepLinkEntity.swift index 2b0ea5788..c17efe1e7 100644 --- a/Sources/Snowplow/Entities/DeepLinkEntity.swift +++ b/Sources/Snowplow/Entities/DeepLinkEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Entities/LifecycleEntity.swift b/Sources/Snowplow/Entities/LifecycleEntity.swift index fc0c9fa6f..fc35407f3 100644 --- a/Sources/Snowplow/Entities/LifecycleEntity.swift +++ b/Sources/Snowplow/Entities/LifecycleEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/Background.swift b/Sources/Snowplow/Events/Background.swift index ba24d4d46..b8bd75140 100644 --- a/Sources/Snowplow/Events/Background.swift +++ b/Sources/Snowplow/Events/Background.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/ConsentDocument.swift b/Sources/Snowplow/Events/ConsentDocument.swift index 94c1d22ac..5ab4a5af9 100644 --- a/Sources/Snowplow/Events/ConsentDocument.swift +++ b/Sources/Snowplow/Events/ConsentDocument.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/ConsentGranted.swift b/Sources/Snowplow/Events/ConsentGranted.swift index 1250659f8..3465a09f8 100644 --- a/Sources/Snowplow/Events/ConsentGranted.swift +++ b/Sources/Snowplow/Events/ConsentGranted.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/ConsentWithdrawn.swift b/Sources/Snowplow/Events/ConsentWithdrawn.swift index b9fdf8376..7b7522353 100644 --- a/Sources/Snowplow/Events/ConsentWithdrawn.swift +++ b/Sources/Snowplow/Events/ConsentWithdrawn.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/DeepLinkReceived.swift b/Sources/Snowplow/Events/DeepLinkReceived.swift index 6fbf1cb1b..a30d3aad8 100644 --- a/Sources/Snowplow/Events/DeepLinkReceived.swift +++ b/Sources/Snowplow/Events/DeepLinkReceived.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/Ecommerce.swift b/Sources/Snowplow/Events/Ecommerce.swift index 8d142a94d..f1407647e 100644 --- a/Sources/Snowplow/Events/Ecommerce.swift +++ b/Sources/Snowplow/Events/Ecommerce.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/EcommerceItem.swift b/Sources/Snowplow/Events/EcommerceItem.swift index 751d2f7b7..3c0f31dad 100644 --- a/Sources/Snowplow/Events/EcommerceItem.swift +++ b/Sources/Snowplow/Events/EcommerceItem.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/EventBase.swift b/Sources/Snowplow/Events/EventBase.swift index 4b8f8ac77..447395555 100644 --- a/Sources/Snowplow/Events/EventBase.swift +++ b/Sources/Snowplow/Events/EventBase.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -13,7 +13,7 @@ import Foundation -/// This class has the basic functionality needed to represent all events +/// This class has the basic functionality needed to represent all events. @objc(SPEvent) public class Event: NSObject { /// The user event timestamp in milliseconds (epoch time). @@ -82,19 +82,18 @@ public class Event: NSObject { return self } - /// Replace the context entities attached to the event with a new list of entities. + /// Adds a list of context entities to the existing ones. @objc public func entities(_ entities: [SelfDescribingJson]) -> Self { - self.entities = entities + self._entities.append(contentsOf: entities) return self } - /// Replace the context entities attached to the event with a new list of entities. + /// Adds a list of context entities to the existing ones. @objc @available(*, deprecated, renamed: "entities") public func contexts(_ entities: [SelfDescribingJson]) -> Self { - self.entities = entities - return self + return self.entities(entities) } } diff --git a/Sources/Snowplow/Events/Foreground.swift b/Sources/Snowplow/Events/Foreground.swift index f5bda7ce3..36e1c29d9 100644 --- a/Sources/Snowplow/Events/Foreground.swift +++ b/Sources/Snowplow/Events/Foreground.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/ListItemView.swift b/Sources/Snowplow/Events/ListItemView.swift new file mode 100644 index 000000000..1b54b256a --- /dev/null +++ b/Sources/Snowplow/Events/ListItemView.swift @@ -0,0 +1,57 @@ +// Copyright (c) 2013-present 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/MessageNotification.swift b/Sources/Snowplow/Events/MessageNotification.swift index 411ddeaaa..79076931c 100644 --- a/Sources/Snowplow/Events/MessageNotification.swift +++ b/Sources/Snowplow/Events/MessageNotification.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/MessageNotificationAttachment.swift b/Sources/Snowplow/Events/MessageNotificationAttachment.swift index cc38413c6..cf077505a 100644 --- a/Sources/Snowplow/Events/MessageNotificationAttachment.swift +++ b/Sources/Snowplow/Events/MessageNotificationAttachment.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/PageView.swift b/Sources/Snowplow/Events/PageView.swift index 2db3292be..6950936f2 100644 --- a/Sources/Snowplow/Events/PageView.swift +++ b/Sources/Snowplow/Events/PageView.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/PushNotification.swift b/Sources/Snowplow/Events/PushNotification.swift index 16e97df73..365550306 100644 --- a/Sources/Snowplow/Events/PushNotification.swift +++ b/Sources/Snowplow/Events/PushNotification.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/SNOWError.swift b/Sources/Snowplow/Events/SNOWError.swift index 89e3cea61..97bfbb64f 100644 --- a/Sources/Snowplow/Events/SNOWError.swift +++ b/Sources/Snowplow/Events/SNOWError.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/ScreenView.swift b/Sources/Snowplow/Events/ScreenView.swift index ed4b16f3f..fe43a3282 100644 --- a/Sources/Snowplow/Events/ScreenView.swift +++ b/Sources/Snowplow/Events/ScreenView.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/ScrollChanged.swift b/Sources/Snowplow/Events/ScrollChanged.swift new file mode 100644 index 000000000..affdac771 --- /dev/null +++ b/Sources/Snowplow/Events/ScrollChanged.swift @@ -0,0 +1,114 @@ +// Copyright (c) 2013-present 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/Events/SelfDescribing.swift b/Sources/Snowplow/Events/SelfDescribing.swift index f3b762c2b..6aacac18a 100644 --- a/Sources/Snowplow/Events/SelfDescribing.swift +++ b/Sources/Snowplow/Events/SelfDescribing.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -27,6 +27,19 @@ public class SelfDescribing: SelfDescribingAbstract { self._payload = payload } + /// Creates a self-describing event using data represented as an Encodable struct. + /// - Parameters: + /// - schema: A valid schema URI. + /// - data: Data represented using an Encodable struct. + /// - Returns: A SelfDescribing event. + public convenience init(schema: String, data: T) throws { + let data = try JSONEncoder().encode(data) + let jsonObject = try JSONSerialization.jsonObject(with: data) + let dict = jsonObject as! [String: Any] + + self.init(schema: schema, payload: dict) + } + private var _schema: String override var schema: String { get { return _schema } diff --git a/Sources/Snowplow/Events/Structured.swift b/Sources/Snowplow/Events/Structured.swift index 448d6980f..aac0cc24d 100644 --- a/Sources/Snowplow/Events/Structured.swift +++ b/Sources/Snowplow/Events/Structured.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/Timing.swift b/Sources/Snowplow/Events/Timing.swift index c958f5945..ab5b5a2d5 100644 --- a/Sources/Snowplow/Events/Timing.swift +++ b/Sources/Snowplow/Events/Timing.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Events/TrackerError.swift b/Sources/Snowplow/Events/TrackerError.swift index 6c575d100..289c0b6f4 100644 --- a/Sources/Snowplow/Events/TrackerError.swift +++ b/Sources/Snowplow/Events/TrackerError.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/GlobalContexts/ContextGenerator.swift b/Sources/Snowplow/GlobalContexts/ContextGenerator.swift index 7bbb706ec..c50363bcd 100644 --- a/Sources/Snowplow/GlobalContexts/ContextGenerator.swift +++ b/Sources/Snowplow/GlobalContexts/ContextGenerator.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/GlobalContexts/GlobalContext.swift b/Sources/Snowplow/GlobalContexts/GlobalContext.swift index dcaaddea1..b1908cd67 100644 --- a/Sources/Snowplow/GlobalContexts/GlobalContext.swift +++ b/Sources/Snowplow/GlobalContexts/GlobalContext.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/GlobalContexts/SchemaRuleset.swift b/Sources/Snowplow/GlobalContexts/SchemaRuleset.swift index c5a8f398a..c4616ca2e 100644 --- a/Sources/Snowplow/GlobalContexts/SchemaRuleset.swift +++ b/Sources/Snowplow/GlobalContexts/SchemaRuleset.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Configuration/MediaTrackingConfiguration.swift b/Sources/Snowplow/Media/Configuration/MediaTrackingConfiguration.swift index a6710f6df..b23e2621f 100644 --- a/Sources/Snowplow/Media/Configuration/MediaTrackingConfiguration.swift +++ b/Sources/Snowplow/Media/Configuration/MediaTrackingConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Controllers/MediaController.swift b/Sources/Snowplow/Media/Controllers/MediaController.swift index 607b83a1e..e0f1eb300 100644 --- a/Sources/Snowplow/Media/Controllers/MediaController.swift +++ b/Sources/Snowplow/Media/Controllers/MediaController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Controllers/MediaTracking.swift b/Sources/Snowplow/Media/Controllers/MediaTracking.swift index 38975a222..c387dcd70 100644 --- a/Sources/Snowplow/Media/Controllers/MediaTracking.swift +++ b/Sources/Snowplow/Media/Controllers/MediaTracking.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Entities/MediaAdBreakEntity.swift b/Sources/Snowplow/Media/Entities/MediaAdBreakEntity.swift index 655cf40a3..578392b35 100644 --- a/Sources/Snowplow/Media/Entities/MediaAdBreakEntity.swift +++ b/Sources/Snowplow/Media/Entities/MediaAdBreakEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Entities/MediaAdEntity.swift b/Sources/Snowplow/Media/Entities/MediaAdEntity.swift index 00c774bea..04f811198 100644 --- a/Sources/Snowplow/Media/Entities/MediaAdEntity.swift +++ b/Sources/Snowplow/Media/Entities/MediaAdEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Entities/MediaPlayerEntity.swift b/Sources/Snowplow/Media/Entities/MediaPlayerEntity.swift index 1557d22b7..141572bea 100644 --- a/Sources/Snowplow/Media/Entities/MediaPlayerEntity.swift +++ b/Sources/Snowplow/Media/Entities/MediaPlayerEntity.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdBreakEndEvent.swift b/Sources/Snowplow/Media/Events/MediaAdBreakEndEvent.swift index b510a4f86..5894197e3 100644 --- a/Sources/Snowplow/Media/Events/MediaAdBreakEndEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdBreakEndEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdBreakStartEvent.swift b/Sources/Snowplow/Media/Events/MediaAdBreakStartEvent.swift index abe8cad59..dcc529dfc 100644 --- a/Sources/Snowplow/Media/Events/MediaAdBreakStartEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdBreakStartEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdClickEvent.swift b/Sources/Snowplow/Media/Events/MediaAdClickEvent.swift index 699eb6964..3d42c1922 100644 --- a/Sources/Snowplow/Media/Events/MediaAdClickEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdClickEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdCompleteEvent.swift b/Sources/Snowplow/Media/Events/MediaAdCompleteEvent.swift index 3f5fc0dcd..2d4ff0c1c 100644 --- a/Sources/Snowplow/Media/Events/MediaAdCompleteEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdCompleteEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdFirstQuartileEvent.swift b/Sources/Snowplow/Media/Events/MediaAdFirstQuartileEvent.swift index 1abbb5a7d..ad73acca5 100644 --- a/Sources/Snowplow/Media/Events/MediaAdFirstQuartileEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdFirstQuartileEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdMidpointEvent.swift b/Sources/Snowplow/Media/Events/MediaAdMidpointEvent.swift index 8b35e664f..46c42f6f4 100644 --- a/Sources/Snowplow/Media/Events/MediaAdMidpointEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdMidpointEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdPauseEvent.swift b/Sources/Snowplow/Media/Events/MediaAdPauseEvent.swift index 917477b1a..14385f7bd 100644 --- a/Sources/Snowplow/Media/Events/MediaAdPauseEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdPauseEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdResumeEvent.swift b/Sources/Snowplow/Media/Events/MediaAdResumeEvent.swift index eda1d71e5..299bb63a8 100644 --- a/Sources/Snowplow/Media/Events/MediaAdResumeEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdResumeEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdSkipEvent.swift b/Sources/Snowplow/Media/Events/MediaAdSkipEvent.swift index 7fe408255..8ce38418a 100644 --- a/Sources/Snowplow/Media/Events/MediaAdSkipEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdSkipEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdStartEvent.swift b/Sources/Snowplow/Media/Events/MediaAdStartEvent.swift index c4f5fc0a0..7e33c06bf 100644 --- a/Sources/Snowplow/Media/Events/MediaAdStartEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdStartEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaAdThirdQuartileEvent.swift b/Sources/Snowplow/Media/Events/MediaAdThirdQuartileEvent.swift index d357a72c9..061e65f4e 100644 --- a/Sources/Snowplow/Media/Events/MediaAdThirdQuartileEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaAdThirdQuartileEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaBufferEndEvent.swift b/Sources/Snowplow/Media/Events/MediaBufferEndEvent.swift index e1fddfb13..001cca166 100644 --- a/Sources/Snowplow/Media/Events/MediaBufferEndEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaBufferEndEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaBufferStartEvent.swift b/Sources/Snowplow/Media/Events/MediaBufferStartEvent.swift index 912d17040..62cc2f4c5 100644 --- a/Sources/Snowplow/Media/Events/MediaBufferStartEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaBufferStartEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaEndEvent.swift b/Sources/Snowplow/Media/Events/MediaEndEvent.swift index 4d9fb48ea..d89fe1ed6 100644 --- a/Sources/Snowplow/Media/Events/MediaEndEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaEndEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaErrorEvent.swift b/Sources/Snowplow/Media/Events/MediaErrorEvent.swift index 9cc052cb5..ae9428a38 100644 --- a/Sources/Snowplow/Media/Events/MediaErrorEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaErrorEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaFullscreenChangeEvent.swift b/Sources/Snowplow/Media/Events/MediaFullscreenChangeEvent.swift index dc5540e0b..ed27d6724 100644 --- a/Sources/Snowplow/Media/Events/MediaFullscreenChangeEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaFullscreenChangeEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaPauseEvent.swift b/Sources/Snowplow/Media/Events/MediaPauseEvent.swift index 307d116a4..fed926718 100644 --- a/Sources/Snowplow/Media/Events/MediaPauseEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaPauseEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaPictureInPictureChangeEvent.swift b/Sources/Snowplow/Media/Events/MediaPictureInPictureChangeEvent.swift index e5a4882da..de509e26e 100644 --- a/Sources/Snowplow/Media/Events/MediaPictureInPictureChangeEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaPictureInPictureChangeEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaPlayEvent.swift b/Sources/Snowplow/Media/Events/MediaPlayEvent.swift index 3b8d5a7b5..ea8741c2d 100644 --- a/Sources/Snowplow/Media/Events/MediaPlayEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaPlayEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaPlaybackRateChangeEvent.swift b/Sources/Snowplow/Media/Events/MediaPlaybackRateChangeEvent.swift index b3844da99..86b8d2792 100644 --- a/Sources/Snowplow/Media/Events/MediaPlaybackRateChangeEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaPlaybackRateChangeEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaQualityChangeEvent.swift b/Sources/Snowplow/Media/Events/MediaQualityChangeEvent.swift index f1ee1bf13..e512d0cc8 100644 --- a/Sources/Snowplow/Media/Events/MediaQualityChangeEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaQualityChangeEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaReadyEvent.swift b/Sources/Snowplow/Media/Events/MediaReadyEvent.swift index 7a2f7608b..2b4e149ed 100644 --- a/Sources/Snowplow/Media/Events/MediaReadyEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaReadyEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaSeekEndEvent.swift b/Sources/Snowplow/Media/Events/MediaSeekEndEvent.swift index e89c6ee4a..da8d72cce 100644 --- a/Sources/Snowplow/Media/Events/MediaSeekEndEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaSeekEndEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaSeekStartEvent.swift b/Sources/Snowplow/Media/Events/MediaSeekStartEvent.swift index b47e699e9..465d7b372 100644 --- a/Sources/Snowplow/Media/Events/MediaSeekStartEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaSeekStartEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Media/Events/MediaVolumeChangeEvent.swift b/Sources/Snowplow/Media/Events/MediaVolumeChangeEvent.swift index 57656f7d0..225ad8b5e 100644 --- a/Sources/Snowplow/Media/Events/MediaVolumeChangeEvent.swift +++ b/Sources/Snowplow/Media/Events/MediaVolumeChangeEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Network/DefaultNetworkConnection.swift b/Sources/Snowplow/Network/DefaultNetworkConnection.swift index 900604d9d..21652db4a 100644 --- a/Sources/Snowplow/Network/DefaultNetworkConnection.swift +++ b/Sources/Snowplow/Network/DefaultNetworkConnection.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -19,52 +19,34 @@ public class DefaultNetworkConnection: NSObject, NetworkConnection { // The protocol for connection to the collector @objc public var `protocol`: ProtocolOptions { - get { - return _protocol - } - set { - _protocol = newValue - if builderFinished { setup() } - } + get { return _protocol } + set { _protocol = newValue; setup() } } private var _urlString: String /// The collector endpoint. @objc public var urlString: String { - get { - return urlEndpoint?.absoluteString ?? _urlString - } - set { - _urlString = newValue - if builderFinished { setup() } - } + get { return urlEndpoint?.absoluteString ?? _urlString } + set { _urlString = newValue; setup() } } - @objc - private(set) public var urlEndpoint: URL? + + private var _urlEndpoint: URL? + public var urlEndpoint: URL? { return _urlEndpoint } private var _httpMethod: HttpMethodOptions = .post /// HTTP method, should be .get or .post. @objc public var httpMethod: HttpMethodOptions { - get { - return _httpMethod - } - set(method) { - _httpMethod = method - if builderFinished && urlEndpoint != nil { - setup() - } - } + get { return _httpMethod } + set(method) { _httpMethod = method; setup() } } - private var _emitThreadPoolSize = 15 + private var _emitThreadPoolSize = EmitterDefaults.emitThreadPoolSize /// The number of threads used by the emitter. @objc public var emitThreadPoolSize: Int { - get { - return _emitThreadPoolSize - } + get { return _emitThreadPoolSize } set(emitThreadPoolSize) { self._emitThreadPoolSize = emitThreadPoolSize if dataOperationQueue.maxConcurrentOperationCount != emitThreadPoolSize { @@ -72,121 +54,172 @@ public class DefaultNetworkConnection: NSObject, NetworkConnection { } } } + /// Maximum event size for a GET request. - public var byteLimitGet: Int = 40000 + @objc + public var byteLimitGet: Int = EmitterDefaults.byteLimitGet + /// Maximum event size for a POST request. @objc - public var byteLimitPost = 40000 + public var byteLimitPost = EmitterDefaults.byteLimitPost + + private var _customPostPath: String? /// A custom path that is used on the endpoint to send requests. - @objc - public var customPostPath: String? + @objc public var customPostPath: String? { + get { return _customPostPath } + set { _customPostPath = newValue; setup() } + } + /// Custom headers (key, value) for http requests. @objc public var requestHeaders: [String : String]? + /// Whether to anonymise server-side user identifiers including the `network_userid` and `user_ipaddress` @objc public var serverAnonymisation = false private var dataOperationQueue = OperationQueue() - private var builderFinished = false + + /// Custom timeout for the requests + private let timeout: TimeInterval + + private var protocolClasses: [AnyClass]? + private var _urlSession: URLSession? + private var urlSession: URLSession { + if let urlSession = _urlSession { return urlSession } + + let sessionConfig: URLSessionConfiguration = .default + sessionConfig.timeoutIntervalForRequest = TimeInterval(self.timeout) + sessionConfig.timeoutIntervalForResource = TimeInterval(self.timeout) + sessionConfig.protocolClasses = protocolClasses + + let urlSession = URLSession(configuration: sessionConfig) + self._urlSession = urlSession + return urlSession + } @objc public init(urlString: String, httpMethod: HttpMethodOptions = EmitterDefaults.httpMethod, protocol: ProtocolOptions = EmitterDefaults.httpProtocol, - customPostPath: String? = nil) { + customPostPath: String? = nil, + timeout: TimeInterval = EmitterDefaults.emitTimeout, + protocolClasses: [AnyClass]? = nil) { self._urlString = urlString + self.timeout = timeout super.init() - self.httpMethod = httpMethod - self.protocol = `protocol` - self.customPostPath = customPostPath + self._httpMethod = httpMethod + self._protocol = `protocol` + self._customPostPath = customPostPath + self.protocolClasses = protocolClasses setup() } // MARK: - Implement SPNetworkConnection protocol + + @objc + public func sendRequests(_ requests: [Request]) -> [RequestResult] { + let urlRequests = requests.map { _httpMethod == .get ? buildGet($0) : buildPost($0) } + + var results: [RequestResult] = [] + // if there is only one request, make it directly + if requests.count == 1 { + if let request = requests.first, let urlRequest = urlRequests.first { + let result = DefaultNetworkConnection.makeRequest( + request: request, + urlRequest: urlRequest, + urlSession: urlSession + ) + + results.append(result) + } + } + // if there are more than 1 request, use the operation queue + else if requests.count > 1 { + for (request, urlRequest) in zip(requests, urlRequests) { + dataOperationQueue.addOperation({ + let result = DefaultNetworkConnection.makeRequest( + request: request, + urlRequest: urlRequest, + urlSession: self.urlSession + ) + + objc_sync_enter(self) + results.append(result) + objc_sync_exit(self) + }) + } + dataOperationQueue.waitUntilAllOperationsAreFinished() + } + + return results + } + + // MARK: - Private methods + + private static func makeRequest(request: Request, urlRequest: URLRequest, urlSession: URLSession?) -> RequestResult { + //source: https://forums.developer.apple.com/thread/11519 + var httpResponse: HTTPURLResponse? = nil + var connectionError: Error? = nil + var sem: DispatchSemaphore + + sem = DispatchSemaphore(value: 0) + + urlSession?.dataTask(with: urlRequest) { data, urlResponse, error in + connectionError = error + httpResponse = urlResponse as? HTTPURLResponse + sem.signal() + }.resume() + + let _ = sem.wait(timeout: .distantFuture) + var statusCode: NSNumber? + if let httpResponse = httpResponse { statusCode = NSNumber(value: httpResponse.statusCode) } + + let result = RequestResult(statusCode: statusCode, oversize: request.oversize, storeIds: request.emitterEventIds) + if !result.isSuccessful { + logError(message: "Connection error: " + (connectionError?.localizedDescription ?? "-")) + } + + return result + } private func setup() { // Decode url to extract protocol let url = URL(string: _urlString) var endpoint = _urlString if url?.scheme == "https" { - `protocol` = .https + _protocol = .https } else if url?.scheme == "http" { - `protocol` = .http + _protocol = .http } else { - `protocol` = .https + _protocol = .https endpoint = "https://\(_urlString)" } // Configure - let urlPrefix = `protocol` == .http ? "http://" : "https://" + let urlPrefix = _protocol == .http ? "http://" : "https://" var urlSuffix = _httpMethod == .get ? kSPEndpointGet : kSPEndpointPost if _httpMethod == .post { - if let customPostPath = customPostPath { urlSuffix = customPostPath } + if let customPostPath = _customPostPath { urlSuffix = customPostPath } } // Remove trailing slashes from endpoint to avoid double slashes when appending path endpoint = endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - urlEndpoint = URL(string: endpoint)?.appendingPathComponent(urlSuffix) + _urlEndpoint = URL(string: endpoint)?.appendingPathComponent(urlSuffix) // Log - if urlEndpoint?.scheme != nil && urlEndpoint?.host != nil { - logDebug(message: "Emitter URL created successfully '\(urlEndpoint?.absoluteString ?? "-")'") + if _urlEndpoint?.scheme != nil && _urlEndpoint?.host != nil { + logDebug(message: "Emitter URL created successfully '\(_urlEndpoint?.absoluteString ?? "-")'") } else { - logDebug(message: "Invalid emitter URL: '\(urlEndpoint?.absoluteString ?? "-")'") + logDebug(message: "Invalid emitter URL: '\(_urlEndpoint?.absoluteString ?? "-")'") } let userDefaults = UserDefaults.standard userDefaults.set(endpoint, forKey: kSPErrorTrackerUrl) userDefaults.set(urlSuffix, forKey: kSPErrorTrackerProtocol) userDefaults.set(urlPrefix, forKey: kSPErrorTrackerMethod) - - builderFinished = true } - - @objc - public func sendRequests(_ requests: [Request]) -> [RequestResult] { - var results: [RequestResult] = [] - - for request in requests { - let urlRequest = _httpMethod == .get - ? buildGet(request) - : buildPost(request) - - dataOperationQueue.addOperation({ - //source: https://forums.developer.apple.com/thread/11519 - var httpResponse: HTTPURLResponse? = nil - var connectionError: Error? = nil - var sem: DispatchSemaphore - - sem = DispatchSemaphore(value: 0) - - URLSession.shared.dataTask(with: urlRequest) { data, urlResponse, error in - connectionError = error - httpResponse = urlResponse as? HTTPURLResponse - sem.signal() - }.resume() - - let _ = sem.wait(timeout: .distantFuture) - var statusCode: NSNumber? - if let httpResponse = httpResponse { statusCode = NSNumber(value: httpResponse.statusCode) } - - let result = RequestResult(statusCode: statusCode, oversize: request.oversize, storeIds: request.emitterEventIds) - if !result.isSuccessful { - logError(message: "Connection error: " + (connectionError?.localizedDescription ?? "-")) - } - - objc_sync_enter(self) - results.append(result) - objc_sync_exit(self) - }) - } - dataOperationQueue.waitUntilAllOperationsAreFinished() - return results - } - - // MARK: - Private methods - - func buildPost(_ request: Request) -> URLRequest { + + private func buildPost(_ request: Request) -> URLRequest { var requestData: Data? = nil do { requestData = try JSONSerialization.data(withJSONObject: request.payload?.dictionary ?? [:], options: []) @@ -208,7 +241,7 @@ public class DefaultNetworkConnection: NSObject, NetworkConnection { return urlRequest } - func buildGet(_ request: Request) -> URLRequest { + private func buildGet(_ request: Request) -> URLRequest { let payload = request.payload?.dictionary ?? [:] let url = "\(urlEndpoint!.absoluteString)?\(Utilities.urlEncode(payload))" let anUrl = URL(string: url)! @@ -224,11 +257,12 @@ public class DefaultNetworkConnection: NSObject, NetworkConnection { return urlRequest } - func applyValuesAndHeaderFields(_ requestHeaders: [String : String], to request: inout URLRequest) { + private func applyValuesAndHeaderFields(_ requestHeaders: [String : String], to request: inout URLRequest) { (requestHeaders as NSDictionary).enumerateKeysAndObjects({ key, obj, stop in if let key = key as? String, let obj = obj as? String { request.setValue(obj, forHTTPHeaderField: key) } }) } + } diff --git a/Sources/Snowplow/Network/HttpMethodOptions.swift b/Sources/Snowplow/Network/HttpMethodOptions.swift index 1c9f9ba08..e44e63e5c 100644 --- a/Sources/Snowplow/Network/HttpMethodOptions.swift +++ b/Sources/Snowplow/Network/HttpMethodOptions.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Network/NetworkConnection.swift b/Sources/Snowplow/Network/NetworkConnection.swift index fa7e68ab9..ec6c94c8b 100644 --- a/Sources/Snowplow/Network/NetworkConnection.swift +++ b/Sources/Snowplow/Network/NetworkConnection.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Network/ProtocolOptions.swift b/Sources/Snowplow/Network/ProtocolOptions.swift index 4f884ddea..7c72c9c33 100644 --- a/Sources/Snowplow/Network/ProtocolOptions.swift +++ b/Sources/Snowplow/Network/ProtocolOptions.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Network/Request.swift b/Sources/Snowplow/Network/Request.swift index 21c77eee4..388fe8567 100644 --- a/Sources/Snowplow/Network/Request.swift +++ b/Sources/Snowplow/Network/Request.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Network/RequestCallback.swift b/Sources/Snowplow/Network/RequestCallback.swift index d02783da0..f030dff96 100644 --- a/Sources/Snowplow/Network/RequestCallback.swift +++ b/Sources/Snowplow/Network/RequestCallback.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Network/RequestResult.swift b/Sources/Snowplow/Network/RequestResult.swift index 9a93e26d1..7f24f1fae 100644 --- a/Sources/Snowplow/Network/RequestResult.swift +++ b/Sources/Snowplow/Network/RequestResult.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Payload/Payload.swift b/Sources/Snowplow/Payload/Payload.swift index f6aa86e49..7190a6a8f 100644 --- a/Sources/Snowplow/Payload/Payload.swift +++ b/Sources/Snowplow/Payload/Payload.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -22,8 +22,6 @@ public class Payload: NSObject { /// Returns the payload of that particular SPPayload object. /// - Returns: NSDictionary of data in the object. public var dictionary: [String : Any] { - objc_sync_enter(self) - defer { objc_sync_exit(self) } return payload } @@ -31,8 +29,6 @@ public class Payload: NSObject { /// - Returns: A long representing the byte size of the payload. @objc public var byteSize: Int { - objc_sync_enter(self) - defer { objc_sync_exit(self) } if let data = try? JSONSerialization.data(withJSONObject: payload) { return data.count } @@ -66,7 +62,6 @@ public class Payload: NSObject { /// - key: A key of type NSString @objc public func addValueToPayload(_ value: Any?, forKey key: String) { - objc_sync_enter(self) if value == nil { if payload[key] != nil { payload.removeValue(forKey: key) @@ -74,7 +69,6 @@ public class Payload: NSObject { } else { payload[key] = value } - objc_sync_exit(self) } /// Adds a dictionary of attributes to be appended into the SPPayload instance. It does NOT overwrite the existing data in the object. diff --git a/Sources/Snowplow/Payload/SelfDescribingJson.swift b/Sources/Snowplow/Payload/SelfDescribingJson.swift index 64cc12aef..044b5f019 100644 --- a/Sources/Snowplow/Payload/SelfDescribingJson.swift +++ b/Sources/Snowplow/Payload/SelfDescribingJson.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -82,6 +82,19 @@ public class SelfDescribingJson: NSObject { public convenience init(schema: String, andSelfDescribingJson data: SelfDescribingJson) { self.init(schema: schema, andData: data.dictionary) } + + /// Creates a self-describing JSON using data represented as an Encodable struct. + /// - Parameters: + /// - schema: A valid schema URI. + /// - data: Data represented using an Encodable struct. + /// - Returns: A SelfDescribingJson. + public convenience init(schema: String, andEncodable data: T) throws { + let data = try JSONEncoder().encode(data) + let jsonObject = try JSONSerialization.jsonObject(with: data) + let dict = jsonObject as! [String: Any] + + self.init(schema: schema, andData: dict) + } /// Sets the data field of the self-describing JSON. /// - Parameter data: An SPPayload to be nested into the data. diff --git a/Sources/Snowplow/Snowplow.swift b/Sources/Snowplow/Snowplow.swift index f8fec0898..82d6d864f 100644 --- a/Sources/Snowplow/Snowplow.swift +++ b/Sources/Snowplow/Snowplow.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -198,14 +198,16 @@ public class Snowplow: NSObject { /// the tracker. /// - Returns: The tracker instance created. @objc - public class func createTracker(namespace: String, network networkConfiguration: NetworkConfiguration, configurations: [ConfigurationProtocol] = []) -> TrackerController? { - if let serviceProvider = serviceProviderInstances[namespace] { - serviceProvider.reset(configurations: configurations + [networkConfiguration]) - return serviceProvider.trackerController - } else { - let serviceProvider = ServiceProvider(namespace: namespace, network: networkConfiguration, configurations: configurations) - let _ = registerInstance(serviceProvider) - return serviceProvider.trackerController + public class func createTracker(namespace: String, network networkConfiguration: NetworkConfiguration, configurations: [ConfigurationProtocol] = []) -> TrackerController { + InternalQueue.sync { + if let serviceProvider = serviceProviderInstances[namespace] { + serviceProvider.reset(configurations: configurations + [networkConfiguration]) + return TrackerControllerIQWrapper(controller: serviceProvider.trackerController) + } else { + let serviceProvider = ServiceProvider(namespace: namespace, network: networkConfiguration, configurations: configurations) + let _ = registerInstance(serviceProvider) + return TrackerControllerIQWrapper(controller: serviceProvider.trackerController) + } } } @@ -234,7 +236,7 @@ public class Snowplow: NSObject { /// collector. /// - configurationBuilder: Swift DSL builder for your configuration objects (e.g, `EmitterConfiguration`, `TrackerConfiguration`) /// - Returns: The tracker instance created. - public class func createTracker(namespace: String, network networkConfiguration: NetworkConfiguration, @ConfigurationBuilder _ configurationBuilder: () -> [ConfigurationProtocol]) -> TrackerController? { + public class func createTracker(namespace: String, network networkConfiguration: NetworkConfiguration, @ConfigurationBuilder _ configurationBuilder: () -> [ConfigurationProtocol]) -> TrackerController { let configurations = configurationBuilder() return createTracker(namespace: namespace, network: networkConfiguration, @@ -248,7 +250,12 @@ public class Snowplow: NSObject { /// calling `setTrackerAsDefault(TrackerController)`. @objc public class func defaultTracker() -> TrackerController? { - return defaultServiceProvider?.trackerController + InternalQueue.sync { + if let controller = defaultServiceProvider?.trackerController { + return TrackerControllerIQWrapper(controller: controller) + } + return nil + } } /// Using the namespace identifier is possible to get the trackerController if already instanced. @@ -257,7 +264,12 @@ public class Snowplow: NSObject { /// - Returns: The tracker if it exist with that namespace. @objc public class func tracker(namespace: String) -> TrackerController? { - return serviceProviderInstances[namespace]?.trackerController + InternalQueue.sync { + if let controller = serviceProviderInstances[namespace]?.trackerController { + return TrackerControllerIQWrapper(controller: controller) + } + return nil + } } /// Set the passed tracker as default tracker if it's registered as an active tracker in the app. @@ -269,13 +281,14 @@ public class Snowplow: NSObject { /// - Returns: Whether the tracker passed is registered among the active trackers of the app. @objc public class func setAsDefault(tracker trackerController: TrackerController?) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - - if let namespace = trackerController?.namespace, - let serviceProvider = serviceProviderInstances[namespace] { - defaultServiceProvider = serviceProvider - return true + if let namespace = trackerController?.namespace { + return InternalQueue.sync { + if let serviceProvider = serviceProviderInstances[namespace] { + defaultServiceProvider = serviceProvider + return true + } + return false + } } return false } @@ -291,20 +304,12 @@ public class Snowplow: NSObject { /// - Returns: Whether it has been able to remove it. @objc public class func remove(tracker trackerController: TrackerController?) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } - if let namespace = trackerController?.namespace, - let serviceProvider = (serviceProviderInstances)[namespace] { - serviceProvider.shutdown() - serviceProviderInstances.removeValue(forKey: namespace) - if serviceProvider == defaultServiceProvider { - defaultServiceProvider = nil - } - return true + if let namespace = trackerController?.namespace { + return remove(namespace: namespace) } return false } - + /// Remove all the trackers. /// /// The removed tracker is always stopped. @@ -312,20 +317,22 @@ public class Snowplow: NSObject { /// See ``remove(tracker:)`` to remove a specific tracker. @objc public class func removeAllTrackers() { - objc_sync_enter(self) - defaultServiceProvider = nil - let serviceProviders = serviceProviderInstances.values - serviceProviderInstances.removeAll() - for sp in serviceProviders { - sp.shutdown() + InternalQueue.sync { + defaultServiceProvider = nil + let serviceProviders = serviceProviderInstances.values + serviceProviderInstances.removeAll() + for sp in serviceProviders { + sp.shutdown() + } } - objc_sync_exit(self) } /// - Returns: Set of namespace of the active trackers in the app. @objc class public var instancedTrackerNamespaces: [String] { - return Array(serviceProviderInstances.keys) + InternalQueue.sync { + return Array(serviceProviderInstances.keys) + } } #if os(iOS) || os(macOS) @@ -345,8 +352,6 @@ public class Snowplow: NSObject { // MARK: - Private methods private class func registerInstance(_ serviceProvider: ServiceProvider) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } let namespace = serviceProvider.namespace let isOverriding = serviceProviderInstances[namespace] != nil serviceProviderInstances[namespace] = serviceProvider @@ -359,22 +364,29 @@ public class Snowplow: NSObject { private class func createTrackers(configurationBundles bundles: [ConfigurationBundle]) -> [String] { var namespaces: [String]? = [] for bundle in bundles { - objc_sync_enter(self) if let networkConfiguration = bundle.networkConfiguration { - if let _ = createTracker( - namespace: bundle.namespace, - network: networkConfiguration, - configurations: bundle.configurations) { - namespaces?.append(bundle.namespace) - } + _ = createTracker(namespace: bundle.namespace, network: networkConfiguration, configurations: bundle.configurations) + namespaces?.append(bundle.namespace) } else { // remove tracker if it exists - if let tracker = tracker(namespace: bundle.namespace) { - let _ = remove(tracker: tracker) - } + _ = remove(namespace: bundle.namespace) } - objc_sync_exit(self) } return namespaces ?? [] } + + private class func remove(namespace: String) -> Bool { + InternalQueue.sync { + if let serviceProvider = (serviceProviderInstances)[namespace] { + serviceProvider.shutdown() + serviceProviderInstances.removeValue(forKey: namespace) + if serviceProvider == defaultServiceProvider { + defaultServiceProvider = nil + } + return true + } + return false + } + } + } diff --git a/Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift b/Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift new file mode 100644 index 000000000..09658e2fd --- /dev/null +++ b/Sources/Snowplow/Tracker/CrossDeviceParameterConfiguration.swift @@ -0,0 +1,42 @@ +// Copyright (c) 2013-present 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 + +/// Configuration object for ``TrackerController/decorateLink`` +@objc public class CrossDeviceParameterConfiguration : NSObject { + /// Whether to include the value of ``SessionController.sessionId`` when decorating a link (enabled by default) + @objc var sessionId: Bool + /// Whether to include the value of ``Subject.userId`` when decorating a link + @objc var subjectUserId: Bool + /// Whether to include the value of ``Tracker.appId`` when decorating a link (enabled by default) + @objc var sourceId: Bool + /// Whether to include the value of ``Tracker.platform`` when decorating a link + @objc var sourcePlatform: Bool + /// Optional identifier/information for cross-navigation + @objc var reason: String? + + @objc init( + sessionId: Bool = true, + subjectUserId: Bool = false, + sourceId: Bool = true, + sourcePlatform: Bool = false, + reason: String? = nil + ) { + self.sessionId = sessionId + self.subjectUserId = subjectUserId + self.sourceId = sourceId + self.sourcePlatform = sourcePlatform + self.reason = reason + } +} diff --git a/Sources/Snowplow/Tracker/DevicePlatform.swift b/Sources/Snowplow/Tracker/DevicePlatform.swift index cbe7d6e2b..602c85d8e 100644 --- a/Sources/Snowplow/Tracker/DevicePlatform.swift +++ b/Sources/Snowplow/Tracker/DevicePlatform.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -23,9 +23,10 @@ public enum DevicePlatform : Int { case connectedTV case gameConsole case internetOfThings + case headset } -func devicePlatformToString(_ devicePlatform: DevicePlatform) -> String? { +func devicePlatformToString(_ devicePlatform: DevicePlatform) -> String { switch devicePlatform { case .web: return "web" @@ -43,11 +44,13 @@ func devicePlatformToString(_ devicePlatform: DevicePlatform) -> String? { return "cnsl" case .internetOfThings: return "iot" + case .headset: + return "headset" } } func stringToDevicePlatform(_ devicePlatformString: String) -> DevicePlatform? { - if let index = ["web", "mob", "pc", "srv", "app", "tv", "cnsl", "iot"].firstIndex(of: devicePlatformString) { + if let index = ["web", "mob", "pc", "srv", "app", "tv", "cnsl", "iot", "headset"].firstIndex(of: devicePlatformString) { return DevicePlatform(rawValue: index) } return nil diff --git a/Sources/Snowplow/Tracker/InspectableEvent.swift b/Sources/Snowplow/Tracker/InspectableEvent.swift index e0ab6c1d2..726303312 100644 --- a/Sources/Snowplow/Tracker/InspectableEvent.swift +++ b/Sources/Snowplow/Tracker/InspectableEvent.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Tracker/LogLevel.swift b/Sources/Snowplow/Tracker/LogLevel.swift index 207c19b60..7fc6cf086 100644 --- a/Sources/Snowplow/Tracker/LogLevel.swift +++ b/Sources/Snowplow/Tracker/LogLevel.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Tracker/LoggerDelegate.swift b/Sources/Snowplow/Tracker/LoggerDelegate.swift index b8b937314..74179b02e 100644 --- a/Sources/Snowplow/Tracker/LoggerDelegate.swift +++ b/Sources/Snowplow/Tracker/LoggerDelegate.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Tracker/PlaformContextRetriever.swift b/Sources/Snowplow/Tracker/PlaformContextRetriever.swift new file mode 100644 index 000000000..6ae7d7ed6 --- /dev/null +++ b/Sources/Snowplow/Tracker/PlaformContextRetriever.swift @@ -0,0 +1,143 @@ +// Copyright (c) 2013-present 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 + +/// Overrides for the values for properties of the platform context. +public class PlatformContextRetriever { + + /// Operating system type (e.g., ios, tvos, watchos, osx, android) + public var osType: (() -> String)? = nil + + /// The current version of the operating system + public var osVersion: (() -> String)? = nil + + /// The manufacturer of the product/hardware + public var deviceVendor: (() -> String)? = nil + + /// The end-user-visible name for the end product + public var deviceModel: (() -> String)? = nil + + /// The carrier of the SIM inserted in the device + public var carrier: (() -> String?)? = nil + + /// Type of network the device is connected to + public var networkType: (() -> String?)? = nil + + /// Radio access technology that the device is using + public var networkTechnology: (() -> String?)? = nil + + /// Advertising identifier on iOS + public var appleIdfa: (() -> UUID?)? = nil + + /// UUID identifier for vendors on iOS + public var appleIdfv: (() -> String?)? = nil + + /// Bytes of storage remaining + public var availableStorage: (() -> Int64?)? = nil + + /// Total size of storage in bytes + public var totalStorage: (() -> Int64?)? = nil + + /// Total physical system memory in bytes + public var physicalMemory: (() -> UInt64?)? = nil + + /// Amount of memory in bytes available to the current app + public var appAvailableMemory: (() -> Int?)? = nil + + /// Remaining battery level as an integer percentage of total battery capacity + public var batteryLevel: (() -> Int?)? = nil + + /// Battery state for the device + public var batteryState: (() -> String?)? = nil + + /// A Boolean indicating whether Low Power Mode is enabled + public var lowPowerMode: (() -> Bool?)? = nil + + /// A Boolean indicating whether the device orientation is portrait (either upright or upside down) + public var isPortrait: (() -> Bool?)? = nil + + /// Screen resolution in pixels. Arrives in the form of WIDTHxHEIGHT (e.g., 1200x900). Doesn't change when device orientation changes + public var resolution: (() -> String?)? = nil + + /// Scale factor used to convert logical coordinates to device coordinates of the screen (uses UIScreen.scale on iOS) + public var scale: (() -> Double?)? = nil + + /// System language currently used on the device (ISO 639) + public var language: (() -> String)? = nil + + /// - Parameters: + /// - osType: Operating system type (e.g., ios, tvos, watchos, osx, android) + /// - osVersion: The current version of the operating system + /// - deviceVendor: The manufacturer of the product/hardware + /// - deviceModel: The end-user-visible name for the end product + /// - carrier: The carrier of the SIM inserted in the device + /// - networkType: Type of network the device is connected to + /// - networkTechnology: Radio access technology that the device is using + /// - appleIdfa: Advertising identifier on iOS + /// - appleIdfv: UUID identifier for vendors on iOS + /// - availableStorage: Bytes of storage remaining + /// - totalStorage: Total size of storage in bytes + /// - physicalMemory: Total physical system memory in bytes + /// - appAvailableMemory: Amount of memory in bytes available to the current app + /// - batteryLevel: Remaining battery level as an integer percentage of total battery capacity + /// - batteryState: Battery state for the device + /// - lowPowerMode: A Boolean indicating whether Low Power Mode is enabled + /// - isPortrait: A Boolean indicating whether the device orientation is portrait (either upright or upside down) + /// - resolution: Screen resolution in pixels. Arrives in the form of WIDTHxHEIGHT (e.g., 1200x900). Doesn't change when device orientation changes + /// - scale: Scale factor used to convert logical coordinates to device coordinates of the screen (uses UIScreen.scale on iOS) + /// - language: System language currently used on the device (ISO 639) + public init( + osType: (() -> String)? = nil, + osVersion: (() -> String)? = nil, + deviceVendor: (() -> String)? = nil, + deviceModel: (() -> String)? = nil, + carrier: (() -> String?)? = nil, + networkType: (() -> String?)? = nil, + networkTechnology: (() -> String?)? = nil, + appleIdfa: (() -> UUID?)? = nil, + appleIdfv: (() -> String?)? = nil, + availableStorage: (() -> Int64?)? = nil, + totalStorage: (() -> Int64?)? = nil, + physicalMemory: (() -> UInt64?)? = nil, + appAvailableMemory: (() -> Int?)? = nil, + batteryLevel: (() -> Int?)? = nil, + batteryState: (() -> String?)? = nil, + lowPowerMode: (() -> Bool?)? = nil, + isPortrait: (() -> Bool?)? = nil, + resolution: (() -> String?)? = nil, + scale: (() -> Double?)? = nil, + language: (() -> String)? = nil + ) { + self.osType = osType + self.osVersion = osVersion + self.deviceVendor = deviceVendor + self.deviceModel = deviceModel + self.carrier = carrier + self.networkType = networkType + self.networkTechnology = networkTechnology + self.appleIdfa = appleIdfa + self.appleIdfv = appleIdfv + self.availableStorage = availableStorage + self.totalStorage = totalStorage + self.physicalMemory = physicalMemory + self.appAvailableMemory = appAvailableMemory + self.batteryLevel = batteryLevel + self.batteryState = batteryState + self.lowPowerMode = lowPowerMode + self.isPortrait = isPortrait + self.resolution = resolution + self.scale = scale + self.language = language + } +} diff --git a/Sources/Snowplow/Tracker/SessionState.swift b/Sources/Snowplow/Tracker/SessionState.swift index cf8f0c90b..c8283a771 100644 --- a/Sources/Snowplow/Tracker/SessionState.swift +++ b/Sources/Snowplow/Tracker/SessionState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Tracker/View.swift b/Sources/Snowplow/Tracker/View.swift index 4773ffed0..3cc057133 100644 --- a/Sources/Snowplow/Tracker/View.swift +++ b/Sources/Snowplow/Tracker/View.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -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/Sources/Snowplow/Utils/GDPRProcessingBasis.swift b/Sources/Snowplow/Utils/GDPRProcessingBasis.swift index 13d6f472f..23202ccf7 100644 --- a/Sources/Snowplow/Utils/GDPRProcessingBasis.swift +++ b/Sources/Snowplow/Utils/GDPRProcessingBasis.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/Utils/SPSize.swift b/Sources/Snowplow/Utils/SPSize.swift index 94c170de1..cd6c96617 100644 --- a/Sources/Snowplow/Utils/SPSize.swift +++ b/Sources/Snowplow/Utils/SPSize.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Sources/Snowplow/VisionOS/Entities/ImmersiveSpaceEntity.swift b/Sources/Snowplow/VisionOS/Entities/ImmersiveSpaceEntity.swift new file mode 100644 index 000000000..0d385f340 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Entities/ImmersiveSpaceEntity.swift @@ -0,0 +1,113 @@ +// Copyright (c) 2013-present 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..531e29eb8 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Entities/WindowGroupEntity.swift @@ -0,0 +1,94 @@ +// Copyright (c) 2013-present 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..c9a4608a4 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/DismissImmersiveSpaceEvent.swift @@ -0,0 +1,26 @@ +// Copyright (c) 2013-present 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..d73f41456 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/DismissWindowEvent.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-present 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..b6e168ba5 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/OpenImmersiveSpaceEvent.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-present 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..d41f2e658 --- /dev/null +++ b/Sources/Snowplow/VisionOS/Events/OpenWindowEvent.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-present 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/Configurations/TestConfigurationBuilder.swift b/Tests/Configurations/TestConfigurationBuilder.swift index d3ba1eec4..c0662b0e4 100644 --- a/Tests/Configurations/TestConfigurationBuilder.swift +++ b/Tests/Configurations/TestConfigurationBuilder.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Configurations/TestEmitterConfiguration.swift b/Tests/Configurations/TestEmitterConfiguration.swift index 0abcd88c2..9cea943f2 100644 --- a/Tests/Configurations/TestEmitterConfiguration.swift +++ b/Tests/Configurations/TestEmitterConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -80,6 +80,27 @@ class TestEmitterConfiguration: XCTestCase { XCTAssertEqual(0, tracker.emitter?.dbCount) } + func testAllowsAccessToTheEventStore() { + let networkConnection = MockNetworkConnection(requestOption: .post, statusCode: 200) + let networkConfig = NetworkConfiguration(networkConnection: networkConnection) + + let tracker = createTracker(networkConfig: networkConfig, emitterConfig: EmitterConfiguration()) + + tracker.emitter?.pause() + for i in 0..<10 { + _ = tracker.track(Structured(category: "cat", action: "act").value(NSNumber(value: i))) + } + Thread.sleep(forTimeInterval: 0.5) + + XCTAssertEqual(10, tracker.emitter?.dbCount) + XCTAssertEqual(10, tracker.emitter?.eventStore.count()) + + XCTAssertTrue(tracker.emitter?.eventStore.removeAllEvents() ?? false) + + XCTAssertEqual(0, tracker.emitter?.dbCount) + XCTAssertEqual(0, tracker.emitter?.eventStore.count()) + } + private func createTracker(networkConfig: NetworkConfiguration, emitterConfig: EmitterConfiguration) -> TrackerController { let trackerConfig = TrackerConfiguration() trackerConfig.installAutotracking = false @@ -88,7 +109,7 @@ class TestEmitterConfiguration: XCTestCase { let namespace = "testEmitter" + String(describing: Int.random(in: 0..<100)) return Snowplow.createTracker(namespace: namespace, network: networkConfig, - configurations: [trackerConfig, emitterConfig])! + configurations: [trackerConfig, emitterConfig]) } } diff --git a/Tests/Configurations/TestFocalMeterConfiguration.swift b/Tests/Configurations/TestFocalMeterConfiguration.swift index 3016d0498..463fdf918 100644 --- a/Tests/Configurations/TestFocalMeterConfiguration.swift +++ b/Tests/Configurations/TestFocalMeterConfiguration.swift @@ -2,7 +2,7 @@ // TestFocalMeterConfiguration.swift // Snowplow-iOSTests // -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -130,7 +130,7 @@ class TestFocalMeterConfiguration: XCTestCase { configurations: [ trackerConfig, focalMeterConfig ?? FocalMeterConfiguration(kantarEndpoint: endpoint) - ])! + ]) } #endif diff --git a/Tests/Configurations/TestMultipleInstances.swift b/Tests/Configurations/TestMultipleInstances.swift index 7cf3fa36b..fee386709 100644 --- a/Tests/Configurations/TestMultipleInstances.swift +++ b/Tests/Configurations/TestMultipleInstances.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -25,18 +25,18 @@ class TestMultipleInstances: XCTestCase { func testSingleInstanceIsReconfigurable() { let t1 = Snowplow.createTracker(namespace: "t1", network: NetworkConfiguration(endpoint: "snowplowanalytics.fake")) - XCTAssertEqual(t1?.network?.endpoint, "https://snowplowanalytics.fake/com.snowplowanalytics.snowplow/tp2") + XCTAssertEqual(t1.network?.endpoint, "https://snowplowanalytics.fake/com.snowplowanalytics.snowplow/tp2") let t2 = Snowplow.createTracker(namespace: "t1", network: NetworkConfiguration(endpoint: "snowplowanalytics.fake2")) - XCTAssertEqual(t2?.network?.endpoint, "https://snowplowanalytics.fake2/com.snowplowanalytics.snowplow/tp2") + XCTAssertEqual(t2.network?.endpoint, "https://snowplowanalytics.fake2/com.snowplowanalytics.snowplow/tp2") XCTAssertEqual(["t1"], Snowplow.instancedTrackerNamespaces) - XCTAssertTrue(t1 === t2) + XCTAssertTrue(t1.network?.endpoint == t2.network?.endpoint) } func testMultipleInstances() { let t1 = Snowplow.createTracker(namespace: "t1", network: NetworkConfiguration(endpoint: "snowplowanalytics.fake")) - XCTAssertEqual(t1?.network?.endpoint, "https://snowplowanalytics.fake/com.snowplowanalytics.snowplow/tp2") + XCTAssertEqual(t1.network?.endpoint, "https://snowplowanalytics.fake/com.snowplowanalytics.snowplow/tp2") let t2 = Snowplow.createTracker(namespace: "t2", network: NetworkConfiguration(endpoint: "snowplowanalytics.fake2")) - XCTAssertEqual(t2?.network?.endpoint, "https://snowplowanalytics.fake2/com.snowplowanalytics.snowplow/tp2") + XCTAssertEqual(t2.network?.endpoint, "https://snowplowanalytics.fake2/com.snowplowanalytics.snowplow/tp2") XCTAssertFalse(t1 === t2) let expectedNamespaces = Set(["t1", "t2"]) XCTAssertEqual(expectedNamespaces, Set(Snowplow.instancedTrackerNamespaces)) @@ -46,7 +46,7 @@ class TestMultipleInstances: XCTestCase { let t1 = Snowplow.createTracker(namespace: "t1", network: NetworkConfiguration(endpoint: "snowplowanalytics.fake")) _ = Snowplow.createTracker(namespace: "t2", network: NetworkConfiguration(endpoint: "snowplowanalytics.fake2")) let td = Snowplow.defaultTracker() - XCTAssertEqual(t1?.namespace, td?.namespace) + XCTAssertEqual(t1.namespace, td?.namespace) } func testUpdateDefaultTracker() { @@ -54,7 +54,7 @@ class TestMultipleInstances: XCTestCase { let t2 = Snowplow.createTracker(namespace: "t2", network: NetworkConfiguration(endpoint: "snowplowanalytics.fake2")) _ = Snowplow.setAsDefault(tracker: t2) let td = Snowplow.defaultTracker() - XCTAssertEqual(t2?.namespace, td?.namespace) + XCTAssertEqual(t2.namespace, td?.namespace) } func testRemoveTracker() { diff --git a/Tests/Configurations/TestRemoteConfiguration.swift b/Tests/Configurations/TestRemoteConfiguration.swift index 982d2302e..c362e54db 100644 --- a/Tests/Configurations/TestRemoteConfiguration.swift +++ b/Tests/Configurations/TestRemoteConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Configurations/TestTrackerConfiguration.swift b/Tests/Configurations/TestTrackerConfiguration.swift index f43661be9..bcff59bc8 100644 --- a/Tests/Configurations/TestTrackerConfiguration.swift +++ b/Tests/Configurations/TestTrackerConfiguration.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -94,8 +94,8 @@ class TestTrackerConfiguration: XCTestCase { let tracker = Snowplow.createTracker(namespace: "namespace", network: networkConfig, configurations: [trackerConfig]) XCTAssertNotNil(tracker) - XCTAssertNotNil(tracker?.emitter) - let url = URL(string: tracker?.network?.endpoint ?? "") + XCTAssertNotNil(tracker.emitter) + let url = URL(string: tracker.network?.endpoint ?? "") XCTAssertNotNil(url) let host = url?.host let scheme = url?.scheme @@ -106,8 +106,8 @@ class TestTrackerConfiguration: XCTestCase { XCTAssertEqual(networkConfig.endpoint, derivedEndpoint) XCTAssertEqual(`protocol`, scheme) - XCTAssertEqual(trackerConfig.appId, tracker?.appId) - XCTAssertEqual("namespace", tracker?.namespace) + XCTAssertEqual(trackerConfig.appId, tracker.appId) + XCTAssertEqual("namespace", tracker.namespace) } func testSessionInitialization() { @@ -120,13 +120,13 @@ class TestTrackerConfiguration: XCTestCase { backgroundTimeoutInSeconds: expectedBackground) let tracker = Snowplow.createTracker(namespace: "namespace", network: networkConfig, configurations: [trackerConfig, sessionConfig]) - let foreground = tracker?.session?.foregroundTimeoutInSeconds ?? 0 - let background = tracker?.session?.backgroundTimeoutInSeconds ?? 0 + let foreground = tracker.session?.foregroundTimeoutInSeconds ?? 0 + let background = tracker.session?.backgroundTimeoutInSeconds ?? 0 XCTAssertEqual(expectedForeground, foreground) XCTAssertEqual(expectedBackground, background) - let foregroundMeasure = (tracker?.session)?.foregroundTimeout - let backgroundMeasure = (tracker?.session)?.backgroundTimeout + let foregroundMeasure = (tracker.session)?.foregroundTimeout + let backgroundMeasure = (tracker.session)?.backgroundTimeout XCTAssertEqual(Measurement(value: Double(expectedForeground), unit: UnitDuration.seconds), foregroundMeasure) XCTAssertEqual(Measurement(value: Double(expectedBackground), unit: UnitDuration.seconds), backgroundMeasure) } @@ -136,11 +136,11 @@ class TestTrackerConfiguration: XCTestCase { let trackerConfig = TrackerConfiguration(appId: "appid") trackerConfig.sessionContext = true var tracker = Snowplow.createTracker(namespace: "namespace", network: networkConfig, configurations: [trackerConfig]) - XCTAssertNotNil(tracker?.session) + XCTAssertNotNil(tracker.session) trackerConfig.sessionContext = false tracker = Snowplow.createTracker(namespace: "namespace", network: networkConfig, configurations: [trackerConfig]) - XCTAssertNil(tracker?.session) + XCTAssertNil(tracker.session) } func testSessionConfigurationCallback() { @@ -157,7 +157,7 @@ class TestTrackerConfiguration: XCTestCase { expectation.fulfill() } - guard let tracker = Snowplow.createTracker(namespace: "namespace", network: networkConfig, configurations: [trackerConfig, sessionConfig]) else { return XCTFail() } + let tracker = Snowplow.createTracker(namespace: "namespace", network: networkConfig, configurations: [trackerConfig, sessionConfig]) _ = tracker.track(Timing(category: "cat", variable: "var", timing: 123)) @@ -185,9 +185,11 @@ class TestTrackerConfiguration: XCTestCase { let tracker = Snowplow.createTracker(namespace: "namespace", network: networkConfig, configurations: [trackerConfig, sessionConfig]) - _ = tracker?.track(Timing(category: "cat", variable: "var", timing: 123)) - tracker?.session?.startNewSession() - _ = tracker?.track(Timing(category: "cat", variable: "var", timing: 123)) + _ = tracker.track(Timing(category: "cat", variable: "var", timing: 123)) + Thread.sleep(forTimeInterval: 0.1) + tracker.session?.startNewSession() + _ = tracker.track(Timing(category: "cat", variable: "var", timing: 123)) + Thread.sleep(forTimeInterval: 0.1) wait(for: [expectation], timeout: 10) } @@ -207,7 +209,7 @@ class TestTrackerConfiguration: XCTestCase { // Track fake event let event = Structured(category: "category", action: "action") - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -232,7 +234,7 @@ class TestTrackerConfiguration: XCTestCase { emitterConfiguration.threadPoolSize = 10 let gdprConfiguration = GDPRConfiguration(basis: .consent, documentId: "id", documentVersion: "ver", documentDescription: "desc") let trackerController = Snowplow.createTracker(namespace: "namespace", network: networkConfiguration, configurations: [trackerConfiguration, gdprConfiguration, emitterConfiguration]) - let gdprController = trackerController?.gdpr + let gdprController = trackerController.gdpr // Check gdpr settings XCTAssertEqual(.consent, gdprController?.basisForProcessing) @@ -246,7 +248,7 @@ class TestTrackerConfiguration: XCTestCase { // Check gdpr context added var event = Structured(category: "category", action: "action") - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -266,7 +268,7 @@ class TestTrackerConfiguration: XCTestCase { // Check gdpr context not added event = Structured(category: "category", action: "action") - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -292,14 +294,14 @@ class TestTrackerConfiguration: XCTestCase { emitterConfiguration.eventStore = eventStore emitterConfiguration.threadPoolSize = 10 let trackerController = Snowplow.createTracker(namespace: "namespace", network: networkConfiguration, configurations: [trackerConfiguration, emitterConfiguration]) - let gdprController = trackerController?.gdpr + let gdprController = trackerController.gdpr // Check gdpr settings XCTAssertFalse(gdprController?.isEnabled ?? false) // Check gdpr context not added var event = Structured(category: "category", action: "action") - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -318,7 +320,7 @@ class TestTrackerConfiguration: XCTestCase { // Check gdpr context added event = Structured(category: "category", action: "action") - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -347,7 +349,7 @@ class TestTrackerConfiguration: XCTestCase { // Track an event and retrieve tracked context JSON from event store let event = Structured(category: "category", action: "action") - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -376,7 +378,7 @@ class TestTrackerConfiguration: XCTestCase { // Track fake event let event = Structured(category: "category", action: "action") - let eventId = trackerController?.track(event) + let eventId = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -387,6 +389,6 @@ class TestTrackerConfiguration: XCTestCase { // Check eid field let trackedEventId = payload?["eid"] as? String - XCTAssertTrue((eventId?.uuidString == trackedEventId)) + XCTAssertTrue((eventId.uuidString == trackedEventId)) } } diff --git a/Tests/Configurations/TestTrackerController.swift b/Tests/Configurations/TestTrackerController.swift index 185789481..cca06983f 100644 --- a/Tests/Configurations/TestTrackerController.swift +++ b/Tests/Configurations/TestTrackerController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -50,16 +50,19 @@ class TestTrackerController: XCTestCase { tracker?.emitter?.pause() _ = tracker?.track(Structured(category: "c", action: "a")) + Thread.sleep(forTimeInterval: 0.1) let sessionIdBefore = tracker?.session?.sessionId tracker?.userAnonymisation = true _ = tracker?.track(Structured(category: "c", action: "a")) + Thread.sleep(forTimeInterval: 0.1) let sessionIdAnonymous = tracker?.session?.sessionId XCTAssertFalse((sessionIdBefore == sessionIdAnonymous)) tracker?.userAnonymisation = false _ = tracker?.track(Structured(category: "c", action: "a")) + Thread.sleep(forTimeInterval: 0.1) let sessionIdNotAnonymous = tracker?.session?.sessionId XCTAssertFalse((sessionIdAnonymous == sessionIdNotAnonymous)) diff --git a/Tests/Ecommerce/TestEcommerceController.swift b/Tests/Ecommerce/TestEcommerceController.swift index b1727aae1..8e8fdabe6 100644 --- a/Tests/Ecommerce/TestEcommerceController.swift +++ b/Tests/Ecommerce/TestEcommerceController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,7 +16,8 @@ import XCTest class TestEcommerceController: XCTestCase { - var trackedEvents: [InspectableEvent] = [] + var eventSink: EventSink? + var trackedEvents: [InspectableEvent] { return eventSink?.trackedEvents ?? [] } var tracker: TrackerController? override func setUp() { @@ -25,7 +26,7 @@ class TestEcommerceController: XCTestCase { override func tearDown() { Snowplow.removeAllTrackers() - trackedEvents.removeAll() + eventSink = nil } func testAddScreenEntity() { @@ -101,18 +102,13 @@ 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)) - let plugin = PluginConfiguration(identifier: "testPlugin" + namespace) - .afterTrack { event in - if namespace == self.tracker?.namespace { - self.trackedEvents.append(event) - } - } - + eventSink = EventSink() return Snowplow.createTracker(namespace: namespace, network: networkConfig, - configurations: [trackerConfig, plugin])! + configurations: [trackerConfig, eventSink!]) } private func waitForEventsToBeTracked() { diff --git a/Tests/Ecommerce/TestEcommerceEntities.swift b/Tests/Ecommerce/TestEcommerceEntities.swift index 64fa0caf1..d7bb5a9d1 100644 --- a/Tests/Ecommerce/TestEcommerceEntities.swift +++ b/Tests/Ecommerce/TestEcommerceEntities.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Ecommerce/TestEcommerceEvents.swift b/Tests/Ecommerce/TestEcommerceEvents.swift index f04a9c016..ba3b39dc5 100644 --- a/Tests/Ecommerce/TestEcommerceEvents.swift +++ b/Tests/Ecommerce/TestEcommerceEvents.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,7 +16,8 @@ import XCTest class TestEcommerceEvents: XCTestCase { - var trackedEvents: [InspectableEvent] = [] + var eventSink: EventSink? + var trackedEvents: [InspectableEvent] { return eventSink?.trackedEvents ?? [] } var tracker: TrackerController? override func setUp() { @@ -25,7 +26,7 @@ class TestEcommerceEvents: XCTestCase { override func tearDown() { Snowplow.removeAllTrackers() - trackedEvents.removeAll() + eventSink = nil } func testAddToCart() { @@ -309,16 +310,11 @@ class TestEcommerceEvents: XCTestCase { trackerConfig.lifecycleAutotracking = false let namespace = "testEcommerce" + String(describing: Int.random(in: 0..<100)) - let plugin = PluginConfiguration(identifier: "testPlugin" + namespace) - .afterTrack { event in - if namespace == self.tracker?.namespace { - self.trackedEvents.append(event) - } - } + eventSink = EventSink() return Snowplow.createTracker(namespace: namespace, network: networkConfig, - configurations: [trackerConfig, plugin])! + configurations: [trackerConfig, eventSink!]) } private func waitForEventsToBeTracked() { diff --git a/Tests/Global Contexts/TestGlobalContexts.swift b/Tests/Global Contexts/TestGlobalContexts.swift index f50a124c2..1b154df3b 100644 --- a/Tests/Global Contexts/TestGlobalContexts.swift +++ b/Tests/Global Contexts/TestGlobalContexts.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -48,13 +48,14 @@ class TestGlobalContexts: XCTestCase { ] }) - var generators = [ + let generators = [ "static": staticGC, "generator": generatorGC, "block": blockGC ] - let serviceProvider = getServiceProviderWithGlobalContextGenerators(&generators) - let controller = serviceProvider.globalContextsController + let tracker = createTracker(generators: generators) { _ in + } + guard let controller = tracker?.globalContexts else { XCTFail(); return } var result = Set(controller.tags) var expected = Set(["static", "generator", "block"]) @@ -93,9 +94,9 @@ class TestGlobalContexts: XCTestCase { "key": "value" ]) ]) - var generators: [String : GlobalContext] = [:] - let serviceProvider = getServiceProviderWithGlobalContextGenerators(&generators) - let controller = serviceProvider.globalContextsController + let tracker = createTracker(generators: [:]) { _ in + } + guard let controller = tracker?.globalContexts else { XCTFail(); return } var result = Set(controller.tags) var expected = Set([]) @@ -125,17 +126,20 @@ class TestGlobalContexts: XCTestCase { "key": "value" ]) ]) - var globalContexts = [ + let globalContexts = [ "static": staticGC ] - let serviceProvider = getServiceProviderWithGlobalContextGenerators(&globalContexts) + let expectation = expectation(description: "Received event") + let tracker = createTracker(generators: globalContexts) { event in + XCTAssertTrue(event.entities.count == 1) + XCTAssertEqual(event.entities[0].schema, "schema") + expectation.fulfill() + } let event = Structured(category: "Category", action: "Action") - let trackerEvent = TrackerEvent(event: event, state: nil) + _ = tracker?.track(event) - serviceProvider.tracker.addStateMachineEntities(event: trackerEvent) - XCTAssertTrue(trackerEvent.entities.count == 1) - XCTAssertEqual(trackerEvent.entities[0].schema, "schema") + wait(for: [expectation], timeout: 1) } func testStaticGeneratortWithFilter() { @@ -156,18 +160,21 @@ class TestGlobalContexts: XCTestCase { ], filter: { event in return false }) - var globalContexts = [ + let globalContexts = [ "matching": filterMatchingGC, "notMatching": filterNotMatchingGC ] - let serviceProvider = getServiceProviderWithGlobalContextGenerators(&globalContexts) + let expectation = expectation(description: "Received event") + let tracker = createTracker(generators: globalContexts) { event in + XCTAssertTrue(event.entities.count == 1) + XCTAssertEqual(event.entities[0].schema, "schema") + expectation.fulfill() + } let event = Structured(category: stringToMatch, action: "Action") - let trackerEvent = TrackerEvent(event: event, state: nil) + _ = tracker?.track(event) - serviceProvider.tracker.addStateMachineEntities(event: trackerEvent) - XCTAssertTrue(trackerEvent.entities.count == 1) - XCTAssertEqual(trackerEvent.entities[0].schema, "schema") + wait(for: [expectation], timeout: 1) } func testStaticGeneratorWithRuleset() { @@ -180,35 +187,42 @@ class TestGlobalContexts: XCTestCase { "key": "value" ]) ], ruleset: ruleset) - var globalContexts = [ + let globalContexts = [ "ruleset": rulesetGC ] - let serviceProvider = getServiceProviderWithGlobalContextGenerators(&globalContexts) + let expectation = expectation(description: "Received events") + var receivedEvents: [InspectableEvent] = [] + let tracker = createTracker(generators: globalContexts) { event in + receivedEvents.append(event) + if receivedEvents.count == 3 { + expectation.fulfill() + } + } // Not matching primitive event let primitiveEvent = Structured(category: "Category", action: "Action") - var trackerEvent = TrackerEvent(event: primitiveEvent, state: nil) - serviceProvider.tracker.addStateMachineEntities(event: trackerEvent) - XCTAssertTrue(trackerEvent.entities.count == 0) + _ = tracker?.track(primitiveEvent) // Not matching self-describing event with mobile schema let screenView = ScreenView(name: "Name", screenId: nil) screenView.type = "Type" - trackerEvent = TrackerEvent(event: screenView, state: nil) - serviceProvider.tracker.addStateMachineEntities(event: trackerEvent) - XCTAssertTrue(trackerEvent.entities.count == 0) + _ = tracker?.track(screenView) // Matching self-describing event with general schema let timing = Timing(category: "Category", variable: "Variable", timing: 123) timing.label = "Label" - trackerEvent = TrackerEvent(event: timing, state: nil) - serviceProvider.tracker.addStateMachineEntities(event: trackerEvent) - XCTAssertTrue(trackerEvent.entities.count == 1) - XCTAssertEqual(trackerEvent.entities[0].schema, "schema") + _ = tracker?.track(timing) + + wait(for: [expectation], timeout: 1) + + XCTAssertTrue(receivedEvents[0].entities.count == 0) + XCTAssertTrue(receivedEvents[1].entities.count == 0) + XCTAssertTrue(receivedEvents[2].entities.count == 1) + XCTAssertEqual(receivedEvents[2].entities[0].schema, "schema") } func testBlockGenerator() { - var generators = [ + let generators = [ "generator": GlobalContext(generator: { event in return [ SelfDescribingJson(schema: "schema", andDictionary: [ @@ -217,50 +231,65 @@ class TestGlobalContexts: XCTestCase { ] }) ] - let serviceProvider = getServiceProviderWithGlobalContextGenerators(&generators) + let expectation = expectation(description: "Received event") + let tracker = createTracker(generators: generators) { event in + XCTAssertTrue(event.entities.count == 1) + XCTAssertEqual(event.entities[0].schema, "schema") + expectation.fulfill() + } let event = Structured(category: "Category", action: "Action") - let trackerEvent = TrackerEvent(event: event, state: nil) + _ = tracker?.track(event) - serviceProvider.tracker.addStateMachineEntities(event: trackerEvent) - XCTAssertTrue(trackerEvent.entities.count == 1) - XCTAssertEqual(trackerEvent.entities[0].schema, "schema") + wait(for: [expectation], timeout: 1) } func testContextGenerator() { let contextGeneratorGC = GlobalContext(contextGenerator: GlobalContextGenerator()) - var generators = [ + let generators = [ "contextGenerator": contextGeneratorGC ] - let serviceProvider = getServiceProviderWithGlobalContextGenerators(&generators) + let expectation = expectation(description: "Received event") + let tracker = createTracker(generators: generators) { event in + XCTAssertTrue(event.entities.count == 1) + XCTAssertEqual(event.entities[0].schema, "schema") + expectation.fulfill() + } let event = Structured(category: "StringToMatch", action: "Action") - let trackerEvent = TrackerEvent(event: event, state: nil) + _ = tracker?.track(event) - serviceProvider.tracker.addStateMachineEntities(event: trackerEvent) - XCTAssertTrue(trackerEvent.entities.count == 1) - XCTAssertEqual(trackerEvent.entities[0].schema, "schema") + wait(for: [expectation], timeout: 1) } // MARK: - Utility function - - func getServiceProviderWithGlobalContextGenerators(_ generators: inout [String : GlobalContext]) -> ServiceProvider { - let networkConfig = NetworkConfiguration( - endpoint: "https://com.acme.fake", - method: .post) + private func createTracker(generators: [String : GlobalContext], afterTrack: @escaping (InspectableEvent) -> ()) -> TrackerController? { let trackerConfig = TrackerConfiguration() trackerConfig.appId = "anAppId" - trackerConfig.platformContext = true + trackerConfig.platformContext = false trackerConfig.geoLocationContext = false trackerConfig.base64Encoding = false - trackerConfig.sessionContext = true + trackerConfig.sessionContext = false + trackerConfig.installAutotracking = false + trackerConfig.lifecycleAutotracking = false + trackerConfig.applicationContext = false + trackerConfig.screenContext = false + let gcConfig = GlobalContextsConfiguration() gcConfig.contextGenerators = generators - let serviceProvider = ServiceProvider( - namespace: "aNamespace", + + let networkConfig = NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)) + + let namespace = "testGlobalContexts" + UUID().uuidString + + return Snowplow.createTracker( + namespace: namespace, network: networkConfig, - configurations: [gcConfig]) - return serviceProvider + configurations: [ + EventSink(callback: afterTrack), + trackerConfig, + gcConfig + ]) } } diff --git a/Tests/Global Contexts/TestSchemaRuleset.swift b/Tests/Global Contexts/TestSchemaRuleset.swift index ac2839679..451e690b8 100644 --- a/Tests/Global Contexts/TestSchemaRuleset.swift +++ b/Tests/Global Contexts/TestSchemaRuleset.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Legacy Tests/LegacyTestSubject.swift b/Tests/Legacy Tests/LegacyTestSubject.swift index 8fe7366ae..f14ffc709 100644 --- a/Tests/Legacy Tests/LegacyTestSubject.swift +++ b/Tests/Legacy Tests/LegacyTestSubject.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -33,7 +33,7 @@ class LegacyTestSubject: XCTestCase { func testSubjectInitWithOptions() { let subject = Subject(platformContext: true, geoLocationContext: false) - XCTAssertNotNil(subject.platformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil)) + XCTAssertNotNil(subject.platformDict(userAnonymisation: false)) XCTAssertNotNil(subject.standardDict(userAnonymisation: false)) } diff --git a/Tests/Media/TestMediaAdTracking.swift b/Tests/Media/TestMediaAdTracking.swift index 32aa6278e..7dfae30ac 100644 --- a/Tests/Media/TestMediaAdTracking.swift +++ b/Tests/Media/TestMediaAdTracking.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Media/TestMediaController.swift b/Tests/Media/TestMediaController.swift index 211312adf..b1c7ef3ab 100644 --- a/Tests/Media/TestMediaController.swift +++ b/Tests/Media/TestMediaController.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -16,7 +16,8 @@ import XCTest class TestMediaController: XCTestCase { - var trackedEvents: [InspectableEvent] = [] + var eventSink: EventSink? + var trackedEvents: [InspectableEvent] { return eventSink?.trackedEvents ?? [] } var tracker: TrackerController? var mediaController: MediaController? { tracker?.media } var firstEvent: InspectableEvent? { trackedEvents.first } @@ -31,7 +32,7 @@ class TestMediaController: XCTestCase { override func tearDown() { Snowplow.removeAllTrackers() - trackedEvents.removeAll() + eventSink = nil } // MARK: Media player event tests @@ -239,10 +240,14 @@ class TestMediaController: XCTestCase { waitForEventsToBeTracked() - XCTAssertEqual(15, firstEvent?.payload["percentProgress"] as? Int) - XCTAssertEqual(30, secondEvent?.payload["percentProgress"] as? Int) - XCTAssertEqual(40, trackedEvents[2].payload["percentProgress"] as? Int) - XCTAssertEqual(50, trackedEvents[3].payload["percentProgress"] as? Int) + let adClickEvent = trackedEvents.first { $0.schema == MediaSchemata.eventSchema("ad_click") } + XCTAssertEqual(15, adClickEvent?.payload["percentProgress"] as? Int) + let adSkipEvent = trackedEvents.first { $0.schema == MediaSchemata.eventSchema("ad_skip") } + XCTAssertEqual(30, adSkipEvent?.payload["percentProgress"] as? Int) + let adResumeEvent = trackedEvents.first { $0.schema == MediaSchemata.eventSchema("ad_resume") } + XCTAssertEqual(40, adResumeEvent?.payload["percentProgress"] as? Int) + let adPauseEvent = trackedEvents.first { $0.schema == MediaSchemata.eventSchema("ad_pause") } + XCTAssertEqual(50, adPauseEvent?.payload["percentProgress"] as? Int) } func testSetsQualityPropertiesInQualityChangeEvent() { @@ -316,10 +321,10 @@ class TestMediaController: XCTestCase { player: MediaPlayerEntity(duration: 10), session: session) - media.track(MediaPlayEvent()) + track(MediaPlayEvent(), media: media) timeTraveler.travel(by: 10.0) - media.update(player: MediaPlayerEntity(currentTime: 10.0)) - media.track(MediaEndEvent()) + update(player: MediaPlayerEntity(currentTime: 10.0), media: media) + track(MediaEndEvent(), media: media) waitForEventsToBeTracked() @@ -332,7 +337,7 @@ class TestMediaController: XCTestCase { // MARK: Ping events func testStartsSendingPingEventsAfterSessionStarts() { - let pingInterval = MediaPingInterval(timerProvider: MockTimer.self) + let pingInterval = MediaPingInterval(startTimer: MockTimer.startTimer) _ = MediaTrackingImpl(id: "media1", tracker: tracker!, pingInterval: pingInterval) MockTimer.currentTimer.fire() @@ -344,12 +349,12 @@ class TestMediaController: XCTestCase { } func testShouldSendPingEventsRegardlessOfOtherEvents() { - let pingInterval = MediaPingInterval(timerProvider: MockTimer.self) + let pingInterval = MediaPingInterval(startTimer: MockTimer.startTimer) let media = MediaTrackingImpl(id: "media1", tracker: tracker!, pingInterval: pingInterval) - media.track(MediaPlayEvent()) + track(MediaPlayEvent(), media: media) MockTimer.currentTimer.fire() - media.track(MediaPauseEvent()) + track(MediaPauseEvent(), media: media) MockTimer.currentTimer.fire() waitForEventsToBeTracked() @@ -358,10 +363,10 @@ class TestMediaController: XCTestCase { } func testShouldStopSendingPingEventsWhenPaused() { - let pingInterval = MediaPingInterval(maxPausedPings: 2, timerProvider: MockTimer.self) + let pingInterval = MediaPingInterval(maxPausedPings: 2, startTimer: MockTimer.startTimer) let media = MediaTrackingImpl(id: "media1", tracker: tracker!, pingInterval: pingInterval) - media.update(player: MediaPlayerEntity(paused: true)) + update(player: MediaPlayerEntity(paused: true), media: media) for _ in 0..<5 { MockTimer.currentTimer.fire() } @@ -372,10 +377,10 @@ class TestMediaController: XCTestCase { } func testShouldNotStopSendingPingEventsWhenPlaying() { - let pingInterval = MediaPingInterval(maxPausedPings: 2, timerProvider: MockTimer.self) + let pingInterval = MediaPingInterval(maxPausedPings: 2, startTimer: MockTimer.startTimer) let media = MediaTrackingImpl(id: "media1", tracker: tracker!, pingInterval: pingInterval) - media.update(player: MediaPlayerEntity(paused: false)) + update(player: MediaPlayerEntity(paused: false), media: media) for _ in 0..<5 { MockTimer.currentTimer.fire() } @@ -450,25 +455,27 @@ class TestMediaController: XCTestCase { trackerConfig.lifecycleAutotracking = false let namespace = "testMedia" + String(describing: Int.random(in: 0..<100)) - let plugin = PluginConfiguration(identifier: "testPlugin" + namespace) - .afterTrack { event in - if namespace == self.tracker?.namespace { - self.trackedEvents.append(event) - } - } - + self.eventSink = EventSink() return Snowplow.createTracker(namespace: namespace, network: networkConfig, - configurations: [trackerConfig, plugin])! + 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 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { () -> Void in expect.fulfill() } wait(for: [expect], timeout: 1) } + + private func track(_ event: Event, player: MediaPlayerEntity? = nil, ad: MediaAdEntity? = nil, adBreak: MediaAdBreakEntity? = nil, media: MediaTracking?) { + InternalQueue.sync { media?.track(event, player: player, ad: ad, adBreak: adBreak) } + } + + private func update(player: MediaPlayerEntity? = nil, ad: MediaAdEntity? = nil, adBreak: MediaAdBreakEntity? = nil, media: MediaTracking?) { + InternalQueue.sync { media?.update(player: player, ad: ad, adBreak: adBreak) } + } } extension InspectableEvent { @@ -480,4 +487,3 @@ extension InspectableEvent { return entities.first { $0.schema == MediaSchemata.sessionSchema }?.data } } - diff --git a/Tests/Media/TestMediaEventAndEntitySerialization.swift b/Tests/Media/TestMediaEventAndEntitySerialization.swift index 249cb4d44..d07dba6fc 100644 --- a/Tests/Media/TestMediaEventAndEntitySerialization.swift +++ b/Tests/Media/TestMediaEventAndEntitySerialization.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Media/TestMediaSessionTrackingStats.swift b/Tests/Media/TestMediaSessionTrackingStats.swift index 99ffe9151..867b7d765 100644 --- a/Tests/Media/TestMediaSessionTrackingStats.swift +++ b/Tests/Media/TestMediaSessionTrackingStats.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/ScreenViewTracking/TestListItemViewModifier.swift b/Tests/ScreenViewTracking/TestListItemViewModifier.swift new file mode 100644 index 000000000..5785f74c8 --- /dev/null +++ b/Tests/ScreenViewTracking/TestListItemViewModifier.swift @@ -0,0 +1,68 @@ +// Copyright (c) 2013-present 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 86% rename from Tests/TestScreenState.swift rename to Tests/ScreenViewTracking/TestScreenState.swift index 30ceeb8bd..40af146f1 100644 --- a/Tests/TestScreenState.swift +++ b/Tests/ScreenViewTracking/TestScreenState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -59,9 +59,7 @@ class TestScreenState: XCTestCase { func testScreenStateMachine() { let eventStore = MockEventStore() - let emitter = Emitter(urlEndpoint: "http://snowplow-fake-url.com") { emitter in - emitter.eventStore = eventStore - } + let emitter = Emitter(namespace: "namespace", urlEndpoint: "http://snowplow-fake-url.com", eventStore: eventStore) let tracker = Tracker(trackerNamespace: "namespace", appId: nil, emitter: emitter) { tracker in tracker.base64Encoded = false tracker.screenContext = true @@ -70,7 +68,7 @@ class TestScreenState: XCTestCase { emitter.pauseEmit() // Send events - _ = tracker.track(Timing(category: "category", variable: "variable", timing: 123)) + track(Timing(category: "category", variable: "variable", timing: 123), tracker) Thread.sleep(forTimeInterval: 1) if eventStore.lastInsertedRow == -1 { XCTFail() @@ -81,7 +79,7 @@ class TestScreenState: XCTestCase { XCTAssertNil(entities) let uuid = UUID() - _ = tracker.track(ScreenView(name: "screen1", screenId: uuid)) + track(ScreenView(name: "screen1", screenId: uuid), tracker) Thread.sleep(forTimeInterval: 1) if eventStore.lastInsertedRow == -1 { XCTFail() @@ -92,7 +90,7 @@ class TestScreenState: XCTestCase { XCTAssertNotNil(entities) XCTAssertTrue(entities!.contains(uuid.uuidString)) - _ = tracker.track(Timing(category: "category", variable: "variable", timing: 123)) + track(Timing(category: "category", variable: "variable", timing: 123), tracker) Thread.sleep(forTimeInterval: 1) if eventStore.lastInsertedRow == -1 { XCTFail() @@ -104,7 +102,7 @@ class TestScreenState: XCTestCase { XCTAssertTrue(entities!.contains(uuid.uuidString)) let uuid2 = UUID() - _ = tracker.track(ScreenView(name: "screen2", screenId: uuid2)) + track(ScreenView(name: "screen2", screenId: uuid2), tracker) Thread.sleep(forTimeInterval: 1) if eventStore.lastInsertedRow == -1 { XCTFail() @@ -119,7 +117,7 @@ class TestScreenState: XCTestCase { XCTAssertTrue(eventPayload!.contains(uuid.uuidString)) XCTAssertTrue(eventPayload!.contains(uuid2.uuidString)) - _ = tracker.track(Timing(category: "category", variable: "variable", timing: 123)) + track(Timing(category: "category", variable: "variable", timing: 123), tracker) Thread.sleep(forTimeInterval: 1) if eventStore.lastInsertedRow == -1 { XCTFail() @@ -130,4 +128,10 @@ class TestScreenState: XCTestCase { XCTAssertNotNil(entities) XCTAssertTrue(entities!.contains(uuid2.uuidString)) } + + private func track(_ event: Event, _ tracker: Tracker) { + InternalQueue.sync { + _ = tracker.track(event) + } + } } diff --git a/Tests/ScreenViewTracking/TestScreenSummaryStateMachine.swift b/Tests/ScreenViewTracking/TestScreenSummaryStateMachine.swift new file mode 100644 index 000000000..48ea722b1 --- /dev/null +++ b/Tests/ScreenViewTracking/TestScreenSummaryStateMachine.swift @@ -0,0 +1,142 @@ +// Copyright (c) 2013-present 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 93% rename from Tests/TestScreenViewModifier.swift rename to Tests/ScreenViewTracking/TestScreenViewModifier.swift index 82743a788..08f629c33 100644 --- a/Tests/TestScreenViewModifier.swift +++ b/Tests/ScreenViewTracking/TestScreenViewModifier.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -58,8 +58,7 @@ class TestScreenViewModifier: XCTestCase { namespace: "screenViewTracker", network: networkConfig, configurations: [ - PluginConfiguration(identifier: "screenViewPlugin") - .afterTrack(closure: afterTrack), + EventSink(callback: afterTrack), TrackerConfiguration() .installAutotracking(false) .lifecycleAutotracking(false) diff --git a/Tests/Storage/TestDatabase.swift b/Tests/Storage/TestDatabase.swift new file mode 100644 index 000000000..7add3ebbf --- /dev/null +++ b/Tests/Storage/TestDatabase.swift @@ -0,0 +1,159 @@ +// Copyright (c) 2013-present 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 os(iOS) || os(macOS) || os(visionOS) + +import XCTest +@testable import SnowplowTracker + +class TestDatabase: XCTestCase { + + func testDatabasePathConsistentForNamespace() { + XCTAssertEqual(Database.dbPath(namespace: "ns1"), Database.dbPath(namespace: "ns1")) + } + + func testDatabasePathDiffersByNamespace() { + XCTAssertNotEqual(Database.dbPath(namespace: "ns1"), Database.dbPath(namespace: "ns2")) + } + + func testDatabasePathDoesntContainSpecialChars() { + XCTAssertFalse(Database.dbPath(namespace: "%*$@db").contains("%*$@")) + } + + func testInsertsAndReadsRow() { + let database = createDatabase("db1") + + database.insertRow(["test": true]) + let rows = database.readRows(numRows: 100) + + XCTAssertEqual(1, rows.count) + XCTAssertEqual( + try? JSONSerialization.data(withJSONObject: rows.first?.data ?? []), + try? JSONSerialization.data(withJSONObject: ["test": true]) + ) + } + + func testCanWorkWithTwoOpenDatabases() { + let db1 = createDatabase("db1") + let db2 = createDatabase("db2") + + db1.insertRow(["test": 1]) + db2.insertRow(["test": 2]) + let rows1 = db1.readRows(numRows: 100) + let rows2 = db2.readRows(numRows: 100) + + XCTAssertEqual(1, rows1.count) + XCTAssertEqual(1, rows2.count) + + XCTAssertNotEqual( + try? JSONSerialization.data(withJSONObject: rows1.first?.data ?? []), + try? JSONSerialization.data(withJSONObject: rows2.first?.data ?? []) + ) + } + + func testDeleteAllRows() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + db.insertRow(["test": 2]) + + XCTAssertEqual(db.readRows(numRows: 100).count, 2) + + XCTAssertTrue(db.deleteRows()) + + XCTAssertEqual(db.readRows(numRows: 100).count, 0) + } + + func testDeleteSpecificRows() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + db.insertRow(["test": 2]) + + let rows = db.readRows(numRows: 100) + XCTAssertEqual(rows.count, 2) + + XCTAssertTrue(db.deleteRows(ids: [rows.first?.id ?? 0])) + + let newRows = db.readRows(numRows: 100) + XCTAssertEqual(newRows.count, 1) + XCTAssertEqual(newRows.first?.id, rows.last?.id) + } + + func testSelectRowsWithLimit() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + db.insertRow(["test": 2]) + + let rows = db.readRows(numRows: 1) + XCTAssertEqual(rows.count, 1) + } + + func testCountRows() { + let db = createDatabase("db") + + db.insertRow(["test": 1]) + XCTAssertEqual(db.countRows(), 1) + db.insertRow(["test": 2]) + XCTAssertEqual(db.countRows(), 2) + XCTAssertTrue(db.deleteRows()) + XCTAssertEqual(db.countRows(), 0) + } + + func testRemoveOldEventsByAge() { + let db = createDatabase("db") + + for i in 1...5 { + db.insertRow(["test": i]) + } + + Thread.sleep(forTimeInterval: 2) + + for i in 6...10 { + db.insertRow(["test": i]) + } + + db.removeOldEvents(maxSize: 10, maxAge: 1) + + let rows = db.readRows(numRows: 10) + XCTAssertEqual(rows.count, 5) + XCTAssertEqual( + rows.map { $0.data["test"] as! Int }.min(), + 6 + ) + } + + func testRemoveOldestEventsByMaxSize() { + let db = createDatabase("db") + + for i in 1...5 { + db.insertRow(["test": i]) + } + db.removeOldEvents(maxSize: 3, maxAge: 5) + + let rows = db.readRows(numRows: 5) + XCTAssertEqual(rows.count, 3) + XCTAssertEqual( + rows.map { $0.data["test"] as! Int }.min(), + 3 + ) + } + + private func createDatabase(_ namespace: String) -> Database { + DatabaseHelpers.clearPreviousDatabase(namespace) + return Database(namespace: namespace) + } +} + +#endif diff --git a/Tests/TestSQLiteEventStore.swift b/Tests/Storage/TestSQLiteEventStore.swift similarity index 57% rename from Tests/TestSQLiteEventStore.swift rename to Tests/Storage/TestSQLiteEventStore.swift index f152b7d3f..fcd114575 100644 --- a/Tests/TestSQLiteEventStore.swift +++ b/Tests/Storage/TestSQLiteEventStore.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -11,24 +11,16 @@ // express or implied. See the Apache License Version 2.0 for the specific // language governing permissions and limitations there under. -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(visionOS) import XCTest @testable import SnowplowTracker class TestSQLiteEventStore: XCTestCase { - override func setUp() { - _ = SQLiteEventStore.removeUnsentEventsExcept(forNamespaces: []) - } - - func testInit() { - let eventStore = SQLiteEventStore(namespace: "aNamespace") - XCTAssertNotNil(eventStore) - } func testInsertPayload() { - let eventStore = SQLiteEventStore(namespace: "aNamespace") - _ = eventStore.removeAllEvents() + let eventStore = createEventStore("aNamespace") + removeAllEvents(eventStore) // Build an event let payload = Payload() @@ -38,20 +30,20 @@ class TestSQLiteEventStore: XCTestCase { payload.addValueToPayload("MEEEE", forKey: "refr") // Insert an event - _ = eventStore.insertEvent(payload) + addEvent(payload, eventStore) - XCTAssertEqual(eventStore.count(), 1) - XCTAssertEqual(eventStore.getEventWithId(1)?.payload.dictionary as! [String : String], + XCTAssertEqual(count(eventStore), 1) + let emittableEvents = emittableEvents(withQueryLimit: 10, eventStore) + XCTAssertEqual(emittableEvents.first?.payload.dictionary as! [String : String], payload.dictionary as! [String : String]) - XCTAssertEqual(eventStore.getLastInsertedRowId(), 1) - _ = eventStore.removeEvent(withId: 1) + removeEvent(withId: emittableEvents.first?.storeId ?? 0, eventStore) - XCTAssertEqual(eventStore.count(), 0) + XCTAssertEqual(count(eventStore), 0) } func testInsertManyPayloads() { - let eventStore = SQLiteEventStore(namespace: "aNamespace") - _ = eventStore.removeAllEvents() + let eventStore = createEventStore("aNamespace") + removeAllEvents(eventStore) // Build an event let payload = Payload() @@ -61,43 +53,29 @@ class TestSQLiteEventStore: XCTestCase { payload.addValueToPayload("MEEEE", forKey: "refr") for _ in 0..<250 { - _ = eventStore.insertEvent(payload) + addEvent(payload, eventStore) } - - XCTAssertEqual(eventStore.count(), 250) - XCTAssertEqual(eventStore.getAllEventsLimited(600)?.count, 250) - XCTAssertEqual(eventStore.getAllEventsLimited(150)?.count, 150) - XCTAssertEqual(eventStore.getAllEvents()?.count, 250) - - _ = eventStore.removeAllEvents() - XCTAssertEqual(eventStore.count(), 0) + + XCTAssertEqual(count(eventStore), 250) + XCTAssertEqual(emittableEvents(withQueryLimit: 600, eventStore).count, 250) + XCTAssertEqual(emittableEvents(withQueryLimit: 150, eventStore).count, 150) + + removeAllEvents(eventStore) + XCTAssertEqual(count(eventStore), 0) } func testSQLiteEventStoreCreateSQLiteFile() { - _ = SQLiteEventStore(namespace: "aNamespace") + let eventStore = createEventStore("aNamespace") + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path let dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace.sqlite").path XCTAssertTrue(FileManager.default.fileExists(atPath: dbPath)) } - func testSQLiteEventStoreRemoveFiles() { - _ = SQLiteEventStore(namespace: "aNamespace1") - _ = SQLiteEventStore(namespace: "aNamespace2") - _ = SQLiteEventStore(namespace: "aNamespace3") - let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] - let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path - _ = SQLiteEventStore.removeUnsentEventsExcept(forNamespaces: ["aNamespace2"]) - var dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace1.sqlite").path - XCTAssertFalse(FileManager.default.fileExists(atPath: dbPath)) - dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace2.sqlite").path - XCTAssertTrue(FileManager.default.fileExists(atPath: dbPath)) - dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNamespace3.sqlite").path - XCTAssertFalse(FileManager.default.fileExists(atPath: dbPath)) - } - func testSQLiteEventStoreInvalidNamespaceConversion() { - _ = SQLiteEventStore(namespace: "namespace*.^?1ò2@") + let eventStore = createEventStore("namespace*.^?1ò2@") + let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] let snowplowDirPath = URL(fileURLWithPath: libraryPath).appendingPathComponent("snowplow").path let dbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-namespace-1-2-.sqlite").path @@ -105,11 +83,11 @@ class TestSQLiteEventStore: XCTestCase { } func testMigrationFromLegacyToNamespacedEventStore() { - var eventStore = SQLiteEventStore(namespace: "aNamespace") - eventStore.addEvent(Payload(dictionary: [ + var eventStore = self.createEventStore("aNamespace") + addEvent(Payload(dictionary: [ "key": "value" - ])) - XCTAssertEqual(1, eventStore.count()) + ]), eventStore) + XCTAssertEqual(1, count(eventStore)) // Create fake legacy database let libraryPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).map(\.path)[0] @@ -123,28 +101,53 @@ class TestSQLiteEventStore: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: newDbPath)) // Migrate database when SQLiteEventStore is launched the first time - eventStore = SQLiteEventStore(namespace: "aNewNamespace") + eventStore = createEventStore("aNewNamespace") + XCTAssertEqual(1, count(eventStore)) newDbPath = URL(fileURLWithPath: snowplowDirPath).appendingPathComponent("snowplowEvents-aNewNamespace.sqlite").path XCTAssertFalse(FileManager.default.fileExists(atPath: oldDbPath)) XCTAssertTrue(FileManager.default.fileExists(atPath: newDbPath)) - XCTAssertEqual(1, eventStore.count()) - for event in eventStore.getAllEvents() ?? [] { + for event in emittableEvents(withQueryLimit: 100, eventStore) { XCTAssertEqual("value", event.payload.dictionary["key"] as? String) } } func testMultipleAccessToSameSQLiteFile() { - let eventStore1 = SQLiteEventStore(namespace: "aNamespace") - eventStore1.addEvent(Payload(dictionary: [ + let eventStore1 = createEventStore("aNamespace") + addEvent(Payload(dictionary: [ "key1": "value1" - ])) - XCTAssertEqual(1, eventStore1.count()) + ]), eventStore1) + XCTAssertEqual(1, count(eventStore1)) let eventStore2 = SQLiteEventStore(namespace: "aNamespace") - eventStore2.addEvent(Payload(dictionary: [ + addEvent(Payload(dictionary: [ "key2": "value2" - ])) - XCTAssertEqual(2, eventStore2.count()) + ]), eventStore2) + XCTAssertEqual(2, count(eventStore2)) + } + + private func createEventStore(_ namespace: String, limit: Int = 250) -> SQLiteEventStore { + DatabaseHelpers.clearPreviousDatabase(namespace) + return SQLiteEventStore(namespace: namespace, limit: limit) + } + + private func addEvent(_ payload: Payload, _ eventStore: EventStore) { + InternalQueue.sync { eventStore.addEvent(payload) } + } + + private func removeAllEvents(_ eventStore: EventStore) { + InternalQueue.sync { _ = eventStore.removeAllEvents() } + } + + private func removeEvent(withId: Int64, _ eventStore: EventStore) { + InternalQueue.sync { _ = eventStore.removeEvent(withId: withId) } + } + + private func count(_ eventStore: EventStore) -> UInt { + InternalQueue.sync { return eventStore.count() } + } + + private func emittableEvents(withQueryLimit: UInt, _ eventStore: EventStore) -> [EmitterEvent] { + InternalQueue.sync { return eventStore.emittableEvents(withQueryLimit: withQueryLimit) } } } diff --git a/Tests/TestDataPersistence.swift b/Tests/TestDataPersistence.swift index 81e088f77..5ccd0b2e3 100644 --- a/Tests/TestDataPersistence.swift +++ b/Tests/TestDataPersistence.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Legacy Tests/LegacyTestEmitter.swift b/Tests/TestEmitter.swift similarity index 62% rename from Tests/Legacy Tests/LegacyTestEmitter.swift rename to Tests/TestEmitter.swift index 255372efc..501af50bc 100644 --- a/Tests/Legacy Tests/LegacyTestEmitter.swift +++ b/Tests/TestEmitter.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -14,29 +14,9 @@ import XCTest @testable import SnowplowTracker -//class BrokenNetworkConnection: NetworkConnection { -// func sendRequests(_ requests: [Request]) -> [RequestResult] { -// NSException.raise("BrokenNetworkConnection", format: "Fake exception on network connection.") -// return nil -// } -// -// var urlEndpoint: URL? { -// NSException.raise("BrokenNetworkConnection", format: "Fake exception on network connection.") -// return nil -// } -// -// var httpMethod: HttpMethodOptions { -// NSException.raise("BrokenNetworkConnection", format: "Fake exception on network connection.") -// return .get -// } -//} - -//#pragma clang diagnostic push -//#pragma clang diagnostic ignored "-Wdeprecated-declarations" - let TEST_SERVER_EMITTER = "www.notarealurl.com" -class LegacyTestEmitter: XCTestCase { +class TestEmitter: XCTestCase { override func setUp() { super.setUp() Logger.logLevel = .verbose @@ -49,13 +29,16 @@ class LegacyTestEmitter: XCTestCase { func testEmitterBuilderAndOptions() { let `protocol` = "https" - let emitter = Emitter(urlEndpoint: TEST_SERVER_EMITTER) { emitter in - emitter.method = .post - emitter.protocol = .https - emitter.emitThreadPoolSize = 30 + let emitter = Emitter( + namespace: "ns1", + urlEndpoint: TEST_SERVER_EMITTER, + method: .post, + protocol: .https + ) { emitter in emitter.byteLimitGet = 30000 emitter.byteLimitPost = 35000 emitter.emitRange = 500 + emitter.emitThreadPoolSize = 30 } var url = "\(`protocol`)://\(TEST_SERVER_EMITTER)/com.snowplowanalytics.snowplow/tp2" @@ -71,11 +54,13 @@ class LegacyTestEmitter: XCTestCase { XCTAssertEqual(emitter.byteLimitPost, 35000) XCTAssertEqual(emitter.protocol, .https) - let customPathEmitter = Emitter(urlEndpoint: TEST_SERVER_EMITTER) { emitter in - emitter.method = .post - emitter.protocol = .https - emitter.customPostPath = "/com.acme.company/tpx" - emitter.emitThreadPoolSize = 30 + let customPathEmitter = Emitter( + namespace: "ns2", + urlEndpoint: TEST_SERVER_EMITTER, + method: .post, + protocol: .https, + customPostPath: "/com.acme.company/tpx" + ) { emitter in emitter.byteLimitGet = 30000 emitter.byteLimitPost = 35000 emitter.emitRange = 500 @@ -104,61 +89,45 @@ class LegacyTestEmitter: XCTestCase { // Test extra functions XCTAssertFalse(emitter.isSending) - XCTAssertTrue(emitter.dbCount >= 0) + XCTAssertTrue(dbCount(emitter) >= 0) // Allow timer to be set RunLoop.main.run(until: Date(timeIntervalSinceNow: 1)) emitter.resumeTimer() - emitter.flush() + flush(emitter) } // MARK: - Emitting tests -// func testEmitEventWithBrokenNetworkConnectionDoesntFreezeStatus() { -// let networkConnection = SPBrokenNetworkConnection() -// let emitter = self.emitter(with: networkConnection, bufferOption: SPBufferOptionSingle) -// emitter?.addPayload(toBuffer: generatePayloads(1)?.first) -// -// Thread.sleep(forTimeInterval: 1) -// -// XCTAssertFalse(emitter?.getSendingStatus()) -// -// emitter?.flush() -// } - func testEmitSingleGetEventWithSuccess() { let networkConnection = MockNetworkConnection(requestOption: .get, statusCode: 200) let emitter = self.emitter(with: networkConnection, bufferOption: .single) - emitter.addPayload(toBuffer: generatePayloads(1).first!) + addPayload(generatePayloads(1).first!, emitter) - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 1) - } + Thread.sleep(forTimeInterval: 1) XCTAssertEqual(1, networkConnection.previousResults.count) XCTAssertEqual(1, networkConnection.previousResults.first!.count) XCTAssertTrue(networkConnection.previousResults.first!.first!.isSuccessful) - XCTAssertEqual(0, emitter.dbCount) + XCTAssertEqual(0, dbCount(emitter)) - emitter.flush() + flush(emitter) } func testEmitSingleGetEventWithNoSuccess() { let networkConnection = MockNetworkConnection(requestOption: .get, statusCode: 500) let emitter = self.emitter(with: networkConnection, bufferOption: .single) - emitter.addPayload(toBuffer: generatePayloads(1).first!) + addPayload(generatePayloads(1).first!, emitter) - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 1) - } + Thread.sleep(forTimeInterval: 1) XCTAssertEqual(1, networkConnection.previousResults.count) XCTAssertEqual(1, networkConnection.previousResults.first!.count) XCTAssertFalse(networkConnection.previousResults.first!.first!.isSuccessful) - XCTAssertEqual(1, emitter.dbCount) + XCTAssertEqual(1, dbCount(emitter)) - emitter.flush() + flush(emitter) } func testEmitTwoGetEventsWithSuccess() { @@ -166,14 +135,13 @@ class LegacyTestEmitter: XCTestCase { let emitter = self.emitter(with: networkConnection, bufferOption: .single) for payload in generatePayloads(2) { - emitter.addPayload(toBuffer: payload) + addPayload(payload, emitter) } - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 1) - } + Thread.sleep(forTimeInterval: 1) - XCTAssertEqual(0, emitter.dbCount) + XCTAssertEqual(0, dbCount(emitter)) + XCTAssertEqual(2, networkConnection.previousResults.count) var totEvents = 0 for results in networkConnection.previousResults { for result in results { @@ -183,7 +151,7 @@ class LegacyTestEmitter: XCTestCase { } XCTAssertEqual(2, totEvents) - emitter.flush() + flush(emitter) } func testEmitTwoGetEventsWithNoSuccess() { @@ -191,64 +159,58 @@ class LegacyTestEmitter: XCTestCase { let emitter = self.emitter(with: networkConnection, bufferOption: .single) for payload in generatePayloads(2) { - emitter.addPayload(toBuffer: payload) + addPayload(payload, emitter) } - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 1) - } + Thread.sleep(forTimeInterval: 1) - XCTAssertEqual(2, emitter.dbCount) + XCTAssertEqual(2, dbCount(emitter)) for results in networkConnection.previousResults { for result in results { XCTAssertFalse(result.isSuccessful) } } - emitter.flush() + flush(emitter) } func testEmitSinglePostEventWithSuccess() { let networkConnection = MockNetworkConnection(requestOption: .post, statusCode: 200) let emitter = self.emitter(with: networkConnection, bufferOption: .single) - emitter.addPayload(toBuffer: generatePayloads(1).first!) + addPayload(generatePayloads(1).first!, emitter) - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 1) - } + Thread.sleep(forTimeInterval: 1) XCTAssertEqual(1, networkConnection.previousResults.count) XCTAssertEqual(1, networkConnection.previousResults.first?.count) XCTAssertTrue(networkConnection.previousResults.first!.first!.isSuccessful) - XCTAssertEqual(0, emitter.dbCount) + XCTAssertEqual(0, dbCount(emitter)) - emitter.flush() + flush(emitter) } func testEmitEventsPostAsGroup() { - let networkConnection = MockNetworkConnection(requestOption: .post, statusCode: 500) - let emitter = self.emitter(with: networkConnection, bufferOption: .defaultGroup) - let payloads = generatePayloads(15) + + let networkConnection = MockNetworkConnection(requestOption: .post, statusCode: 500) + let emitter = self.emitter(with: networkConnection, bufferOption: .smallGroup) + for i in 0..<14 { - emitter.addPayload(toBuffer: payloads[i]) + addPayload(payloads[i], emitter) } - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 1) - } + // wait longer than the stop sending timeout + Thread.sleep(forTimeInterval: 6) - XCTAssertEqual(14, emitter.dbCount) + XCTAssertEqual(14, dbCount(emitter)) networkConnection.statusCode = 200 let prevSendingCount = networkConnection.sendingCount - emitter.addPayload(toBuffer: payloads[14]) + addPayload(payloads[14], emitter) - for _ in 0..<10 { - Thread.sleep(forTimeInterval: 1) - } + Thread.sleep(forTimeInterval: 1) - XCTAssertEqual(0, emitter.dbCount) + XCTAssertEqual(0, dbCount(emitter)) var totEvents = 0 var areGrouped = false let prevResults = networkConnection.previousResults[prevSendingCount.. Emitter { - let emitter = Emitter(networkConnection: networkConnection) { emitter in + let emitter = Emitter(networkConnection: networkConnection, namespace: "ns1", eventStore: MockEventStore()) { emitter in emitter.bufferOption = bufferOption emitter.emitRange = 200 emitter.byteLimitGet = 20000 emitter.byteLimitPost = 25000 - emitter.eventStore = MockEventStore() } return emitter } @@ -388,5 +419,22 @@ class LegacyTestEmitter: XCTestCase { } return payloads } + + private func addPayload(_ eventPayload: Payload, _ emitter: Emitter) { + InternalQueue.sync { + emitter.addPayload(toBuffer: eventPayload) + } + } + + private func flush(_ emitter: Emitter) { + InternalQueue.sync { + emitter.flush() + } + } + + private func dbCount(_ emitter: Emitter) -> Int { + return InternalQueue.sync { + emitter.dbCount + } + } } -//#pragma clang diagnostic pop diff --git a/Tests/TestEvents.swift b/Tests/TestEvents.swift index c8511f2d8..6fe0e0805 100644 --- a/Tests/TestEvents.swift +++ b/Tests/TestEvents.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -25,6 +25,27 @@ class TestEvents: XCTestCase { XCTAssertEqual(event.trueTimestamp, testDate) } + func testEntities() { + let event = ScreenView(name: "screen") + let entity1 = SelfDescribingJson(schema: "schema1", andData: [String:NSObject]()) + let entity2 = SelfDescribingJson(schema: "schema2", andData: [String:NSObject]()) + let entity3 = SelfDescribingJson(schema: "schema3", andData: [String:NSObject]()) + + event.entities.append(entity1) + XCTAssertEqual(1, event.entities.count) + + _ = event.entities([entity2]) + XCTAssertEqual(2, event.entities.count) + + _ = event.contexts([entity3]) + XCTAssertEqual(3, event.entities.count) + + XCTAssertEqual(3, event.contexts.count) + XCTAssertTrue(event.entities.contains(entity1)) + XCTAssertTrue(event.entities.contains(entity2)) + XCTAssertTrue(event.entities.contains(entity3)) + } + func testApplicationInstall() { // Prepare ApplicationInstall event let installEvent = SelfDescribingJson(schema: kSPApplicationInstallSchema, andDictionary: [String:NSObject]()) @@ -44,7 +65,7 @@ class TestEvents: XCTestCase { let trackerController = Snowplow.createTracker(namespace: "namespace", network: networkConfiguration, configurations: [trackerConfiguration, emitterConfiguration]) // Track event - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -76,7 +97,7 @@ class TestEvents: XCTestCase { let trackerController = Snowplow.createTracker(namespace: "namespace", network: networkConfiguration, configurations: [trackerConfiguration, emitterConfiguration]) // Track event - _ = trackerController?.track(event) + _ = trackerController.track(event) for _ in 0..<1 { Thread.sleep(forTimeInterval: 1) } @@ -112,8 +133,8 @@ class TestEvents: XCTestCase { let trackerController = Snowplow.createTracker(namespace: "namespace", network: networkConfiguration, configurations: [trackerConfiguration, emitterConfiguration]) // Track event - _ = trackerController?.track(deepLink) - let screenViewId = trackerController?.track(screenView) + _ = trackerController.track(deepLink) + let screenViewId = trackerController.track(screenView) for _ in 0..<2 { Thread.sleep(forTimeInterval: 1) } @@ -123,7 +144,7 @@ class TestEvents: XCTestCase { var screenViewPayload: Payload? = nil for event in events { - if (event.payload.dictionary["eid"] as? String) == screenViewId?.uuidString { + if (event.payload.dictionary["eid"] as? String) == screenViewId.uuidString { screenViewPayload = event.payload } } @@ -152,7 +173,7 @@ class TestEvents: XCTestCase { XCTAssertEqual("action", event.payload["se_ac"] as? String) } - func testUnstructured() { + func testSelfDescribing() { var data: [String : Any] = [:] data["level"] = 23 data["score"] = 56473 @@ -164,6 +185,22 @@ class TestEvents: XCTestCase { XCTAssertEqual(23, event.payload["level"] as? Int) } + func testSelfDescribingWithEncodableData() { + struct Data: Encodable { + var level: Int + var score: Int + } + + let data = Data(level: 23, score: 56473) + let event = try? SelfDescribing( + schema: "iglu:com.acme_company/demo_ios_event/jsonschema/1-0-0", + data: data + ) + XCTAssertNotNil(event) + XCTAssertEqual("iglu:com.acme_company/demo_ios_event/jsonschema/1-0-0", event?.schema) + XCTAssertEqual(23, event?.payload["level"] as? Int) + } + func testConsentWithdrawn() { let event = ConsentWithdrawn() event.name = "name" diff --git a/Tests/TestLifecycleState.swift b/Tests/TestLifecycleState.swift index 024e7e3c0..2b5a10d53 100644 --- a/Tests/TestLifecycleState.swift +++ b/Tests/TestLifecycleState.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -25,17 +25,18 @@ class TestLifecycleState: XCTestCase { func testLifecycleStateMachine() { let eventStore = MockEventStore() - let emitter = Emitter(urlEndpoint: "http://snowplow-fake-url.com") { emitter in - emitter.eventStore = eventStore - } + 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.lifecycleEvents = true } // Send events - _ = tracker.track(Timing(category: "category", variable: "variable", timing: 123)) - Thread.sleep(forTimeInterval: 1) + track(Timing(category: "category", variable: "variable", timing: 123), tracker) if eventStore.lastInsertedRow == -1 { XCTFail() } @@ -45,8 +46,7 @@ class TestLifecycleState: XCTestCase { XCTAssertNotNil(entities) XCTAssertTrue(entities!.contains("\"isVisible\":true")) - _ = tracker.track(Background(index: 1)) - Thread.sleep(forTimeInterval: 1) + track(Background(index: 1), tracker) if eventStore.lastInsertedRow == -1 { XCTFail() } @@ -56,8 +56,7 @@ class TestLifecycleState: XCTestCase { XCTAssertNotNil(entities) XCTAssertTrue(entities!.contains("\"isVisible\":false")) - _ = tracker.track(Timing(category: "category", variable: "variable", timing: 123)) - Thread.sleep(forTimeInterval: 1) + track(Timing(category: "category", variable: "variable", timing: 123), tracker) if eventStore.lastInsertedRow == -1 { XCTFail() } @@ -66,8 +65,7 @@ class TestLifecycleState: XCTestCase { entities = (payload?["co"]) as? String XCTAssertTrue(entities!.contains("\"isVisible\":false")) - _ = tracker.track(Foreground(index: 1)) - Thread.sleep(forTimeInterval: 1) + track(Foreground(index: 1), tracker) if eventStore.lastInsertedRow == -1 { XCTFail() } @@ -78,8 +76,7 @@ class TestLifecycleState: XCTestCase { XCTAssertTrue(entities!.contains("\"isVisible\":true")) let uuid = UUID() - _ = tracker.track(ScreenView(name: "screen1", screenId: uuid)) - Thread.sleep(forTimeInterval: 1) + track(ScreenView(name: "screen1", screenId: uuid), tracker) if eventStore.lastInsertedRow == -1 { XCTFail() } @@ -89,4 +86,10 @@ class TestLifecycleState: XCTestCase { XCTAssertNotNil(entities) XCTAssertTrue(entities!.contains("\"isVisible\":true")) } + + private func track(_ event: Event, _ tracker: Tracker) { + InternalQueue.sync { + _ = tracker.track(event) + } + } } diff --git a/Tests/TestLinkDecorator.swift b/Tests/TestLinkDecorator.swift new file mode 100644 index 000000000..fab79aadb --- /dev/null +++ b/Tests/TestLinkDecorator.swift @@ -0,0 +1,169 @@ +// Copyright (c) 2013-present 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 TestLinkDecorator: XCTestCase { + let epoch = "\\d{13}" + + let replacements = [".", "/", "?"] + func matches(for regex: String, in text: String) { + var regex = "^\(regex)$" + + do { + for replacement in replacements { + regex = regex.replacingOccurrences(of: replacement, with: "\\" + replacement) + } + let pattern = try NSRegularExpression(pattern: regex) + let nsString = text as NSString + let results = pattern.matches(in: text, range: NSRange(location: 0, length: nsString.length)) + let fullMatch = results.map { nsString.substring(with: $0.range)} + if (fullMatch.count == 0) { + XCTFail("URL does not match pattern:\n\(text)\n\(regex)") + } + XCTAssertEqual(fullMatch.count, 1) + } catch let error { + print("invalid regex: \(error.localizedDescription)") + } + } + + func testParameterConfiguration() { + let tracker = getTracker() + let _ = tracker.track(ScreenView(name: "test")) + + let link = URL(string: "https://example.com")! + let userId = tracker.session!.userId! + let sessionId = tracker.session!.sessionId! + let subjectUserId = tracker.subject!.userId!.toBase64() + let appId = tracker.appId.toBase64() + let platform = devicePlatformToString(tracker.devicePlatform) + let reason = "reason" + let reasonb64 = reason.toBase64() + + // All false + matches( + for: "https://example.com?_sp=\(userId).\(epoch)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))!.absoluteString + ) + + // Default + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId)..\(appId)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration())!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(subjectUserId: true))!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId).\(platform)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(subjectUserId: true, sourcePlatform: true))!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).\(sessionId).\(subjectUserId).\(appId).\(platform).\(reasonb64)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(subjectUserId: true, sourcePlatform: true, reason: reason))!.absoluteString + ) + + matches( + for: "https://example.com?_sp=\(userId).\(epoch).....\(reasonb64)", + in: tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false, reason: reason))!.absoluteString + ) + } + + func testWithExistingSpQueryParameter() { + let tracker = getTracker() + let link = URL(string: "https://example.com?_sp=test")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))! + + matches(for: "https://example.com?_sp=\(tracker.session!.userId!).\(epoch)", in: result.absoluteString) + } + + func testWithOtherQueryParameters() { + let tracker = getTracker() + let link = URL(string: "https://example.com?a=a&b=b")! + let userId = tracker.session!.userId! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))! + + matches(for: "https://example.com?a=a&b=b&_sp=\(userId).\(epoch)", in: result.absoluteString) + } + + func testExistingSpQueryParameterInMiddleOfOtherQueryParameters() { + let tracker = getTracker() + let link = URL(string: "https://example.com?a=a&_sp=test&b=b")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: false, sourceId: false))! + + matches(for: "https://example.com?a=a&b=b&_sp=\(tracker.session!.userId!).\(epoch)", in: result.absoluteString) + } + + func testMissingFields() { + let tracker = getTrackerNoSubjectUserId() + let link = URL(string: "https://example.com")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: true, subjectUserId: true))! + + // Resulting _sp param will have nothing for: + // - sessionId, as an event has not been tracked + // - subjectUserId, as it has not been set + matches( + for: "https://example.com?_sp=\(tracker.session!.userId!).\(epoch)...\(tracker.appId.toBase64())", + in: result.absoluteString + ) + } + + func testMissingSessionUserId() { + let tracker = getTrackerNoSessionUserId() + let link = URL(string: "https://example.com")! + + let result = tracker.decorateLink(link, extendedParameters: CrossDeviceParameterConfiguration(sessionId: true, subjectUserId: true)) + + XCTAssertNil(result) + } + + var (emitterConfig, networkConfig, trackerConfig) = ( + EmitterConfiguration().eventStore(MockEventStore()).bufferOption(.single), + NetworkConfiguration(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 200)), + TrackerConfiguration().installAutotracking(false).screenViewAutotracking(false).lifecycleAutotracking(false).sessionContext(true) + ) + + func getTracker() -> TrackerController { + let subjectConfig = SubjectConfiguration().userId("userId") + + let namespace = "testEmitter" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, emitterConfig, subjectConfig]) + } + + private func getTrackerNoSubjectUserId() -> TrackerController { + let namespace = "testEmitter" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, emitterConfig]) + } + + private func getTrackerNoSessionUserId() -> TrackerController { + trackerConfig.sessionContext = false + + let namespace = "testEmitter" + String(describing: Int.random(in: 0..<100)) + return Snowplow.createTracker(namespace: namespace, + network: networkConfig, + configurations: [trackerConfig, emitterConfig]) + } +} diff --git a/Tests/TestLogger.swift b/Tests/TestLogger.swift index 94095ca32..df2ecd09a 100644 --- a/Tests/TestLogger.swift +++ b/Tests/TestLogger.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/TestMemoryEventStore.swift b/Tests/TestMemoryEventStore.swift index cd6868649..8fdaccd77 100644 --- a/Tests/TestMemoryEventStore.swift +++ b/Tests/TestMemoryEventStore.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -22,7 +22,7 @@ class TestMemoryEventStore: XCTestCase { func testInsertPayload() { let eventStore = MemoryEventStore() - _ = eventStore.removeAllEvents() + removeAllEvents(eventStore) // Build an event let payload = Payload() @@ -32,20 +32,20 @@ class TestMemoryEventStore: XCTestCase { payload.addValueToPayload("MEEEE", forKey: "refr") // Insert an event - eventStore.addEvent(payload) + addEvent(payload, eventStore) - XCTAssertEqual(eventStore.count(), 1) - let events = eventStore.emittableEvents(withQueryLimit: 1) + XCTAssertEqual(count(eventStore), 1) + let events = emittableEvents(withQueryLimit: 1, eventStore) XCTAssertEqual(events[0].payload.dictionary as! [String : String], payload.dictionary as! [String : String]) - _ = eventStore.removeEvent(withId: 0) + removeEvent(withId: 0, eventStore) - XCTAssertEqual(eventStore.count(), 0) + XCTAssertEqual(count(eventStore), 0) } func testInsertManyPayloads() { let eventStore = MemoryEventStore() - _ = eventStore.removeAllEvents() + removeAllEvents(eventStore) // Build an event let payload = Payload() @@ -55,14 +55,85 @@ class TestMemoryEventStore: XCTestCase { payload.addValueToPayload("MEEEE", forKey: "refr") for _ in 0..<250 { - eventStore.addEvent(payload) + addEvent(payload, eventStore) } - XCTAssertEqual(eventStore.count(), 250) - XCTAssertEqual(eventStore.emittableEvents(withQueryLimit: 600).count, 250) - XCTAssertEqual(eventStore.emittableEvents(withQueryLimit: 150).count, 150) + XCTAssertEqual(count(eventStore), 250) + XCTAssertEqual(emittableEvents(withQueryLimit: 600, eventStore).count, 250) + XCTAssertEqual(emittableEvents(withQueryLimit: 150, eventStore).count, 150) - _ = eventStore.removeAllEvents() - XCTAssertEqual(eventStore.count(), 0) + removeAllEvents(eventStore) + XCTAssertEqual(count(eventStore), 0) + } + + func testRemoveOldEventsByAge() { + let eventStore = MemoryEventStore() + + for (i, timeDiff) in [5.0, 4.0, 3.0, 2.0, 1.0].enumerated() { + let payload = Payload() + payload.addValueToPayload(getTimestamp(Date().timeIntervalSince1970 - timeDiff), forKey: "dtm") + payload.addValueToPayload(String(i + 1), forKey: "eid") + addEvent(payload, eventStore) + } + + XCTAssertEqual(count(eventStore), 5) + + removeOldEvents(maxSize: 5, maxAge: 3, eventStore) + XCTAssertEqual(count(eventStore), 2) + + let events = emittableEvents(withQueryLimit: 5, eventStore) + XCTAssertEqual( + events.map { $0.payload["eid"] as! String }.sorted(), + ["4", "5"] + ) + } + + func testRemoveOldestEventsByMaxSize() { + let eventStore = MemoryEventStore() + + for i in 0..<5 { + let payload = Payload() + payload.addValueToPayload(String(i + 1), forKey: "eid") + addEvent(payload, eventStore) + } + + XCTAssertEqual(count(eventStore), 5) + + removeOldEvents(maxSize: 2, maxAge: 5, eventStore) + XCTAssertEqual(count(eventStore), 2) + + let events = emittableEvents(withQueryLimit: 5, eventStore) + XCTAssertEqual( + events.map { $0.payload["eid"] as! String }.sorted(), + ["4", "5"] + ) + } + + private func addEvent(_ payload: Payload, _ eventStore: EventStore) { + InternalQueue.sync { eventStore.addEvent(payload) } + } + + private func removeAllEvents(_ eventStore: EventStore) { + InternalQueue.sync { _ = eventStore.removeAllEvents() } + } + + private func removeEvent(withId: Int64, _ eventStore: EventStore) { + InternalQueue.sync { _ = eventStore.removeEvent(withId: withId) } + } + + private func count(_ eventStore: EventStore) -> UInt { + InternalQueue.sync { return eventStore.count() } + } + + private func emittableEvents(withQueryLimit: UInt, _ eventStore: EventStore) -> [EmitterEvent] { + InternalQueue.sync { return eventStore.emittableEvents(withQueryLimit: withQueryLimit) } + } + + private func removeOldEvents(maxSize: Int64, maxAge: TimeInterval, _ eventStore: EventStore) { + InternalQueue.sync { eventStore.removeOldEvents(maxSize: maxSize, maxAge: maxAge) } + } + + private func getTimestamp(_ timeInterval: TimeInterval) -> String { + return String(format: "%lld", Int64(timeInterval * 1000)) } } diff --git a/Tests/TestNetworkConnection.swift b/Tests/TestNetworkConnection.swift index 2ba85ba44..c153f96c9 100644 --- a/Tests/TestNetworkConnection.swift +++ b/Tests/TestNetworkConnection.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -28,7 +28,7 @@ class TestNetworkConnection: XCTestCase { let endpoint = "https://\(TEST_URL_ENDPOINT)/i" Mock(url: URL(string: endpoint)!, ignoreQuery: true, dataType: .json, statusCode: 200, data: [.get: Data()]).register() - let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .get) + let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .get, protocolClasses: [MockingURLProtocol.self]) let payload = Payload() payload.addValueToPayload("value", forKey: "key") @@ -45,7 +45,7 @@ class TestNetworkConnection: XCTestCase { let endpoint = "https://\(TEST_URL_ENDPOINT)/i" Mock(url: URL(string: endpoint)!, ignoreQuery: true, dataType: .json, statusCode: 404, data: [.get: Data()]).register() - let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .get) + let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .get, protocolClasses: [MockingURLProtocol.self]) let payload = Payload() payload.addValueToPayload("value", forKey: "key") @@ -62,7 +62,7 @@ class TestNetworkConnection: XCTestCase { let endpoint = "https://\(TEST_URL_ENDPOINT)/com.snowplowanalytics.snowplow/tp2" Mock(url: URL(string: endpoint)!, ignoreQuery: true, dataType: .json, statusCode: 200, data: [.post: Data()]).register() - let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post) + let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post, protocolClasses: [MockingURLProtocol.self]) let payload = Payload() payload.addValueToPayload("value", forKey: "key") @@ -79,7 +79,7 @@ class TestNetworkConnection: XCTestCase { let endpoint = "https://\(TEST_URL_ENDPOINT)/com.snowplowanalytics.snowplow/tp2" Mock(url: URL(string: endpoint)!, ignoreQuery: true, dataType: .json, statusCode: 404, data: [.post: Data()]).register() - let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post) + let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post, protocolClasses: [MockingURLProtocol.self]) let payload = Payload() payload.addValueToPayload("value", forKey: "key") @@ -124,7 +124,7 @@ class TestNetworkConnection: XCTestCase { } mock.register() - let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post) + let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post, protocolClasses: [MockingURLProtocol.self]) connection.serverAnonymisation = false let payload = Payload() @@ -149,7 +149,7 @@ class TestNetworkConnection: XCTestCase { } mock.register() - let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post) + let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .post, protocolClasses: [MockingURLProtocol.self]) connection.serverAnonymisation = true let payload = Payload() @@ -174,7 +174,7 @@ class TestNetworkConnection: XCTestCase { } mock.register() - let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .get) + let connection = DefaultNetworkConnection(urlString: TEST_URL_ENDPOINT, httpMethod: .get, protocolClasses: [MockingURLProtocol.self]) connection.serverAnonymisation = true let payload = Payload() diff --git a/Tests/TestPayload.swift b/Tests/TestPayload.swift index d29ae7ea2..91573cbc9 100644 --- a/Tests/TestPayload.swift +++ b/Tests/TestPayload.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/TestPlatformContext.swift b/Tests/TestPlatformContext.swift index 22d57ec12..91f856a92 100644 --- a/Tests/TestPlatformContext.swift +++ b/Tests/TestPlatformContext.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -17,23 +17,24 @@ import XCTest class TestPlatformContext: XCTestCase { func testContainsPlatformInfo() { let context = PlatformContext(deviceInfoMonitor: MockDeviceInfoMonitor()) - let platformDict = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil).dictionary + let platformDict = context.fetchPlatformDict(userAnonymisation: false).dictionary XCTAssertNotNil(platformDict) XCTAssertNotNil(platformDict) } func testContainsMobileInfo() { let context = PlatformContext(deviceInfoMonitor: MockDeviceInfoMonitor()) - let platformDict = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil).dictionary + let platformDict = context.fetchPlatformDict(userAnonymisation: false).dictionary XCTAssertNotNil(platformDict) XCTAssertNotNil(platformDict) } func testAddsAllMockedInfo() { let deviceInfoMonitor = MockDeviceInfoMonitor() - let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) let idfa = UUID() - let platformDict = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: { idfa }) + let retriever = PlatformContextRetriever(appleIdfa: { idfa }) + let context = PlatformContext(platformContextRetriever: retriever, mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) + let platformDict = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(idfa.uuidString, platformDict[kSPMobileAppleIdfa] as? String) XCTAssertEqual("Apple Inc.", platformDict[kSPPlatformDeviceManu] as? String) XCTAssertEqual("deviceModel", platformDict[kSPPlatformDeviceModel] as? String) @@ -59,10 +60,10 @@ class TestPlatformContext: XCTestCase { let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) XCTAssertEqual(1, deviceInfoMonitor.accessCount("batteryLevel")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("appAvailableMemory")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(2, deviceInfoMonitor.accessCount("batteryLevel")) XCTAssertEqual(2, deviceInfoMonitor.accessCount("appAvailableMemory")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(3, deviceInfoMonitor.accessCount("batteryLevel")) XCTAssertEqual(3, deviceInfoMonitor.accessCount("appAvailableMemory")) } @@ -72,10 +73,10 @@ class TestPlatformContext: XCTestCase { let context = PlatformContext(mobileDictUpdateFrequency: 1000, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) XCTAssertEqual(1, deviceInfoMonitor.accessCount("batteryLevel")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("appAvailableMemory")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(1, deviceInfoMonitor.accessCount("batteryLevel")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("appAvailableMemory")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(1, deviceInfoMonitor.accessCount("batteryLevel")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("appAvailableMemory")) } @@ -85,10 +86,10 @@ class TestPlatformContext: XCTestCase { let context = PlatformContext(mobileDictUpdateFrequency: 1, networkDictUpdateFrequency: 0, deviceInfoMonitor: deviceInfoMonitor) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkTechnology")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkType")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(2, deviceInfoMonitor.accessCount("networkTechnology")) XCTAssertEqual(2, deviceInfoMonitor.accessCount("networkType")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(3, deviceInfoMonitor.accessCount("networkTechnology")) XCTAssertEqual(3, deviceInfoMonitor.accessCount("networkType")) } @@ -98,10 +99,10 @@ class TestPlatformContext: XCTestCase { let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1000, deviceInfoMonitor: deviceInfoMonitor) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkTechnology")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkType")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkTechnology")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkType")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkTechnology")) XCTAssertEqual(1, deviceInfoMonitor.accessCount("networkType")) } @@ -110,20 +111,20 @@ class TestPlatformContext: XCTestCase { let deviceInfoMonitor = MockDeviceInfoMonitor() let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 0, deviceInfoMonitor: deviceInfoMonitor) XCTAssertEqual(1, deviceInfoMonitor.accessCount("physicalMemory")) - XCTAssertEqual(1, deviceInfoMonitor.accessCount("totalStorage")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + XCTAssertEqual(1, deviceInfoMonitor.accessCount("carrierName")) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(1, deviceInfoMonitor.accessCount("physicalMemory")) - XCTAssertEqual(1, deviceInfoMonitor.accessCount("totalStorage")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + XCTAssertEqual(1, deviceInfoMonitor.accessCount("carrierName")) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(1, deviceInfoMonitor.accessCount("physicalMemory")) - XCTAssertEqual(1, deviceInfoMonitor.accessCount("totalStorage")) + XCTAssertEqual(1, deviceInfoMonitor.accessCount("carrierName")) } func testDoesntUpdateIdfvIfNotNil() { let deviceInfoMonitor = MockDeviceInfoMonitor() let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) XCTAssertEqual(1, deviceInfoMonitor.accessCount("appleIdfv")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(1, deviceInfoMonitor.accessCount("appleIdfv")) } @@ -132,53 +133,61 @@ class TestPlatformContext: XCTestCase { deviceInfoMonitor.customAppleIdfv = nil let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) XCTAssertEqual(1, deviceInfoMonitor.accessCount("appleIdfv")) - _ = context.fetchPlatformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + _ = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(2, deviceInfoMonitor.accessCount("appleIdfv")) } func testUpdatesIdfaIfNil() { let deviceInfoMonitor = MockDeviceInfoMonitor() - let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) - - let platformDict1 = context.fetchPlatformDict( - userAnonymisation: false, - advertisingIdentifierRetriever: { nil } + var idfa: UUID? = nil + let retriever = PlatformContextRetriever(appleIdfa: { idfa }) + let context = PlatformContext( + platformContextRetriever: retriever, + mobileDictUpdateFrequency: 0, + networkDictUpdateFrequency: 1, + deviceInfoMonitor: deviceInfoMonitor ) + + let platformDict1 = context.fetchPlatformDict(userAnonymisation: false) XCTAssertNil(platformDict1[kSPMobileAppleIdfa]) - let idfa = UUID() - let platformDict2 = context.fetchPlatformDict( - userAnonymisation: false, - advertisingIdentifierRetriever: { idfa } - ) - XCTAssertEqual(idfa.uuidString, platformDict2[kSPMobileAppleIdfa] as? String) + idfa = UUID() + let platformDict2 = context.fetchPlatformDict(userAnonymisation: false) + XCTAssertEqual(idfa?.uuidString, platformDict2[kSPMobileAppleIdfa] as? String) } func testDoesntUpdateIdfaIfAlreadyRetrieved() { let deviceInfoMonitor = MockDeviceInfoMonitor() - let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) let idfa1 = UUID() - let platformDict1 = context.fetchPlatformDict( - userAnonymisation: false, - advertisingIdentifierRetriever: { idfa1 } + var idfa = idfa1 + + let retriever = PlatformContextRetriever(appleIdfa: { idfa }) + let context = PlatformContext( + platformContextRetriever: retriever, + mobileDictUpdateFrequency: 0, + networkDictUpdateFrequency: 1, + deviceInfoMonitor: deviceInfoMonitor ) + + let platformDict1 = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(idfa1.uuidString, platformDict1[kSPMobileAppleIdfa] as? String) - let platformDict2 = context.fetchPlatformDict( - userAnonymisation: false, - advertisingIdentifierRetriever: { UUID() } - ) + idfa = UUID() + let platformDict2 = context.fetchPlatformDict(userAnonymisation: false) XCTAssertEqual(idfa1.uuidString, platformDict2[kSPMobileAppleIdfa] as? String) } func testAnonymisesUserIdentifiers() { let deviceInfoMonitor = MockDeviceInfoMonitor() - let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) - let platformDict = context.fetchPlatformDict( - userAnonymisation: true, - advertisingIdentifierRetriever: { UUID() } + let retriever = PlatformContextRetriever(appleIdfa: { UUID() }) + let context = PlatformContext( + platformContextRetriever: retriever, + mobileDictUpdateFrequency: 0, + networkDictUpdateFrequency: 1, + deviceInfoMonitor: deviceInfoMonitor ) + let platformDict = context.fetchPlatformDict(userAnonymisation: true) XCTAssertNil(platformDict[kSPMobileAppleIdfa]) XCTAssertNil(platformDict[kSPMobileAppleIdfv]) } @@ -187,25 +196,21 @@ class TestPlatformContext: XCTestCase { let deviceInfoMonitor = MockDeviceInfoMonitor() deviceInfoMonitor.language = "1234567890" let context = PlatformContext(mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) - let platformDict = context.fetchPlatformDict( - userAnonymisation: true, - advertisingIdentifierRetriever: { UUID() } - ) + let platformDict = context.fetchPlatformDict(userAnonymisation: true) XCTAssertEqual("12345678", platformDict[kSPMobileLanguage] as? String) } #endif func testOnlyAddsRequestedProperties() { let deviceInfoMonitor = MockDeviceInfoMonitor() + let retriever = PlatformContextRetriever(appleIdfa: { UUID() }) let context = PlatformContext( - platformContextProperties: [.appAvailableMemory, .availableStorage, .language], + platformContextProperties: [.appAvailableMemory, .language], + platformContextRetriever: retriever, mobileDictUpdateFrequency: 0, networkDictUpdateFrequency: 1, deviceInfoMonitor: deviceInfoMonitor) - let platformDict = context.fetchPlatformDict( - userAnonymisation: false, - advertisingIdentifierRetriever: { UUID() } - ) + let platformDict = context.fetchPlatformDict(userAnonymisation: false) XCTAssertNotNil(platformDict[kSPPlatformDeviceManu]) #if os(iOS) @@ -213,10 +218,64 @@ class TestPlatformContext: XCTestCase { XCTAssertNil(platformDict[kSPMobileScale]) XCTAssertNil(platformDict[kSPMobileResolution]) XCTAssertNotNil(platformDict[kSPMobileAppAvailableMemory]) - XCTAssertNotNil(platformDict[kSPMobileAvailableStorage]) XCTAssertNil(platformDict[kSPMobilePhysicalMemory]) XCTAssertNil(platformDict[kSPMobileIsPortrait]) XCTAssertNil(platformDict[kSPMobileAppleIdfa]) +#endif + } + + func testPlatformContextRetrieverOverridesProperties() { + let deviceInfoMonitor = MockDeviceInfoMonitor() + let idfa = UUID() + let retriever = PlatformContextRetriever( + osType: { "r1" }, + osVersion: { "r2" }, + deviceVendor: { "r3" }, + deviceModel: { "r4" }, + carrier: { "r5" }, + networkType: { "r6" }, + networkTechnology: { "r7" }, + appleIdfa: { idfa }, + appleIdfv: { "r9" }, + availableStorage: { 100 }, + totalStorage: { 101 }, + physicalMemory: { 102 }, + appAvailableMemory: { 103 }, + batteryLevel: { 104 }, + batteryState: { "r10" }, + lowPowerMode: { true }, + isPortrait: { false }, + resolution: { "r11" }, + scale: { 105 }, + language: { "r12" } + ) + let context = PlatformContext( + platformContextRetriever: retriever, + deviceInfoMonitor: deviceInfoMonitor) + let platformDict = context.fetchPlatformDict(userAnonymisation: false) + + XCTAssertEqual(platformDict[kSPPlatformOsType] as? String, "r1") + XCTAssertEqual(platformDict[kSPPlatformOsVersion] as? String, "r2") + XCTAssertEqual(platformDict[kSPPlatformDeviceManu] as? String, "r3") + XCTAssertEqual(platformDict[kSPPlatformDeviceModel] as? String, "r4") + +#if os(iOS) || os(visionOS) + XCTAssertEqual(platformDict[kSPMobileCarrier] as? String, "r5") + XCTAssertEqual(platformDict[kSPMobileNetworkType] as? String, "r6") + XCTAssertEqual(platformDict[kSPMobileNetworkTech] as? String, "r7") + XCTAssertEqual(platformDict[kSPMobileAppleIdfa] as? String, idfa.uuidString) + XCTAssertEqual(platformDict[kSPMobileAppleIdfv] as? String, "r9") + XCTAssertEqual(platformDict[kSPMobileAvailableStorage] as? Int64, 100) + XCTAssertEqual(platformDict[kSPMobileTotalStorage] as? Int64, 101) + XCTAssertEqual(platformDict[kSPMobilePhysicalMemory] as? UInt64, 102) + XCTAssertEqual(platformDict[kSPMobileAppAvailableMemory] as? Int, 103) + XCTAssertEqual(platformDict[kSPMobileBatteryLevel] as? Int, 104) + XCTAssertEqual(platformDict[kSPMobileBatteryState] as? String, "r10") + XCTAssertEqual(platformDict[kSPMobileLowPowerMode] as? Bool, true) + XCTAssertEqual(platformDict[kSPMobileIsPortrait] as? Bool, false) + XCTAssertEqual(platformDict[kSPMobileResolution] as? String, "r11") + XCTAssertEqual(platformDict[kSPMobileScale] as? Double, 105) + XCTAssertEqual(platformDict[kSPMobileLanguage] as? String, "r12") #endif } } diff --git a/Tests/TestPlugins.swift b/Tests/TestPlugins.swift index b69b2658b..a554ec8d2 100644 --- a/Tests/TestPlugins.swift +++ b/Tests/TestPlugins.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -25,17 +25,16 @@ class TestPlugins: XCTestCase { .entities { [SelfDescribingJson(schema: "schema", andData: ["val": $0.payload["se_ca"]!])] } let expect = expectation(description: "Has context entity on event") - let testPlugin = PluginConfiguration(identifier: "test") - .afterTrack { event in - XCTAssertTrue( - event.entities.filter({ entity in - entity.schema == "schema" && entity.data["val"] as? String == "cat" - }).count == 1 - ) - expect.fulfill() - } + let eventSink = EventSink { event in + XCTAssertTrue( + event.entities.filter({ entity in + entity.schema == "schema" && entity.data["val"] as? String == "cat" + }).count == 1 + ) + expect.fulfill() + } - let tracker = createTracker([plugin, testPlugin]) + let tracker = createTracker([plugin, eventSink]) _ = tracker.track(Structured(category: "cat", action: "act")) wait(for: [expect], timeout: 10) @@ -49,18 +48,17 @@ class TestPlugins: XCTestCase { .entities { _ in [SelfDescribingJson(schema: "schema2", andData: [:])] } let expect = expectation(description: "Has both context entities on event") - let testPlugin = PluginConfiguration(identifier: "test") - .afterTrack { event in - XCTAssertTrue( - event.entities.filter({ $0.schema == "schema1" }).count == 1 - ) - XCTAssertTrue( - event.entities.filter({ $0.schema == "schema2" }).count == 1 - ) - expect.fulfill() - } + let eventSink = EventSink { event in + XCTAssertTrue( + event.entities.filter({ $0.schema == "schema1" }).count == 1 + ) + XCTAssertTrue( + event.entities.filter({ $0.schema == "schema2" }).count == 1 + ) + expect.fulfill() + } - let tracker = createTracker([plugin1, plugin2, testPlugin]) + let tracker = createTracker([plugin1, plugin2, eventSink]) _ = tracker.track(ScreenView(name: "sv")) wait(for: [expect], timeout: 1) @@ -73,17 +71,16 @@ class TestPlugins: XCTestCase { var event1HasEntity: Bool? = nil var event2HasEntity: Bool? = nil - let testPlugin = PluginConfiguration(identifier: "test") - .afterTrack { event in - if event.schema == "schema1" { - event1HasEntity = event.entities.contains(where: { $0.schema == "xx" }) - } - if event.schema == "schema2" { - event2HasEntity = event.entities.contains(where: { $0.schema == "xx" }) - } + let eventSink = EventSink { event in + if event.schema == "schema1" { + event1HasEntity = event.entities.contains(where: { $0.schema == "xx" }) } + if event.schema == "schema2" { + event2HasEntity = event.entities.contains(where: { $0.schema == "xx" }) + } + } - let tracker = createTracker([plugin, testPlugin]) + let tracker = createTracker([plugin, eventSink]) _ = tracker.track(SelfDescribing(schema: "schema1", payload: [:])) _ = tracker.track(SelfDescribing(schema: "schema2", payload: [:])) @@ -205,7 +202,7 @@ class TestPlugins: XCTestCase { let namespace = "testPlugins" + String(describing: Int.random(in: 0..<100)) return Snowplow.createTracker(namespace: namespace, network: networkConfig, - configurations: configurations + [trackerConfig])! + configurations: configurations + [trackerConfig]) } private func waitForEventsToBeTracked() { diff --git a/Tests/TestRequest.swift b/Tests/TestRequest.swift index 0d3e9c225..23ee0eccd 100644 --- a/Tests/TestRequest.swift +++ b/Tests/TestRequest.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -124,7 +124,9 @@ class TestRequest: XCTestCase, RequestCallback { if emitter?.dbCount == 0 { break } - emitter?.flush() + InternalQueue.sync { + emitter?.flush() + } Thread.sleep(forTimeInterval: 5) } Thread.sleep(forTimeInterval: 3) @@ -153,7 +155,7 @@ class TestRequest: XCTestCase, RequestCallback { event.property = "DemoProperty" event.value = NSNumber(value: 5) event.entities = customContext() - _ = tracker_.track(event) + track(event, tracker_) return 1 } @@ -166,7 +168,7 @@ class TestRequest: XCTestCase, RequestCallback { andDictionary: data) let event = SelfDescribing(eventData: sdj) event.entities = customContext() - _ = tracker_.track(event) + track(event, tracker_) return 1 } @@ -175,14 +177,14 @@ class TestRequest: XCTestCase, RequestCallback { event.pageTitle = "DemoPageTitle" event.referrer = "DemoPageReferrer" event.entities = customContext() - _ = tracker_.track(event) + track(event, tracker_) return 1 } func trackScreenView(with tracker_: Tracker) -> Int { let event = ScreenView(name: "DemoScreenName", screenId: nil) event.entities = customContext() - _ = tracker_.track(event) + track(event, tracker_) return 1 } @@ -190,7 +192,7 @@ class TestRequest: XCTestCase, RequestCallback { let event = Timing(category: "DemoTimingCategory", variable: "DemoTimingVariable", timing: 5) event.label = "DemoTimingLabel" event.entities = customContext() - _ = tracker_.track(event) + track(event, tracker_) return 1 } @@ -212,7 +214,7 @@ class TestRequest: XCTestCase, RequestCallback { event.country = "USA" event.currency = "USD" event.entities = customContext() - _ = tracker_.track(event) + track(event, tracker_) return 2 } @@ -225,4 +227,10 @@ class TestRequest: XCTestCase, RequestCallback { andDictionary: data) return [context] } + + private func track(_ event: Event, _ tracker: Tracker) { + InternalQueue.sync { + _ = tracker.track(event) + } + } } diff --git a/Tests/TestRequestResult.swift b/Tests/TestRequestResult.swift index 0d41e29d7..184ac7904 100644 --- a/Tests/TestRequestResult.swift +++ b/Tests/TestRequestResult.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/TestSelfDescribingJson.swift b/Tests/TestSelfDescribingJson.swift index 178084861..15e4febf3 100644 --- a/Tests/TestSelfDescribingJson.swift +++ b/Tests/TestSelfDescribingJson.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -78,7 +78,40 @@ class TestSelfDescribingJson: XCTestCase { XCTAssertEqual(NSDictionary(dictionary: expected), NSDictionary(dictionary: sdj.dictionary)) } - + + func testInitWithEncodable() { + struct EncodableUserData: Encodable { + var firstName: String + var lastName: String + var nickname: String? + var age: Decimal + var children: [EncodableUserData]? + } + + let user = EncodableUserData( + firstName: "John", + lastName: "Doe", + age: 32.5, + children: [ + EncodableUserData(firstName: "Emily", lastName: "Doe", age: 1.2) + ] + ) + + let json = try? SelfDescribingJson(schema: "iglu:acme.com/user/jsonschema/1-0-0", andEncodable: user) + XCTAssertNotNil(json) + XCTAssertEqual(json?.data["firstName"] as? String, "John") + XCTAssertEqual(json?.data["lastName"] as? String, "Doe") + XCTAssertFalse(json?.data.keys.contains("nickname") ?? false) + XCTAssertNotNil(json?.data["children"]) + XCTAssertEqual((json?.data["children"] as? Array)?.count, 1) + XCTAssertEqual(json?.data["age"] as? Double, 32.5) + let children = json?.data["children"] as? Array> + XCTAssertEqual(children?.count, 1) + XCTAssertEqual(children?[0]["firstName"] as? String, "Emily") + XCTAssertEqual(children?[0]["lastName"] as? String, "Doe") + XCTAssertEqual(children?[0]["age"] as? Double, 1.2) + } + func testUpdateSchema() { let expected: [String : Any] = [ "schema": "iglu:acme.com/test_event_2/jsonschema/1-0-0", @@ -161,4 +194,3 @@ class TestSelfDescribingJson: XCTestCase { NSDictionary(dictionary: sdj.dictionary)) } } - diff --git a/Tests/TestServiceProvider.swift b/Tests/TestServiceProvider.swift index 26617a807..d80f56fd3 100644 --- a/Tests/TestServiceProvider.swift +++ b/Tests/TestServiceProvider.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -48,16 +48,20 @@ class TestServiceProvider: XCTestCase { serviceProvider.reset(configurations: [EmitterConfiguration()]) // track event and check that emitter is paused - _ = serviceProvider.trackerController.track(Structured(category: "cat", action: "act")) + InternalQueue.sync { + _ = serviceProvider.trackerController.track(Structured(category: "cat", action: "act")) + } Thread.sleep(forTimeInterval: 3) - XCTAssertEqual(1, serviceProvider.emitter.dbCount) + InternalQueue.sync { XCTAssertEqual(1, serviceProvider.emitter.dbCount) } XCTAssertEqual(0, networkConnection.sendingCount) // resume emitting - serviceProvider.emitterController.resume() + InternalQueue.sync { + serviceProvider.emitterController.resume() + } Thread.sleep(forTimeInterval: 3) XCTAssertEqual(1, networkConnection.sendingCount) - XCTAssertEqual(0, serviceProvider.emitter.dbCount) + InternalQueue.sync { XCTAssertEqual(0, serviceProvider.emitter.dbCount) } } // TODO: fix logging and handle the case //- (void)testLogsErrorWhenAccessingShutDownTracker { diff --git a/Tests/TestSession.swift b/Tests/TestSession.swift index 7a89cfcd7..e3b2afe76 100644 --- a/Tests/TestSession.swift +++ b/Tests/TestSession.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -26,17 +26,16 @@ class TestSession: XCTestCase { } func testInit() { - let session = Session(foregroundTimeout: 600, andBackgroundTimeout: 300) - XCTAssertNil(session.tracker) + let session = Session(foregroundTimeout: 600, backgroundTimeout: 300) XCTAssertTrue(!session.inBackground) XCTAssertNotNil(session.getDictWithEventId("eventid-1", eventTimestamp: 1654496481346, userAnonymisation: false)) - XCTAssertTrue(session.state!.sessionIndex >= 1) + XCTAssertTrue(session.sessionIndex ?? 0 >= 1) XCTAssertEqual(session.foregroundTimeout, 600000) XCTAssertEqual(session.backgroundTimeout, 300000) } func testInitWithOptions() { - let session = Session(foregroundTimeout: 5, andBackgroundTimeout: 300, andTracker: nil) + let session = Session(foregroundTimeout: 5, backgroundTimeout: 300) XCTAssertEqual(session.foregroundTimeout, 5000) XCTAssertEqual(session.backgroundTimeout, 300000) @@ -48,10 +47,10 @@ class TestSession: XCTestCase { } func testFirstSession() { - let session = Session(foregroundTimeout: 3, andBackgroundTimeout: 3, andTracker: nil) + let session = Session(foregroundTimeout: 3, backgroundTimeout: 3) let sessionContext = session.getDictWithEventId("event_1", eventTimestamp: 1654496481346, userAnonymisation: false) - let sessionIndex = session.state!.sessionIndex + let sessionIndex = session.sessionIndex ?? 0 XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual("event_1", sessionContext?[kSPSessionFirstEventId] as? String) @@ -59,10 +58,10 @@ class TestSession: XCTestCase { } func testForegroundEventsOnSameSession() { - let session = Session(foregroundTimeout: 3, andBackgroundTimeout: 3, andTracker: nil) + let session = Session(foregroundTimeout: 3, backgroundTimeout: 3) var sessionContext = session.getDictWithEventId("event_1", eventTimestamp: 1654496481346, userAnonymisation: false) - var sessionIndex = session.state?.sessionIndex + var sessionIndex = session.sessionIndex ?? 0 let sessionId = sessionContext?[kSPSessionId] as? String XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) @@ -72,7 +71,7 @@ class TestSession: XCTestCase { Thread.sleep(forTimeInterval: 1) sessionContext = session.getDictWithEventId("event_2", eventTimestamp: 1654496481347, userAnonymisation: false) - sessionIndex = session.state?.sessionIndex + sessionIndex = session.sessionIndex ?? 0 XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual("event_1", sessionContext?[kSPSessionFirstEventId] as? String) @@ -82,7 +81,7 @@ class TestSession: XCTestCase { Thread.sleep(forTimeInterval: 1) sessionContext = session.getDictWithEventId("event_3", eventTimestamp: 1654496481348, userAnonymisation: false) - sessionIndex = session.state?.sessionIndex + sessionIndex = session.sessionIndex ?? 0 XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual("event_1", sessionContext?[kSPSessionFirstEventId] as? String) @@ -92,7 +91,7 @@ class TestSession: XCTestCase { Thread.sleep(forTimeInterval: 3.1) sessionContext = session.getDictWithEventId("event_4", eventTimestamp: 1654496481349, userAnonymisation: false) - sessionIndex = session.state?.sessionIndex + sessionIndex = session.sessionIndex ?? 0 XCTAssertEqual(2, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual("event_4", sessionContext?[kSPSessionFirstEventId] as? String) @@ -103,7 +102,7 @@ class TestSession: XCTestCase { func testBackgroundEventsOnWhenLifecycleEventsDisabled() { cleanFile(withNamespace: "tracker") - let emitter = Emitter(urlEndpoint: "") { emitter in} + let emitter = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "tracker") let tracker = Tracker(trackerNamespace: "tracker", appId: nil, emitter: emitter) { tracker in tracker.lifecycleEvents = false tracker.sessionContext = true @@ -115,7 +114,7 @@ class TestSession: XCTestCase { session?.updateInBackground() let sessionContext = session?.getDictWithEventId("event_1", eventTimestamp: 1654496481346, userAnonymisation: false) - let sessionIndex = session?.state?.sessionIndex ?? 0 + let sessionIndex = session?.sessionIndex ?? 0 XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual("event_1", sessionContext?[kSPSessionFirstEventId] as? String) @@ -127,7 +126,7 @@ class TestSession: XCTestCase { func testBackgroundEventsOnSameSession() { cleanFile(withNamespace: "t1") - let emitter = Emitter(urlEndpoint: "") { emitter in} + let emitter = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "t1") let tracker = Tracker(trackerNamespace: "t1", appId: nil, emitter: emitter) { tracker in tracker.installEvent = false tracker.lifecycleEvents = true @@ -138,11 +137,13 @@ class TestSession: XCTestCase { let session = tracker.session session?.updateInBackground() // It sends a background event + + Thread.sleep(forTimeInterval: 1) - let sessionId = session?.state?.sessionId + let sessionId = session?.sessionId var sessionContext = session?.getDictWithEventId("event_1", eventTimestamp: 1654496481346, userAnonymisation: false) - var sessionIndex = session?.state?.sessionIndex ?? 0 + var sessionIndex = session?.sessionIndex ?? 0 XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual(sessionId, sessionContext?[kSPSessionId] as? String) @@ -152,7 +153,7 @@ class TestSession: XCTestCase { Thread.sleep(forTimeInterval: 1) sessionContext = session?.getDictWithEventId("event_2", eventTimestamp: 1654496481347, userAnonymisation: false) - sessionIndex = session?.state?.sessionIndex ?? 0 + sessionIndex = session?.sessionIndex ?? 0 XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual(sessionId, sessionContext?[kSPSessionId] as? String) @@ -162,7 +163,7 @@ class TestSession: XCTestCase { Thread.sleep(forTimeInterval: 1) sessionContext = session?.getDictWithEventId("event_3", eventTimestamp: 1654496481348, userAnonymisation: false) - sessionIndex = session?.state?.sessionIndex ?? 0 + sessionIndex = session?.sessionIndex ?? 0 XCTAssertEqual(1, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual(sessionId, sessionContext?[kSPSessionId] as? String) @@ -172,7 +173,7 @@ class TestSession: XCTestCase { Thread.sleep(forTimeInterval: 2.1) sessionContext = session?.getDictWithEventId("event_4", eventTimestamp: 1654496481349, userAnonymisation: false) - sessionIndex = session?.state?.sessionIndex ?? 0 + sessionIndex = session?.sessionIndex ?? 0 XCTAssertEqual(2, sessionIndex) XCTAssertEqual(sessionIndex, sessionContext?[kSPSessionIndex] as? Int) XCTAssertEqual("event_4", sessionContext?[kSPSessionFirstEventId] as? String) @@ -185,7 +186,7 @@ class TestSession: XCTestCase { func testMixedEventsOnManySessions() { cleanFile(withNamespace: "t2") - let emitter = Emitter(urlEndpoint: "") { emitter in} + let emitter = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "t2") let tracker = Tracker(trackerNamespace: "t2", appId: nil, emitter: emitter) { tracker in tracker.lifecycleEvents = true tracker.sessionContext = true @@ -239,7 +240,7 @@ class TestSession: XCTestCase { } func testTimeoutSessionWhenPauseAndResume() { - let session = Session(foregroundTimeout: 1, andBackgroundTimeout: 1, andTracker: nil) + let session = Session(foregroundTimeout: 1, backgroundTimeout: 1) var sessionContext = session.getDictWithEventId("event_1", eventTimestamp: 1654496481355, userAnonymisation: false) var prevSessionId = sessionContext?[kSPSessionId] as? String @@ -268,7 +269,7 @@ class TestSession: XCTestCase { func testBackgroundTimeBiggerThanBackgroundTimeoutCausesNewSession() { cleanFile(withNamespace: "tracker") - let emitter = Emitter(urlEndpoint: "") { emitter in} + let emitter = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "tracker") let tracker = Tracker(trackerNamespace: "tracker", appId: nil, emitter: emitter) { tracker in tracker.lifecycleEvents = true tracker.sessionContext = true @@ -288,9 +289,10 @@ class TestSession: XCTestCase { session?.updateInBackground() // Sends a background event Thread.sleep(forTimeInterval: 3) // Bigger than background timeout session?.updateInForeground() // Sends a foreground event + Thread.sleep(forTimeInterval: 1) - XCTAssertEqual(oldSessionId, session?.state?.previousSessionId) - XCTAssertEqual(2, session?.state?.sessionIndex) + XCTAssertEqual(oldSessionId, session?.previousSessionId) + XCTAssertEqual(2, session?.sessionIndex) XCTAssertFalse(session!.inBackground) XCTAssertEqual(1, session?.backgroundIndex) XCTAssertEqual(1, session?.foregroundIndex) @@ -299,7 +301,7 @@ class TestSession: XCTestCase { func testBackgroundTimeSmallerThanBackgroundTimeoutDoesntCauseNewSession() { cleanFile(withNamespace: "tracker") - let emitter = Emitter(urlEndpoint: "") { emitter in} + let emitter = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "tracker") let tracker = Tracker(trackerNamespace: "tracker", appId: nil, emitter: emitter) { tracker in tracker.lifecycleEvents = true tracker.sessionContext = true @@ -319,16 +321,17 @@ class TestSession: XCTestCase { session?.updateInBackground() // Sends a background event Thread.sleep(forTimeInterval: 1) // Smaller than background timeout session?.updateInForeground() // Sends a foreground event + Thread.sleep(forTimeInterval: 1) - XCTAssertEqual(oldSessionId, session?.state?.sessionId) - XCTAssertEqual(1, session?.state?.sessionIndex) + XCTAssertEqual(oldSessionId, session?.sessionId) + XCTAssertEqual(1, session?.sessionIndex) XCTAssertFalse(session!.inBackground) XCTAssertEqual(1, session?.backgroundIndex) XCTAssertEqual(1, session?.foregroundIndex) } func testNoEventsForLongTimeDontIncreaseIndexMultipleTimes() { - let session = Session(foregroundTimeout: 1, andBackgroundTimeout: 1, andTracker: nil) + let session = Session(foregroundTimeout: 1, backgroundTimeout: 1) var sessionContext = session.getDictWithEventId("event_1", eventTimestamp: 1654496481359, userAnonymisation: false) XCTAssertEqual("event_1", sessionContext?[kSPSessionFirstEventId] as? String) @@ -344,48 +347,49 @@ class TestSession: XCTestCase { cleanFile(withNamespace: "tracker1") cleanFile(withNamespace: "tracker2") - let emitter = Emitter(urlEndpoint: "") { emitter in} - let tracker1 = Tracker(trackerNamespace: "tracker1", appId: nil, emitter: emitter) { tracker in + let emitter1 = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "tracker1") + let tracker1 = Tracker(trackerNamespace: "tracker1", appId: nil, emitter: emitter1) { tracker in tracker.sessionContext = true tracker.foregroundTimeout = 10 tracker.backgroundTimeout = 10 } - let tracker2 = Tracker(trackerNamespace: "tracker2", appId: nil, emitter: emitter) { tracker in + let emitter2 = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "tracker2") + let tracker2 = Tracker(trackerNamespace: "tracker2", appId: nil, emitter: emitter2) { tracker in tracker.sessionContext = true tracker.foregroundTimeout = 10 tracker.backgroundTimeout = 10 } let event = Structured(category: "c", action: "a") - _ = tracker1.track(event) - _ = tracker2.track(event) + track(event, tracker1) + track(event, tracker2) - guard let initialValue1 = tracker1.session?.state?.sessionIndex else { return XCTFail() } - guard let id1 = tracker1.session?.state?.sessionId else { return XCTFail() } - guard let initialValue2 = tracker2.session?.state?.sessionIndex else { return XCTFail() } - guard var id2 = tracker2.session?.state?.sessionId else { return XCTFail() } + guard let initialValue1 = tracker1.session?.sessionIndex else { return XCTFail() } + guard let id1 = tracker1.session?.sessionId else { return XCTFail() } + guard let initialValue2 = tracker2.session?.sessionIndex else { return XCTFail() } + guard var id2 = tracker2.session?.sessionId else { return XCTFail() } // Retrigger session in tracker1 Thread.sleep(forTimeInterval: 7) - _ = tracker1.track(event) + track(event, tracker1) Thread.sleep(forTimeInterval: 5) // Send event to force update of session on tracker2 - _ = tracker2.track(event) - id2 = tracker2.session!.state!.sessionId + track(event, tracker2) + id2 = tracker2.session!.sessionId! // Check sessions have the correct state - XCTAssertEqual(0, tracker1.session!.state!.sessionIndex - initialValue1) // retriggered - XCTAssertEqual(1, tracker2.session!.state!.sessionIndex - initialValue2) // timed out + XCTAssertEqual(0, tracker1.session!.sessionIndex! - initialValue1) // retriggered + XCTAssertEqual(1, tracker2.session!.sessionIndex! - initialValue2) // timed out //Recreate tracker2 - let tracker2b = Tracker(trackerNamespace: "tracker2", appId: nil, emitter: emitter) { tracker in + let tracker2b = Tracker(trackerNamespace: "tracker2", appId: nil, emitter: emitter2) { tracker in tracker.sessionContext = true tracker.foregroundTimeout = 5 tracker.backgroundTimeout = 5 } - _ = tracker2b.track(event) - guard let initialValue2b = tracker2b.session?.state?.sessionIndex else { return XCTFail() } - guard let previousId2b = tracker2b.session?.state?.previousSessionId else { return XCTFail() } + track(event, tracker2b) + guard let initialValue2b = tracker2b.session?.sessionIndex else { return XCTFail() } + guard let previousId2b = tracker2b.session?.previousSessionId else { return XCTFail() } // Check the new tracker session gets the data from the old tracker2 session XCTAssertEqual(initialValue2 + 2, initialValue2b) @@ -397,22 +401,22 @@ class TestSession: XCTestCase { cleanFile(withNamespace: "tracker") storeAsV3_0(withNamespace: "tracker", eventId: "eventId", sessionId: "sessionId", sessionIndex: 123, userId: "userId") - let emitter = Emitter(urlEndpoint: "") { emitter in} + let emitter = Emitter(networkConnection: MockNetworkConnection(requestOption: .post, statusCode: 500), namespace: "tracker") let tracker = Tracker(trackerNamespace: "tracker", appId: nil, emitter: emitter) { tracker in tracker.sessionContext = true } let event = Structured(category: "c", action: "a") - _ = tracker.track(event) + track(event, tracker) - guard let sessionState = tracker.session?.state else { return XCTFail() } - XCTAssertEqual("sessionId", sessionState.previousSessionId) - XCTAssertEqual(124, sessionState.sessionIndex) - XCTAssertEqual("userId", sessionState.userId) - XCTAssertNotEqual("eventId", sessionState.firstEventId) + guard let session = tracker.session else { return XCTFail() } + XCTAssertEqual("sessionId", session.previousSessionId!) + XCTAssertEqual(124, session.sessionIndex!) + XCTAssertEqual("userId", session.userId) + XCTAssertNotEqual("eventId", session.firstEventId!) } func testIncrementsEventIndex() { - let session = Session(foregroundTimeout: 3, andBackgroundTimeout: 3, andTracker: nil) + let session = Session(foregroundTimeout: 3, backgroundTimeout: 3) var sessionContext = session.getDictWithEventId("event_1", eventTimestamp: 1654496481346, userAnonymisation: false) XCTAssertEqual(1, sessionContext?[kSPSessionEventIndex] as? Int) @@ -434,7 +438,7 @@ class TestSession: XCTestCase { } func testAnonymisesUserIdentifiers() { - let session = Session(foregroundTimeout: 3, andBackgroundTimeout: 3, andTracker: nil) + let session = Session(foregroundTimeout: 3, backgroundTimeout: 3) _ = session.getDictWithEventId("event_1", eventTimestamp: 1654496481345, userAnonymisation: false) session.startNewSession() // create previous session ID reference @@ -467,4 +471,10 @@ class TestSession: XCTestCase { let userDefaults = UserDefaults.standard userDefaults.set(userId, forKey: kSPInstallationUserId) } + + private func track(_ event: Event, _ tracker: Tracker) { + InternalQueue.sync { + _ = tracker.track(event) + } + } } diff --git a/Tests/TestStateManager.swift b/Tests/TestStateManager.swift index 83d3690c2..c359a6828 100644 --- a/Tests/TestStateManager.swift +++ b/Tests/TestStateManager.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -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"] } @@ -244,22 +252,4 @@ class TestStateManager: XCTestCase { ) ) } - - @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) - func testConcurrentRemoveStateMachineWithAddOrReplaceStateMachine() async throws { - let stateManager = StateManager() - await withTaskGroup(of: Task.self) { group in - (1...100).forEach { element in - group.addTask { - Task.detached { - if Int(element).isMultiple(of: 2) { - _ = stateManager.removeStateMachine("MockStateMachine-»\(element-1)") - } else { - stateManager.addOrReplaceStateMachine(MockStateMachine("MockStateMachine-»\(element)")) - } - } - } - } - } - } } diff --git a/Tests/TestSubject.swift b/Tests/TestSubject.swift index 36e163f14..e11d6302d 100644 --- a/Tests/TestSubject.swift +++ b/Tests/TestSubject.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -17,14 +17,14 @@ import XCTest class TestSubject: XCTestCase { func testReturnsPlatformContextIfEnabled() { let subject = Subject(platformContext: true, geoLocationContext: false) - let platformDict = subject.platformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + let platformDict = subject.platformDict(userAnonymisation: false) XCTAssertNotNil(platformDict) XCTAssertNotNil(platformDict?.dictionary[kSPPlatformOsType]) } func testDoesntReturnPlatformContextIfDisabled() { let subject = Subject(platformContext: false, geoLocationContext: false) - let platformDict = subject.platformDict(userAnonymisation: false, advertisingIdentifierRetriever: nil) + let platformDict = subject.platformDict(userAnonymisation: false) XCTAssertNil(platformDict) } diff --git a/Tests/TestUtils.swift b/Tests/TestUtils.swift index 601d75a02..cd87b6c69 100644 --- a/Tests/TestUtils.swift +++ b/Tests/TestUtils.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -34,16 +34,20 @@ class TestUtils: XCTestCase { } func testGetPlatform() { -#if os(iOS) +#if os(iOS) || os(visionOS) || os(watchOS) XCTAssertEqual(Utilities.platform, .mobile) +#elseif os(tvOS) + XCTAssertEqual(Utilities.platform, .connectedTV) #else XCTAssertEqual(Utilities.platform, .desktop) #endif } func testGetResolution() { + #if !os(visionOS) let actualResolution = Utilities.resolution XCTAssertTrue(actualResolution != nil) + #endif } func testGetEventId() { diff --git a/Tests/TestWebViewMessageHandler.swift b/Tests/TestWebViewMessageHandler.swift index a71aa39d6..a2ebe5e76 100644 --- a/Tests/TestWebViewMessageHandler.swift +++ b/Tests/TestWebViewMessageHandler.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -11,7 +11,7 @@ // express or implied. See the Apache License Version 2.0 for the specific // language governing permissions and limitations there under. -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(visionOS) import XCTest @testable import SnowplowTracker diff --git a/Tests/Legacy Tests/LegacyTestTracker.swift b/Tests/Tracker/TestTracker.swift similarity index 75% rename from Tests/Legacy Tests/LegacyTestTracker.swift rename to Tests/Tracker/TestTracker.swift index 695db9db5..9153997f2 100644 --- a/Tests/Legacy Tests/LegacyTestTracker.swift +++ b/Tests/Tracker/TestTracker.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -11,17 +11,14 @@ // express or implied. See the Apache License Version 2.0 for the specific // language governing permissions and limitations there under. -//#pragma clang diagnostic push -//#pragma clang diagnostic ignored "-Wdeprecated-declarations" - import XCTest @testable import SnowplowTracker let TEST_SERVER_TRACKER = "http://www.notarealurl.com" -class LegacyTestTracker: XCTestCase { +class TestTracker: XCTestCase { func testTrackerSetup() { - let emitter = Emitter(urlEndpoint: "not-real.com") { emitter in } + let emitter = Emitter(namespace: "aNamespace", urlEndpoint: "not-real.com") let subject = Subject(platformContext: true, geoLocationContext: true) @@ -31,9 +28,46 @@ class LegacyTestTracker: XCTestCase { tracker.sessionContext = true } } + + func testTrackerPayload() { + let subject = Subject(platformContext: true, geoLocationContext: true) + let emitter = Emitter(namespace: "aNamespace", urlEndpoint: "not-real.com") + + let tracker = Tracker(trackerNamespace: "aNamespace", appId: "anAppId", emitter: emitter) { tracker in + tracker.subject = subject + tracker.devicePlatform = .general + tracker.base64Encoded = false + tracker.sessionContext = true + tracker.foregroundTimeout = 300 + tracker.backgroundTimeout = 150 + } + + let event = Structured(category: "Category", action: "Action") + let trackerEvent = TrackerEvent(event: event, state: nil) + + var payload = tracker.payload(with: trackerEvent) + + var payloadDict = payload!.dictionary + + XCTAssertEqual(payloadDict[kSPPlatform] as? String, devicePlatformToString(.general)) + XCTAssertEqual(payloadDict[kSPAppId] as? String, "anAppId") + XCTAssertEqual(payloadDict[kSPNamespace] as? String, "aNamespace") + + // Test setting variables to new values + + tracker.devicePlatform = .desktop + tracker.appId = "newAppId" + + payload = tracker.payload(with: trackerEvent) + payloadDict = payload!.dictionary + + XCTAssertEqual(payloadDict[kSPPlatform] as? String, "pc") + XCTAssertEqual(payloadDict[kSPAppId] as? String, "newAppId") + } func testTrackerBuilderAndOptions() { - let emitter = Emitter(urlEndpoint: TEST_SERVER_TRACKER) { emitter in} + let eventSink = EventSink() + let emitter = Emitter(namespace: "aNamespace", urlEndpoint: "http://localhost") let subject = Subject(platformContext: true, geoLocationContext: true) @@ -44,11 +78,12 @@ class LegacyTestTracker: XCTestCase { tracker.foregroundTimeout = 300 tracker.backgroundTimeout = 150 } + tracker.addOrReplace(stateMachine: eventSink.toStateMachine()) // Test builder setting properly XCTAssertNotNil(tracker.emitter) - XCTAssertEqual(tracker.emitter, emitter) + XCTAssertEqual(tracker.emitter.namespace, tracker.trackerNamespace) XCTAssertNotNil(tracker.subject) XCTAssertEqual(tracker.subject, subject) XCTAssertEqual(tracker.devicePlatform, Utilities.platform) @@ -62,16 +97,23 @@ class LegacyTestTracker: XCTestCase { tracker.pauseEventTracking() XCTAssertEqual(tracker.isTracking, false) - XCTAssertNil(tracker.track(Structured(category: "c", action: "a"))) + track(Structured(category: "c", action: "a"), tracker) tracker.resumeEventTracking() XCTAssertEqual(tracker.isTracking, true) + + // check that no events were tracked + Thread.sleep(forTimeInterval: 0.5) + XCTAssertEqual(eventSink.trackedEvents.count, 0) + + // tracks event after tracking resumed + track(Structured(category: "c", action: "a"), tracker) + Thread.sleep(forTimeInterval: 0.5) + XCTAssertEqual(eventSink.trackedEvents.count, 1) // Test setting variables to new values tracker.appId = "newAppId" XCTAssertEqual(tracker.appId, "newAppId") - tracker.trackerNamespace = "newNamespace" - XCTAssertEqual(tracker.trackerNamespace, "newNamespace") tracker.base64Encoded = true XCTAssertEqual(tracker.base64Encoded, true) tracker.devicePlatform = .general @@ -82,11 +124,6 @@ class LegacyTestTracker: XCTestCase { XCTAssertNotEqual(tracker.subject, subject) XCTAssertEqual(tracker.subject, subject2) - let emitter2 = Emitter(urlEndpoint: TEST_SERVER_TRACKER) { emitter in} - tracker.emitter = emitter2 - XCTAssertNotEqual(tracker.emitter, emitter) - XCTAssertEqual(tracker.emitter, emitter2) - // Test Session Switch on/off let oldSessionManager = tracker.session @@ -97,52 +134,11 @@ class LegacyTestTracker: XCTestCase { XCTAssertNotNil(tracker.session) XCTAssertFalse(oldSessionManager === tracker.session) } - - func testTrackerPayload() { - let emitter = Emitter(urlEndpoint: TEST_SERVER_TRACKER) { emitter in} - - let subject = Subject(platformContext: true, geoLocationContext: true) - - let tracker = Tracker(trackerNamespace: "aNamespace", appId: "anAppId", emitter: emitter) { tracker in - tracker.subject = subject - tracker.devicePlatform = .general - tracker.appId = "anAppId" - tracker.base64Encoded = false - tracker.sessionContext = true - tracker.foregroundTimeout = 300 - tracker.backgroundTimeout = 150 + + private func track(_ event: Event, _ tracker: Tracker) { + InternalQueue.sync { + _ = tracker.track(event) } - - let event = Structured(category: "Category", action: "Action") - let trackerEvent = TrackerEvent(event: event, state: nil) - var payload = tracker.payload(with: trackerEvent) - var payloadDict = payload!.dictionary - - XCTAssertEqual(payloadDict[kSPPlatform] as? String, devicePlatformToString(.general)) - XCTAssertEqual(payloadDict[kSPAppId] as? String, "anAppId") - XCTAssertEqual(payloadDict[kSPNamespace] as? String, "aNamespace") - - // Test setting variables to new values - - tracker.devicePlatform = .desktop - tracker.appId = "newAppId" - tracker.trackerNamespace = "newNamespace" - - payload = tracker.payload(with: trackerEvent) - payloadDict = payload!.dictionary - - XCTAssertEqual(payloadDict[kSPPlatform] as? String, "pc") - XCTAssertEqual(payloadDict[kSPAppId] as? String, "newAppId") - XCTAssertEqual(payloadDict[kSPNamespace] as? String, "newNamespace") } - func testEventIdNotDuplicated() { - let event = Structured(category: "Category", action: "Action") - let eventId = TrackerEvent(event: event, state: nil).eventId - XCTAssertNotNil(eventId) - let newEventId = TrackerEvent(event: event, state: nil).eventId - XCTAssertNotNil(newEventId) - XCTAssertNotEqual(eventId, newEventId) - } } -//#pragma clang diagnostic pop diff --git a/Tests/Tracker/TestTrackerEvent.swift b/Tests/Tracker/TestTrackerEvent.swift new file mode 100644 index 000000000..c24bc76d4 --- /dev/null +++ b/Tests/Tracker/TestTrackerEvent.swift @@ -0,0 +1,31 @@ +// Copyright (c) 2013-present 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 TestTrackerEvent: XCTestCase { + + func testEventIdNotDuplicated() { + let event = Structured(category: "Category", action: "Action") + + let eventId = TrackerEvent(event: event, state: nil).eventId + XCTAssertNotNil(eventId) + + let newEventId = TrackerEvent(event: event, state: nil).eventId + XCTAssertNotNil(newEventId) + + XCTAssertNotEqual(eventId, newEventId) + } + +} diff --git a/Tests/Utils/DatabaseHelpers.swift b/Tests/Utils/DatabaseHelpers.swift new file mode 100644 index 000000000..bc9cb066f --- /dev/null +++ b/Tests/Utils/DatabaseHelpers.swift @@ -0,0 +1,28 @@ +// Copyright (c) 2013-present 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 os(iOS) || os(macOS) || os(visionOS) + +import Foundation +@testable import SnowplowTracker + +class DatabaseHelpers { + static func clearPreviousDatabase(_ namespace: String) { + let path = Database.dbPath(namespace: namespace) + if FileManager.default.fileExists(atPath: path) { + try? FileManager.default.removeItem(atPath: path) + } + } +} + +#endif diff --git a/Tests/Utils/EventSink.swift b/Tests/Utils/EventSink.swift new file mode 100644 index 000000000..b5195657c --- /dev/null +++ b/Tests/Utils/EventSink.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2013-present 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 +@testable import SnowplowTracker + +class EventSink: ConfigurationProtocol, PluginIdentifiable, PluginFilterCallable { + + var identifier = "EventSink" + var filterConfiguration: SnowplowTracker.PluginFilterConfiguration? + private(set) var trackedEvents: [InspectableEvent] = [] + + init(callback: ((InspectableEvent) -> Void)? = nil) { + filterConfiguration = PluginFilterConfiguration { event in + self.trackedEvents.append(event) + if let callback = callback { + callback(event) + } + return false + } + } + + func toStateMachine() -> StateMachineProtocol { + return PluginStateMachine( + identifier: identifier, + entitiesConfiguration: nil, + afterTrackConfiguration: nil, + filterConfiguration: (schemas: nil, closure: filterConfiguration!.closure) + ) + } +} diff --git a/Tests/Utils/MockDeviceInfoMonitor.swift b/Tests/Utils/MockDeviceInfoMonitor.swift index c3a56706c..fdf0177eb 100644 --- a/Tests/Utils/MockDeviceInfoMonitor.swift +++ b/Tests/Utils/MockDeviceInfoMonitor.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -23,22 +23,22 @@ class MockDeviceInfoMonitor: DeviceInfoMonitor { return customAppleIdfv } - override var deviceVendor: String? { + override var deviceVendor: String { increaseMethodAccessCount("deviceVendor") return "Apple Inc." } - override var deviceModel: String? { + override var deviceModel: String { increaseMethodAccessCount("deviceModel") return "deviceModel" } - override var osVersion: String? { + override var osVersion: String { increaseMethodAccessCount("osVersion") return "13.0.0" } - override var osType: String? { + override var osType: String { increaseMethodAccessCount("osType") return "ios" } @@ -86,16 +86,6 @@ class MockDeviceInfoMonitor: DeviceInfoMonitor { increaseMethodAccessCount("appAvailableMemory") return 1000 } - - override var availableStorage: Int64? { - increaseMethodAccessCount("availableStorage") - return 9000 - } - - override var totalStorage: Int? { - increaseMethodAccessCount("totalStorage") - return 900000 - } override var isPortrait: Bool? { return true diff --git a/Tests/Utils/MockEventStore.swift b/Tests/Utils/MockEventStore.swift index 9c5053e60..cfd20b762 100644 --- a/Tests/Utils/MockEventStore.swift +++ b/Tests/Utils/MockEventStore.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -26,16 +26,12 @@ class MockEventStore: NSObject, EventStore { } func addEvent(_ payload: Payload) { - objc_sync_enter(self) lastInsertedRow += 1 logVerbose(message: "Add \(payload)") db[Int64(lastInsertedRow)] = payload - objc_sync_exit(self) } func removeEvent(withId storeId: Int64) -> Bool { - objc_sync_enter(self) - defer { objc_sync_exit(self) } logVerbose(message: "Remove \(storeId)") return db.removeValue(forKey: storeId) != nil } @@ -49,22 +45,16 @@ class MockEventStore: NSObject, EventStore { } func removeAllEvents() -> Bool { - objc_sync_enter(self) db.removeAll() lastInsertedRow = -1 - objc_sync_exit(self) return true } func count() -> UInt { - objc_sync_enter(self) - defer { objc_sync_exit(self) } return UInt(db.count) } func emittableEvents(withQueryLimit queryLimit: UInt) -> [EmitterEvent] { - objc_sync_enter(self) - defer { objc_sync_exit(self) } var eventIds: [Int64] = [] var events: [EmitterEvent] = [] for (key, obj) in db { @@ -79,4 +69,8 @@ class MockEventStore: NSObject, EventStore { logVerbose(message: "emittableEventsWithQueryLimit: \(eventIds)") return events } + + func removeOldEvents(maxSize: Int64, maxAge: TimeInterval) { + // Not implemented in the mock event store. + } } diff --git a/Tests/Utils/MockLoggerDelegate.swift b/Tests/Utils/MockLoggerDelegate.swift index 5e7463dc1..9398c1849 100644 --- a/Tests/Utils/MockLoggerDelegate.swift +++ b/Tests/Utils/MockLoggerDelegate.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 diff --git a/Tests/Utils/MockNetworkConnection.swift b/Tests/Utils/MockNetworkConnection.swift index 220cd5608..78df9c03f 100644 --- a/Tests/Utils/MockNetworkConnection.swift +++ b/Tests/Utils/MockNetworkConnection.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -48,4 +48,9 @@ class MockNetworkConnection: NSObject, NetworkConnection { previousResults.append(requestResults) return requestResults } + + func clear() { + previousRequests = [] + previousResults = [] + } } diff --git a/Tests/Utils/MockTimer.swift b/Tests/Utils/MockTimer.swift index 47da12c45..037893b00 100644 --- a/Tests/Utils/MockTimer.swift +++ b/Tests/Utils/MockTimer.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -12,21 +12,27 @@ // language governing permissions and limitations there under. import Foundation +@testable import SnowplowTracker -class MockTimer: Timer { +class MockTimer: InternalQueueTimer { - var block: ((Timer) -> Void)! + var block: (() -> Void) + + init(block: @escaping () -> Void) { + self.block = block + } static var currentTimer: MockTimer! - override func fire() { - block(self) + func fire() { + InternalQueue.sync { + block() + } } - override open class func scheduledTimer(withTimeInterval interval: TimeInterval, - repeats: Bool, - block: @escaping (Timer) -> Void) -> Timer { - let mockTimer = MockTimer() + static func startTimer(_ interval: TimeInterval, + _ block: @escaping () -> Void) -> InternalQueueTimer { + let mockTimer = MockTimer(block: block) mockTimer.block = block MockTimer.currentTimer = mockTimer diff --git a/Tests/Utils/MockWKScriptMessage.swift b/Tests/Utils/MockWKScriptMessage.swift index 9ba06d742..cb34b160d 100644 --- a/Tests/Utils/MockWKScriptMessage.swift +++ b/Tests/Utils/MockWKScriptMessage.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -11,7 +11,7 @@ // express or implied. See the Apache License Version 2.0 for the specific // language governing permissions and limitations there under. -#if os(iOS) || os(macOS) +#if os(iOS) || os(macOS) || os(visionOS) import WebKit class MockWKScriptMessage: WKScriptMessage { diff --git a/Tests/Utils/TimeTraveler.swift b/Tests/Utils/TimeTraveler.swift index 30fcac2bf..1589007b0 100644 --- a/Tests/Utils/TimeTraveler.swift +++ b/Tests/Utils/TimeTraveler.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2013-2023 Snowplow Analytics Ltd. All rights reserved. +// Copyright (c) 2013-present 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 @@ -23,4 +23,8 @@ class TimeTraveler { func generateDate() -> Date { return date } + + func generateTimeInterval() -> TimeInterval { + return date.timeIntervalSince1970 + } } diff --git a/Tests/VisionOS/TestImmersiveSpaceState.swift b/Tests/VisionOS/TestImmersiveSpaceState.swift new file mode 100644 index 000000000..2b14b3122 --- /dev/null +++ b/Tests/VisionOS/TestImmersiveSpaceState.swift @@ -0,0 +1,221 @@ +// Copyright (c) 2013-present 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..4c01b67ea --- /dev/null +++ b/Tests/VisionOS/TestVisionOSEntities.swift @@ -0,0 +1,51 @@ +// Copyright (c) 2013-present 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..28032a92c --- /dev/null +++ b/Tests/VisionOS/TestVisionOSEvents.swift @@ -0,0 +1,155 @@ +// Copyright (c) 2013-present 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 + } +} diff --git a/VERSION b/VERSION index 1bc788d3b..09b254e90 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.6.0 +6.0.0