diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..daa42bf --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,163 @@ +# For a detailed guide to building and testing on iOS, read the docs: +# https://circleci.com/docs/2.0/testing-ios/ + +version: 2.1 + +orbs: + codecov: codecov/codecov@3.2.4 + macos: circleci/macos@2 + +# Workflows orchestrate a set of jobs to be run; +workflows: + version: 2 + build-test: + jobs: + - validate-code + - test-ios: + requires: + - validate-code + - test-tvos: + requires: + - validate-code + - build_xcframework_and_app: + requires: + - validate-code + +commands: + install_dependencies: + steps: + # restore pods related caches + - restore_cache: + name: Restoring Gemfile Cache + keys: + - 1-gems-{{ checksum "Gemfile.lock" }} + + # make sure we're on the right version of cocoapods + - run: + name: Verify Cocoapods Version + command: bundle check || bundle install --path vendor/bundle + + # save cocoapods version gem data + - save_cache: + name: Saving Gemfile Cache + key: 1-gems-{{ checksum "Gemfile.lock" }} + paths: + - vendor/bundle + + # restore pods related caches + - restore_cache: + name: Restoring CocoaPods Cache + keys: + - cocoapods-cache-v1-{{ arch }}-{{ .Branch }}-{{ checksum "Podfile.lock" }} + - cocoapods-cache-v1-{{ arch }}-{{ .Branch }} + - cocoapods-cache-v1 + + # install CocoaPods - using default CocoaPods version, not the bundle + - run: + name: Repo Update & Install CocoaPods + command: make ci-pod-install + + # save pods related files + - save_cache: + name: Saving CocoaPods Cache + key: cocoapods-cache-v1-{{ arch }}-{{ .Branch }}-{{ checksum "Podfile.lock" }} + paths: + - ./Pods + + prestart_ios_simulator: + steps: + - macos/preboot-simulator: + platform: "iOS" + device: "iPhone 8" + + prestart_tvos_simulator: + steps: + - macos/preboot-simulator: + platform: "tvOS" + device: "Apple TV" + +jobs: + + working_directory: ~/project + + validate-code: + macos: + xcode: 13.4.0 # Specify the Xcode version to use + + steps: + - checkout + + - install_dependencies + + - run: + name: Lint Source Code + command: make lint + + test-ios: + macos: + xcode: 13.4.0 # Specify the Xcode version to use + + steps: + - checkout + + - install_dependencies + + # pre-start the simulator to prevent timeouts + - prestart_ios_simulator + + - run: + name: Run iOS Tests + command: make test-ios + + # Code coverage upload using Codecov + # See options explanation here: https://docs.codecov.com/docs/codecov-uploader + - codecov/upload: + flags: ios-tests + upload_name: Coverage Report for iOS Tests + xtra_args: -c -v --xc --xp iosresults.xcresult + + + test-tvos: + macos: + xcode: 13.4.0 # Specify the Xcode version to use + + steps: + - checkout + + - install_dependencies + + # pre-start the simulator to prevent timeouts + - prestart_tvos_simulator + + - run: + name: Run tvOS Tests + command: make test-tvos + + # Code coverage upload using Codecov + # See options explanation here: https://docs.codecov.com/docs/codecov-uploader + - codecov/upload: + flags: tvos-tests + upload_name: Coverage Report for tvOS Tests + xtra_args: -c -v --xc --xp tvosresults.xcresult + + build_xcframework_and_app: + macos: + xcode: 13.4.0 # Specify the Xcode version to use + + steps: + - checkout + # verify XCFramework archive builds + - run: + name: Build XCFramework + command: | + if [ "${CIRCLE_BRANCH}" == "main" ]; then + make archive + fi + + # verify test app builds + - run: + name: Build Test App + command: | + if [ "${CIRCLE_BRANCH}" == "main" ]; then + make build-app + fi \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..c80d140 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,31 @@ +#!/bin/bash +SWIFTLINT=./Pods/SwiftLint/swiftlint +CONFIG=.swiftlint.yml + +if ! command -v "${SWIFTLINT}" &> /dev/null; then + echo "${SWIFTLINT} is not installed. Please run 'pod install'." + exit 0 +fi + +echo "SwiftLint $(${SWIFTLINT} version)" + +count=0 + +# Changed files added to stage +for file_path in $(git diff --cached --name-only --diff-filter=d | grep ".swift$"); do + export SCRIPT_INPUT_FILE_$count=$file_path + count=$((count + 1)) +done + +if [ "$count" -ne 0 ]; then + export SCRIPT_INPUT_FILE_COUNT=$count + $SWIFTLINT --fix --config $CONFIG --use-script-input-files --force-exclude --format +else + echo "No files to lint!" + exit 0 +fi + +# Re-add changes to files in stage area +for file_path in $(git diff --cached --name-only --diff-filter=d | grep ".swift$"); do + git add "$file_path" +done diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100755 index 0000000..dd00b8f --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing + +Thanks for choosing to contribute! + +The following are a set of guidelines to follow when contributing to this project. + +## Code Of Conduct + +This project adheres to the Adobe [code of conduct](../CODE_OF_CONDUCT.md). By participating, +you are expected to uphold this code. Please report unacceptable behavior to +[Grp-opensourceoffice@adobe.com](mailto:Grp-opensourceoffice@adobe.com). + +## Have A Question? + +Start by filing an issue. The existing committers on this project work to reach +consensus around project direction and issue solutions within issue threads +(when appropriate). + +## Contributor License Agreement + +All third-party contributions to this project must be accompanied by a signed contributor +license agreement. This gives Adobe permission to redistribute your contributions +as part of the project. [Sign our CLA](https://opensource.adobe.com/cla.html). You +only need to submit an Adobe CLA one time, so if you have submitted one previously, +you are good to go! + +## Code Reviews + +All submissions should come in the form of pull requests and need to be reviewed +by project committers. Read [GitHub's pull request documentation](https://help.github.com/articles/about-pull-requests/) +for more information on sending pull requests. + +Lastly, please follow the [pull request template](PULL_REQUEST_TEMPLATE.md) when +submitting a pull request! + +## From Contributor To Committer + +We love contributions from our community! If you'd like to go a step beyond contributor +and become a committer with full write access and a say in the project, you must +be invited to the project. The existing committers employ an internal nomination +process that must reach lazy consensus (silence is approval) before invitations +are issued. If you feel you are qualified and want to get more deeply involved, +feel free to reach out to existing committers to have a conversation about that. + +## Security Issues + +Security issues shouldn't be reported on this issue tracker. Instead, [file an issue to our security experts](https://helpx.adobe.com/security/alertus.html). diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100755 index 0000000..ffd2022 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,8 @@ +--- +name: Blank issue +labels: task +--- +## Prerequisites + +- [ ] I have searched in this repository's issues to see if it has already been reported. +- [ ] This is not a Security Disclosure, otherwise please follow the guidelines in [Security Policy](https://github.com/adobe/aepsdk-edgemedia-ios/security/policy). diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100755 index 0000000..27edf4a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,70 @@ +name: Bug report +description: Create a bug report to help us improve. Use this template if you encountered an issue while integrating with or implementing the APIs of this SDK. +labels: [bug, triage-required] + +body: +- type: checkboxes + attributes: + label: Prerequisites + description: Please check the following items before logging a new bug report. + options: + - label: This is not a Security Disclosure, otherwise please follow the guidelines in [Security Policy](https://github.com/adobe/aepsdk-edgemedia-ios/security/policy). + required: true + - label: I have searched in this repository's issues to see if it has already been reported. + required: true + - label: I have updated to the latest released version of the SDK and the issue still persists. + required: true + +- type: textarea + attributes: + label: Bug summary + description: Please provide a summary of the bug you are reporting. + validations: + required: true + +- type: textarea + attributes: + label: Environment + description: | + Please provide the OS version, SDK version(s) used, IDE version, and any other specific settings that could help us narrow down the problem. + Example: + - **OS**: iOS 15.5 + - **SDK(s)**: AEPEdgeMedia 1.0.0, AEPEdge 1.4.0, AEPCore 1.7.0 + - **IDE**: Xcode 13.4 + validations: + required: true + +- type: textarea + attributes: + label: Steps to reproduce + description: Steps to reproduce the behavior consistently. + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: false + +- type: textarea + attributes: + label: Current behavior + description: A concise description of what you are experiencing. + validations: + required: false + +- type: textarea + attributes: + label: Expected behavior + description: A concise description of what you expected to happen. + validations: + required: false + +- type: textarea + attributes: + label: Anything else? + description: | + Here you can include sample code that illustrates the problem, logs taken while reproducing the problem, or anything that can give us more context about the issue you are encountering. + + Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100755 index 0000000..2aba654 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: Feature request +description: Suggest an idea for this project. +labels: [feature-request, triage-required] + +body: +- type: checkboxes + attributes: + label: Prerequisites + description: Please check the following items before logging a new feature request. + options: + - label: This is not a Security Disclosure, otherwise please follow the guidelines in [Security Policy](https://github.com/adobe/aepsdk-edgemedia-ios/security/policy). + required: true + - label: I have searched in this repository's issues to see if it has already been reported. + required: true + +- type: textarea + id: description + attributes: + label: Feature request summary + description: Please provide a summary of the feature. + validations: + required: true + +- type: textarea + attributes: + label: Current behavior + description: A concise description of what you are experiencing. + validations: + required: false + +- type: textarea + attributes: + label: Expected behavior + description: A concise description of what you expected to happen. + validations: + required: false + +- type: textarea + attributes: + label: Additional implementation details or code snippets + description: Provide additional information about this request, implementation details or code snippets. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/project_epic.yml b/.github/ISSUE_TEMPLATE/project_epic.yml new file mode 100644 index 0000000..eec745a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/project_epic.yml @@ -0,0 +1,24 @@ +name: Project epic +description: Create an internal epic that represents the top level parent of multiple tasks. +labels: [epic] + +body: +- type: textarea + id: description + attributes: + label: Epic description + description: Please provide a detailed description for this epic. + validations: + required: true + +- type: textarea + id: tasks + attributes: + label: Tasks + description: | + Provide a high-level definition of done for this epic as a list of tasks that need to be completed. + Tip: List out the task links if they already exist or list them out as text with a descriptive title so they can be easily converted to task items. + placeholder: | + - [ ] your task link here + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/project_task.yml b/.github/ISSUE_TEMPLATE/project_task.yml new file mode 100644 index 0000000..f4334a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/project_task.yml @@ -0,0 +1,17 @@ +name: Project task +description: Create an internal task that can be completed as a standalone code change or is part of an epic. +labels: [task] +body: +- type: textarea + attributes: + label: Task description + description: Please provide a summary or the "what" of the task logged. + validations: + required: true + +- type: textarea + attributes: + label: Additional implementation details or code snippet(s) + description: Provide additional information about this task, implementation details or code snippets. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100755 index 0000000..9efe0d8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,45 @@ + + +## Description + + + +## Related Issue + + + + + + +## Motivation and Context + + + +## How Has This Been Tested? + + + + + +## Screenshots (if appropriate): + +## Types of changes + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) + +## Checklist: + + + + +- [ ] I have signed the [Adobe Open Source CLA](https://opensource.adobe.com/cla.html). +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I have updated the documentation accordingly. +- [ ] I have read the **CONTRIBUTING** document. +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..27bcee3 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,4 @@ +template: | + ## What’s Changed + + $CHANGES diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..602aef3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + workflow_dispatch: + inputs: + tag: + description: 'tag/version' + required: true + default: '1.0.0' + + action_tag: + description: 'create tag ("no" to skip)' + required: true + default: 'yes' + + release_AEPEdgeMedia: + description: 'release AEPEdgeMedia to Cocoapods ("no" to skip)' + required: true + default: 'yes' + +jobs: + release_edgemedia: + runs-on: macos-latest + steps: + - uses: actions/checkout@v2 + with: + ref: main + + - name: Install jq + run: brew install jq + + - name: Install cocoapods + run: gem install cocoapods + + - name: Check version in Podspec + run: | + set -eo pipefail + echo Target version: ${{ github.event.inputs.tag }} + make check-version VERSION=${{ github.event.inputs.tag }} + + - name: SPM integration test + if: ${{ github.event.inputs.action_tag == 'yes' }} + run: | + set -eo pipefail + echo SPM integration test starts: + make test-SPM-integration + + - name: podspec file verification + if: ${{ github.event.inputs.action_tag == 'yes' }} + run: | + set -eo pipefail + echo podspec file verification starts: + make test-podspec + + - uses: release-drafter/release-drafter@v5 + if: ${{ github.event.inputs.action_tag == 'yes' }} + with: + name: v${{ github.event.inputs.tag }} + tag: ${{ github.event.inputs.tag }} + version: ${{ github.event.inputs.tag }} + publish: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish Pods - AEPEdgeMedia + if: ${{ github.event.inputs.release_AEPEdgeMedia == 'yes' }} + run: | + set -eo pipefail + pod trunk push AEPEdgeMedia.podspec --allow-warnings --synchronous --swift-version=5.1 + pod repo update + env: + COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08e6d06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,95 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +#*.xcworkspace +*.xcresult + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +.DS_Store + +*.log + +build/ diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..85cbbd5 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,61 @@ +disabled_rules: # rule identifiers to exclude from running +- nesting +opt_in_rules: # some rules are opt-in only +- closure_end_indentation +- convenience_type +- empty_collection_literal +- empty_count +- empty_string +- force_unwrapping +- missing_docs +- multiline_arguments +- multiline_function_chains +- multiline_parameters +- operator_usage_whitespace +- sorted_imports +- toggle_bool +- unneeded_parentheses_in_closure_argument +- unused_import +- vertical_parameter_alignment_on_call +excluded: # paths to ignore during linting +- Carthage +- TestApps/*/Pods +- Pods +- build +- .build +empty_count: + severity: warning +force_cast: warning +force_try: warning +identifier_name: + allowed_symbols: "_" + excluded: + - ts + - ID + - id + - no + - ok +line_length: + warning: 200 + error: 220 + ignores_function_declarations: true + ignores_comments: true + ignores_urls: true +function_body_length: + warning: 50 + error: 150 +function_parameter_count: + warning: 6 + error: 8 +type_body_length: + warning: 300 + error: 800 +file_length: + warning: 1000 + error: 1500 + ignore_comment_only_lines: true +cyclomatic_complexity: + ignores_case_statements: true + warning: 15 + error: 30 +reporter: "xcode" diff --git a/AEPEdgeMedia.podspec b/AEPEdgeMedia.podspec new file mode 100644 index 0000000..92b9ebc --- /dev/null +++ b/AEPEdgeMedia.podspec @@ -0,0 +1,24 @@ +Pod::Spec.new do |s| + s.name = "AEPEdgeMedia" + s.version = "1.0.0-beta" + s.summary = "Experience Platform Edge Media extension for Adobe Experience Platform Mobile SDK. Written and maintained by Adobe." + + s.description = <<-DESC + The Experience Platform Edge Media extension enables handling Media Analytics using Adobe Edge Network. + DESC + + s.homepage = "https://github.com/adobe/aepsdk-edgemedia-ios.git" + s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } + s.author = "Adobe Experience Platform SDK Team" + s.source = { :git => "https://github.com/adobe/aepsdk-edgemedia-ios.git", :tag => s.version.to_s } + + s.ios.deployment_target = '10.0' + s.tvos.deployment_target = '10.0' + + s.swift_version = '5.1' + + s.pod_target_xcconfig = { 'BUILD_LIBRARY_FOR_DISTRIBUTION' => 'YES' } + s.dependency 'AEPCore', '>= 3.7.0' + s.dependency 'AEPEdge', '>= 1.6.0' + s.source_files = 'Sources/**/*.swift' +end diff --git a/AEPEdgeMedia.xcodeproj/project.pbxproj b/AEPEdgeMedia.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d061bb7 --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/project.pbxproj @@ -0,0 +1,1911 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 2E0A1C3C2997107F0099C134 /* MediaContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E0A1C3B2997107F0099C134 /* MediaContextTests.swift */; }; + 2E19666328B8157D00298FD4 /* AEPEdgeMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */; }; + 2E19666428B8158300298FD4 /* AEPEdgeMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */; }; + 2E19666628B8220900298FD4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19666528B8220900298FD4 /* AppDelegate.swift */; }; + 2E19666728B8220900298FD4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19666528B8220900298FD4 /* AppDelegate.swift */; }; + 2E19666928B8236100298FD4 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19666828B8236100298FD4 /* SceneDelegate.swift */; }; + 2E19666A28B8236100298FD4 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19666828B8236100298FD4 /* SceneDelegate.swift */; }; + 2E19666C28B824C300298FD4 /* AssuranceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19666B28B824C300298FD4 /* AssuranceView.swift */; }; + 2E19666D28B824C300298FD4 /* AssuranceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E19666B28B824C300298FD4 /* AssuranceView.swift */; }; + 2E224FCC2971EC29005FB095 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2228C02F79001A7C2A /* TestUtils.swift */; }; + 2E37D33D28CFDD7800B782F8 /* MediaEventProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D33C28CFDD7800B782F8 /* MediaEventProcessor.swift */; }; + 2E37D33F28CFDDF900B782F8 /* MediaSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D33E28CFDDF900B782F8 /* MediaSession.swift */; }; + 2E37D34128CFE44400B782F8 /* MediaRealTimeSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D34028CFE44400B782F8 /* MediaRealTimeSession.swift */; }; + 2E37D34328D123BE00B782F8 /* MediaXDMEventGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D34228D123BE00B782F8 /* MediaXDMEventGenerator.swift */; }; + 2E37D34528D12A3500B782F8 /* MediaXDMEventHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D34428D12A3500B782F8 /* MediaXDMEventHelper.swift */; }; + 2E37D38528D290CE00B782F8 /* MediaEventProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D38428D290CE00B782F8 /* MediaEventProcessing.swift */; }; + 2E37D38828D416C100B782F8 /* MediaEventProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D38628D4169100B782F8 /* MediaEventProcessorTests.swift */; }; + 2E37D38B28D4186000B782F8 /* MediaSessionSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D38928D4185B00B782F8 /* MediaSessionSpy.swift */; }; + 2E37D38D28D54B2900B782F8 /* XDMMediaEventType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E37D38C28D54B2900B782F8 /* XDMMediaEventType.swift */; }; + 2E3BF82B28D935370043DD00 /* MediaXDMEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E3BF82928D9352F0043DD00 /* MediaXDMEvent.swift */; }; + 2E3BF83028D961440043DD00 /* MediaXDMEventGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E3BF82F28D961440043DD00 /* MediaXDMEventGeneratorTests.swift */; }; + 2E3BF83528DBB59C0043DD00 /* XDMCustomMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E3BF83428DBB59C0043DD00 /* XDMCustomMetadata.swift */; }; + 2E459864290A2EEC003111EE /* MediaXDMEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E459863290A2EEC003111EE /* MediaXDMEventTests.swift */; }; + 2E459866290B7144003111EE /* MediaRealTimeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E459865290B7144003111EE /* MediaRealTimeSessionTests.swift */; }; + 2E459868290B8585003111EE /* XDMDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E459867290B8585003111EE /* XDMDataHelper.swift */; }; + 2E4B4A9A29838CB900638DE7 /* CustomPingDuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4B4A9929838CB900638DE7 /* CustomPingDuration.swift */; }; + 2E4D75042979E02E00396819 /* XDMDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E459867290B8585003111EE /* XDMDataHelper.swift */; }; + 2E4D75062979E02E00396819 /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2228C02F79001A7C2A /* TestUtils.swift */; }; + 2E4D75072979E02E00396819 /* XDMData+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA1FB46291DAC9000C4FFFE /* XDMData+Equatable.swift */; }; + 2E4D75092979E02E00396819 /* InstrumentedExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FBA29710881005FB095 /* InstrumentedExtension.swift */; }; + 2E4D750A2979E02E00396819 /* FunctionalTestNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FBB29710881005FB095 /* FunctionalTestNetworkService.swift */; }; + 2E4D750E2979E02E00396819 /* FunctionalTestConstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FC42971EA50005FB095 /* FunctionalTestConstant.swift */; }; + 2E4D75102979E02E00396819 /* UserDefaults+Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FC92971EC11005FB095 /* UserDefaults+Test.swift */; }; + 2E4D75122979E02E00396819 /* FileManager+Testable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FC62971EB46005FB095 /* FileManager+Testable.swift */; }; + 2E4D75132979E02E00396819 /* FunctionalTestBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FBD29710881005FB095 /* FunctionalTestBase.swift */; }; + 2E4D75142979E02E00396819 /* CountDownLatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FBC29710881005FB095 /* CountDownLatch.swift */; }; + 2E4D75162979E02E00396819 /* XDMData+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EEA08E6291DD51100043C43 /* XDMData+Comparable.swift */; }; + 2E4D75172979E02E00396819 /* EdgeEventHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4D81702936942F005A4543 /* EdgeEventHelper.swift */; }; + 2E4D75192979E02E00396819 /* EventHub+Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FC82971EC11005FB095 /* EventHub+Test.swift */; }; + 2E4D751B2979E02E00396819 /* Media+Edge+EdgeIdentityFunctionalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E224FC2297108A9005FB095 /* Media+Edge+EdgeIdentityFunctionalTests.swift */; }; + 2E4D751D2979E02E00396819 /* AEPEdgeMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */; }; + 2E4D75262979E34100396819 /* AdPlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EAD52EF296F90C60099D82B /* AdPlayback.swift */; }; + 2E4D75272979E34600396819 /* BaseScenarioTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4D816A29357B82005A4543 /* BaseScenarioTest.swift */; }; + 2E4D75282979E34E00396819 /* CustomStatePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EAD52EA296F8FDE0099D82B /* CustomStatePlayback.swift */; }; + 2E4D75292979E34E00396819 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EAD52E8296F8FC90099D82B /* CustomError.swift */; }; + 2E4D752A2979E34E00396819 /* ChapterPlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EAD52F0296F90C60099D82B /* ChapterPlayback.swift */; }; + 2E4D752B2979E34E00396819 /* SimplePlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4D816929357B82005A4543 /* SimplePlayback.swift */; }; + 2E4D752C2979E34E00396819 /* Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EAD52EC296F8FFA0099D82B /* Timeout.swift */; }; + 2E4D752D2979E34E00396819 /* SpecialAdPlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EAD52EE296F90C60099D82B /* SpecialAdPlayback.swift */; }; + 2E4D816D29357BDE005A4543 /* MediaEventProcessorSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E3BF83128D9628B0043DD00 /* MediaEventProcessorSpy.swift */; }; + 2E4D816E29357BEC005A4543 /* MediaEventGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB164F2289AF59C00089C83 /* MediaEventGenerator.swift */; }; + 2E4D816F2935CA5B005A4543 /* XDMDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E459867290B8585003111EE /* XDMDataHelper.swift */; }; + 2E4D81712936942F005A4543 /* EdgeEventHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4D81702936942F005A4543 /* EdgeEventHelper.swift */; }; + 2E4D8173293954F6005A4543 /* FakeMediaEventProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4D8172293954F6005A4543 /* FakeMediaEventProcessor.swift */; }; + 2E58A93928BEB403004A9FA5 /* XDMErrorDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A92928BEB403004A9FA5 /* XDMErrorDetails.swift */; }; + 2E58A93B28BEB403004A9FA5 /* XDMMediaCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A92B28BEB403004A9FA5 /* XDMMediaCollection.swift */; }; + 2E58A93D28BEB403004A9FA5 /* XDMQoeDataDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A92D28BEB403004A9FA5 /* XDMQoeDataDetails.swift */; }; + 2E58A93E28BEB403004A9FA5 /* XDMAdvertisingPodDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A92E28BEB403004A9FA5 /* XDMAdvertisingPodDetails.swift */; }; + 2E58A93F28BEB403004A9FA5 /* XDMSessionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A92F28BEB403004A9FA5 /* XDMSessionDetails.swift */; }; + 2E58A94028BEB403004A9FA5 /* XDMChapterDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A93028BEB403004A9FA5 /* XDMChapterDetails.swift */; }; + 2E58A94128BEB403004A9FA5 /* XDMPlayerStateData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A93128BEB403004A9FA5 /* XDMPlayerStateData.swift */; }; + 2E58A94328BEB403004A9FA5 /* XDMStreamType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A93328BEB403004A9FA5 /* XDMStreamType.swift */; }; + 2E58A94728BEB403004A9FA5 /* XDMAdvertisingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A93728BEB403004A9FA5 /* XDMAdvertisingDetails.swift */; }; + 2E58A94928BEB523004A9FA5 /* XDMAdvertisingDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E58A94828BEB523004A9FA5 /* XDMAdvertisingDetailsTests.swift */; }; + 2EA1FB47291DAC9000C4FFFE /* XDMData+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA1FB46291DAC9000C4FFFE /* XDMData+Equatable.swift */; }; + 2EA1FB48291DAC9000C4FFFE /* XDMData+Equatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA1FB46291DAC9000C4FFFE /* XDMData+Equatable.swift */; }; + 2EA7BC2328C02F79001A7C2A /* TestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2228C02F79001A7C2A /* TestUtils.swift */; }; + 2EA7BC2528C035FF001A7C2A /* XDMAdvertisingPodDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2428C035FF001A7C2A /* XDMAdvertisingPodDetailsTests.swift */; }; + 2EA7BC2728C04675001A7C2A /* XDMErrorDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2628C04675001A7C2A /* XDMErrorDetailsTests.swift */; }; + 2EA7BC2928C04796001A7C2A /* XDMChapterDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2828C04796001A7C2A /* XDMChapterDetailsTests.swift */; }; + 2EA7BC2D28C11CD4001A7C2A /* XDMSessionDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2C28C11CD4001A7C2A /* XDMSessionDetailsTests.swift */; }; + 2EA7BC2F28C13924001A7C2A /* XDMMediaCollectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EA7BC2E28C13924001A7C2A /* XDMMediaCollectionTests.swift */; }; + 2EAD52F5296F90C60099D82B /* AdChapterPlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EAD52F1296F90C60099D82B /* AdChapterPlayback.swift */; }; + 2EB040812888B0D200306323 /* AEPEdgeMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */; }; + 2EB0409A2888B14D00306323 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 2EB040982888B14C00306323 /* README.md */; }; + 2EB0409C2888B35D00306323 /* AEPEdgeMedia.h in Headers */ = {isa = PBXBuildFile; fileRef = 2EB0409B2888B35D00306323 /* AEPEdgeMedia.h */; }; + 2EB040A62888B46400306323 /* AEPEdgeMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */; }; + 2EB040E92894A50900306323 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2EB040E22894A4E300306323 /* Assets.xcassets */; }; + 2EB040EA2894A50A00306323 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2EB040E22894A4E300306323 /* Assets.xcassets */; }; + 2EB040EB2894A50D00306323 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB040E32894A4E300306323 /* ContentView.swift */; }; + 2EB040EC2894A50E00306323 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB040E32894A4E300306323 /* ContentView.swift */; }; + 2EB040F72894AE0600306323 /* Event+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB040EE2894AE0500306323 /* Event+Media.swift */; }; + 2EB040FB2894AE0600306323 /* Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB040F22894AE0600306323 /* Media.swift */; }; + 2EB040FC2894AE0600306323 /* Double+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB040F32894AE0600306323 /* Double+Media.swift */; }; + 2EB040FE2894AE0600306323 /* MediaConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB040F52894AE0600306323 /* MediaConstants.swift */; }; + 2EB164EE289AF3B800089C83 /* MediaEventTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB164ED289AF3B800089C83 /* MediaEventTracking.swift */; }; + 2EB164EF289AF55800089C83 /* MediaTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD062899E3B400D22B25 /* MediaTracker.swift */; }; + 2EB164F0289AF56400089C83 /* MediaPublicTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD042899E36C00D22B25 /* MediaPublicTracker.swift */; }; + 2EB164F3289AF59C00089C83 /* MediaEventGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB164F2289AF59C00089C83 /* MediaEventGenerator.swift */; }; + 2EB164F5289AF63900089C83 /* MockExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB164F4289AF63900089C83 /* MockExtension.swift */; }; + 2EB164F7289AF6B900089C83 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB164F6289AF6B900089C83 /* TestHelpers.swift */; }; + 2EC3C49329B98C6900B4308B /* MediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3C49229B98C6900B4308B /* MediaTests.swift */; }; + 2EC3C49529B98CFF00B4308B /* TestableExtensionRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3C49429B98CFF00B4308B /* TestableExtensionRuntime.swift */; }; + 2EC3C49729B98D2800B4308B /* MockDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC3C49629B98D2800B4308B /* MockDataStore.swift */; }; + 2EC6027028EB589600C07D5A /* MediaXDMEventHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC6026F28EB589600C07D5A /* MediaXDMEventHelperTests.swift */; }; + 2EC6027228EB797B00C07D5A /* AssertUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC6027128EB797B00C07D5A /* AssertUtils.swift */; }; + 2ED7125728ADA958006A83D0 /* MediaRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7125428ADA958006A83D0 /* MediaRule.swift */; }; + 2ED7125828ADA958006A83D0 /* MediaEventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7125528ADA958006A83D0 /* MediaEventTracker.swift */; }; + 2ED7125928ADA958006A83D0 /* MediaRuleEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7125628ADA958006A83D0 /* MediaRuleEngine.swift */; }; + 2ED7125B28ADBEEC006A83D0 /* MediaContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7125A28ADBEEC006A83D0 /* MediaContext.swift */; }; + 2ED7126028ADC424006A83D0 /* MediaRuleEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7125E28ADC424006A83D0 /* MediaRuleEngineTests.swift */; }; + 2ED7126128ADC424006A83D0 /* MediaEventTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7125F28ADC424006A83D0 /* MediaEventTrackerTests.swift */; }; + 2ED7126528ADD665006A83D0 /* MediaState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7126428ADD665006A83D0 /* MediaState.swift */; }; + 2ED7126728ADDF5B006A83D0 /* MediaStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7126628ADDF5B006A83D0 /* MediaStateTests.swift */; }; + 2ED7126928ADDFC0006A83D0 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7126828ADDFC0006A83D0 /* TestConstants.swift */; }; + 2ED7126B28B42B58006A83D0 /* video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = 2ED7126A28B42B58006A83D0 /* video.mp4 */; }; + 2ED7126C28B42B58006A83D0 /* video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = 2ED7126A28B42B58006A83D0 /* video.mp4 */; }; + 2ED7126F28B4445E006A83D0 /* MediaAnalyticsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7126E28B4445E006A83D0 /* MediaAnalyticsProvider.swift */; }; + 2ED7127028B4445E006A83D0 /* MediaAnalyticsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7126E28B4445E006A83D0 /* MediaAnalyticsProvider.swift */; }; + 2ED7127328B444C8006A83D0 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7127228B444C8006A83D0 /* VideoPlayer.swift */; }; + 2ED7127428B444C8006A83D0 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ED7127228B444C8006A83D0 /* VideoPlayer.swift */; }; + 2EDFBCF42899E06300D22B25 /* Media+PublicAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBCF12899E06200D22B25 /* Media+PublicAPITests.swift */; }; + 2EDFBCF52899E06300D22B25 /* MediaObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBCF22899E06200D22B25 /* MediaObjectTests.swift */; }; + 2EDFBCF62899E06300D22B25 /* MediaPublicTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBCF32899E06200D22B25 /* MediaPublicTrackerTests.swift */; }; + 2EDFBD002899E30200D22B25 /* MediaConstants+Public.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBCF82899E2F700D22B25 /* MediaConstants+Public.swift */; }; + 2EDFBD012899E30500D22B25 /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBCF92899E2F700D22B25 /* MediaType.swift */; }; + 2EDFBD022899E30800D22B25 /* Media+PublicAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBCFA2899E2F700D22B25 /* Media+PublicAPI.swift */; }; + 2EDFBD0F2899E3DF00D22B25 /* StateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD092899E3DF00D22B25 /* StateInfo.swift */; }; + 2EDFBD102899E3DF00D22B25 /* AdInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD0A2899E3DF00D22B25 /* AdInfo.swift */; }; + 2EDFBD112899E3DF00D22B25 /* MediaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD0B2899E3DF00D22B25 /* MediaInfo.swift */; }; + 2EDFBD122899E3DF00D22B25 /* AdBreakInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD0C2899E3DF00D22B25 /* AdBreakInfo.swift */; }; + 2EDFBD132899E3DF00D22B25 /* QoEInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD0D2899E3DF00D22B25 /* QoEInfo.swift */; }; + 2EDFBD142899E3DF00D22B25 /* ChapterInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EDFBD0E2899E3DF00D22B25 /* ChapterInfo.swift */; }; + 2EE03B1128B7FF0F00176FF8 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EE03B1028B7FF0F00176FF8 /* VideoPlayerView.swift */; }; + 2EE03B1228B7FF0F00176FF8 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EE03B1028B7FF0F00176FF8 /* VideoPlayerView.swift */; }; + 2EEA08E7291DD51100043C43 /* XDMData+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EEA08E6291DD51100043C43 /* XDMData+Comparable.swift */; }; + 2EEA08E8291DD51100043C43 /* XDMData+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EEA08E6291DD51100043C43 /* XDMData+Comparable.swift */; }; + 561CC7CE9CFC2B6318CD2E52 /* Pods_IntegrationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFB5276F870C0E011C744CAD /* Pods_IntegrationTests.framework */; }; + 6541D12AFD63BB69228C3FB6 /* Pods_TestApptvOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5D3C48A3D451937CE4915A86 /* Pods_TestApptvOS.framework */; }; + 7978A40ADDA23DA53B6952D0 /* Pods_AEPEdgeMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 77817399750E272978A207DF /* Pods_AEPEdgeMedia.framework */; }; + 90D4AFD8E67DC9BCB248A73C /* Pods_TestAppiOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCCBE2545470FEAD9ABD6398 /* Pods_TestAppiOS.framework */; }; + 91162D1381B7B61A074079F4 /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 44015C33FEFC2F875D4F6E18 /* Pods_UnitTests.framework */; }; + B4FEF674B4304E913DC7F33F /* Pods_FunctionalTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 44F1B2CD280B01D238562520 /* Pods_FunctionalTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2E4D75002979E02E00396819 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2EB0406D2888B0D200306323 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2EB040752888B0D200306323; + remoteInfo = AEPEdgeMedia; + }; + 2EB040822888B0D200306323 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2EB0406D2888B0D200306323 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2EB040752888B0D200306323; + remoteInfo = AEPEdgeMedia; + }; + 2EB040A32888B46400306323 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2EB0406D2888B0D200306323 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2EB040752888B0D200306323; + remoteInfo = AEPEdgeMedia; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 02892AE8A4A0629382CD7B94 /* Pods-IntegrationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-IntegrationTests.release.xcconfig"; path = "Target Support Files/Pods-IntegrationTests/Pods-IntegrationTests.release.xcconfig"; sourceTree = ""; }; + 02D1FDF0F82BB698F7852D94 /* Pods-UnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.release.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.release.xcconfig"; sourceTree = ""; }; + 08C6BFDAC0AFE7593216FAE5 /* Pods-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.debug.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.debug.xcconfig"; sourceTree = ""; }; + 0B55F129F98146BCD54F342D /* Pods-AEPEdgeMedia.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AEPEdgeMedia.release.xcconfig"; path = "Target Support Files/Pods-AEPEdgeMedia/Pods-AEPEdgeMedia.release.xcconfig"; sourceTree = ""; }; + 0C34C0809714D8E227F8E454 /* Pods-TestApptvOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TestApptvOS.debug.xcconfig"; path = "Target Support Files/Pods-TestApptvOS/Pods-TestApptvOS.debug.xcconfig"; sourceTree = ""; }; + 2BA2F8686E9727B9638C7741 /* Pods-TestApptvOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TestApptvOS.release.xcconfig"; path = "Target Support Files/Pods-TestApptvOS/Pods-TestApptvOS.release.xcconfig"; sourceTree = ""; }; + 2E0A1C3B2997107F0099C134 /* MediaContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaContextTests.swift; sourceTree = ""; }; + 2E19666528B8220900298FD4 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 2E19666828B8236100298FD4 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 2E19666B28B824C300298FD4 /* AssuranceView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssuranceView.swift; sourceTree = ""; }; + 2E224FBA29710881005FB095 /* InstrumentedExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InstrumentedExtension.swift; sourceTree = ""; }; + 2E224FBB29710881005FB095 /* FunctionalTestNetworkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionalTestNetworkService.swift; sourceTree = ""; }; + 2E224FBC29710881005FB095 /* CountDownLatch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CountDownLatch.swift; sourceTree = ""; }; + 2E224FBD29710881005FB095 /* FunctionalTestBase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionalTestBase.swift; sourceTree = ""; }; + 2E224FC2297108A9005FB095 /* Media+Edge+EdgeIdentityFunctionalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Media+Edge+EdgeIdentityFunctionalTests.swift"; sourceTree = ""; }; + 2E224FC42971EA50005FB095 /* FunctionalTestConstant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionalTestConstant.swift; sourceTree = ""; }; + 2E224FC62971EB46005FB095 /* FileManager+Testable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FileManager+Testable.swift"; sourceTree = ""; }; + 2E224FC82971EC11005FB095 /* EventHub+Test.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EventHub+Test.swift"; sourceTree = ""; }; + 2E224FC92971EC11005FB095 /* UserDefaults+Test.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Test.swift"; sourceTree = ""; }; + 2E37D33C28CFDD7800B782F8 /* MediaEventProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEventProcessor.swift; sourceTree = ""; }; + 2E37D33E28CFDDF900B782F8 /* MediaSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaSession.swift; sourceTree = ""; }; + 2E37D34028CFE44400B782F8 /* MediaRealTimeSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaRealTimeSession.swift; sourceTree = ""; }; + 2E37D34228D123BE00B782F8 /* MediaXDMEventGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaXDMEventGenerator.swift; sourceTree = ""; }; + 2E37D34428D12A3500B782F8 /* MediaXDMEventHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaXDMEventHelper.swift; sourceTree = ""; }; + 2E37D38428D290CE00B782F8 /* MediaEventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventProcessing.swift; sourceTree = ""; }; + 2E37D38628D4169100B782F8 /* MediaEventProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEventProcessorTests.swift; sourceTree = ""; }; + 2E37D38928D4185B00B782F8 /* MediaSessionSpy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaSessionSpy.swift; sourceTree = ""; }; + 2E37D38C28D54B2900B782F8 /* XDMMediaEventType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMMediaEventType.swift; sourceTree = ""; }; + 2E3BF82928D9352F0043DD00 /* MediaXDMEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaXDMEvent.swift; sourceTree = ""; }; + 2E3BF82F28D961440043DD00 /* MediaXDMEventGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaXDMEventGeneratorTests.swift; sourceTree = ""; }; + 2E3BF83128D9628B0043DD00 /* MediaEventProcessorSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventProcessorSpy.swift; sourceTree = ""; }; + 2E3BF83428DBB59C0043DD00 /* XDMCustomMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMCustomMetadata.swift; sourceTree = ""; }; + 2E459863290A2EEC003111EE /* MediaXDMEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaXDMEventTests.swift; sourceTree = ""; }; + 2E459865290B7144003111EE /* MediaRealTimeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaRealTimeSessionTests.swift; sourceTree = ""; }; + 2E459867290B8585003111EE /* XDMDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMDataHelper.swift; sourceTree = ""; }; + 2E4B4A9929838CB900638DE7 /* CustomPingDuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPingDuration.swift; sourceTree = ""; }; + 2E4D75242979E02E00396819 /* IntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2E4D816929357B82005A4543 /* SimplePlayback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimplePlayback.swift; sourceTree = ""; }; + 2E4D816A29357B82005A4543 /* BaseScenarioTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseScenarioTest.swift; sourceTree = ""; }; + 2E4D81702936942F005A4543 /* EdgeEventHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeEventHelper.swift; sourceTree = ""; }; + 2E4D8172293954F6005A4543 /* FakeMediaEventProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FakeMediaEventProcessor.swift; sourceTree = ""; }; + 2E58A92928BEB403004A9FA5 /* XDMErrorDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMErrorDetails.swift; sourceTree = ""; }; + 2E58A92B28BEB403004A9FA5 /* XDMMediaCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMMediaCollection.swift; sourceTree = ""; }; + 2E58A92D28BEB403004A9FA5 /* XDMQoeDataDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMQoeDataDetails.swift; sourceTree = ""; }; + 2E58A92E28BEB403004A9FA5 /* XDMAdvertisingPodDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMAdvertisingPodDetails.swift; sourceTree = ""; }; + 2E58A92F28BEB403004A9FA5 /* XDMSessionDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMSessionDetails.swift; sourceTree = ""; }; + 2E58A93028BEB403004A9FA5 /* XDMChapterDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMChapterDetails.swift; sourceTree = ""; }; + 2E58A93128BEB403004A9FA5 /* XDMPlayerStateData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMPlayerStateData.swift; sourceTree = ""; }; + 2E58A93328BEB403004A9FA5 /* XDMStreamType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMStreamType.swift; sourceTree = ""; }; + 2E58A93728BEB403004A9FA5 /* XDMAdvertisingDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XDMAdvertisingDetails.swift; sourceTree = ""; }; + 2E58A94828BEB523004A9FA5 /* XDMAdvertisingDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMAdvertisingDetailsTests.swift; sourceTree = ""; }; + 2E7F67EA28B82CDB005BD190 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 2EA1FB46291DAC9000C4FFFE /* XDMData+Equatable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XDMData+Equatable.swift"; sourceTree = ""; }; + 2EA7BC2228C02F79001A7C2A /* TestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtils.swift; sourceTree = ""; }; + 2EA7BC2428C035FF001A7C2A /* XDMAdvertisingPodDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMAdvertisingPodDetailsTests.swift; sourceTree = ""; }; + 2EA7BC2628C04675001A7C2A /* XDMErrorDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMErrorDetailsTests.swift; sourceTree = ""; }; + 2EA7BC2828C04796001A7C2A /* XDMChapterDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMChapterDetailsTests.swift; sourceTree = ""; }; + 2EA7BC2C28C11CD4001A7C2A /* XDMSessionDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMSessionDetailsTests.swift; sourceTree = ""; }; + 2EA7BC2E28C13924001A7C2A /* XDMMediaCollectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDMMediaCollectionTests.swift; sourceTree = ""; }; + 2EAD52E8296F8FC90099D82B /* CustomError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = ""; }; + 2EAD52EA296F8FDE0099D82B /* CustomStatePlayback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomStatePlayback.swift; sourceTree = ""; }; + 2EAD52EC296F8FFA0099D82B /* Timeout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Timeout.swift; sourceTree = ""; }; + 2EAD52EE296F90C60099D82B /* SpecialAdPlayback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpecialAdPlayback.swift; sourceTree = ""; }; + 2EAD52EF296F90C60099D82B /* AdPlayback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdPlayback.swift; sourceTree = ""; }; + 2EAD52F0296F90C60099D82B /* ChapterPlayback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChapterPlayback.swift; sourceTree = ""; }; + 2EAD52F1296F90C60099D82B /* AdChapterPlayback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdChapterPlayback.swift; sourceTree = ""; }; + 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AEPEdgeMedia.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2EB040802888B0D200306323 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2EB040982888B14C00306323 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 2EB0409B2888B35D00306323 /* AEPEdgeMedia.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AEPEdgeMedia.h; sourceTree = ""; }; + 2EB040AB2888B46400306323 /* FunctionalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FunctionalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2EB040B42889CD0500306323 /* EdgeMediaTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EdgeMediaTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2EB040D22889CD2D00306323 /* EdgeMediaTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EdgeMediaTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2EB040E22894A4E300306323 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2EB040E32894A4E300306323 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 2EB040EE2894AE0500306323 /* Event+Media.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Event+Media.swift"; sourceTree = ""; }; + 2EB040F22894AE0600306323 /* Media.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Media.swift; sourceTree = ""; }; + 2EB040F32894AE0600306323 /* Double+Media.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Media.swift"; sourceTree = ""; }; + 2EB040F52894AE0600306323 /* MediaConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaConstants.swift; sourceTree = ""; }; + 2EB164ED289AF3B800089C83 /* MediaEventTracking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEventTracking.swift; sourceTree = ""; }; + 2EB164F2289AF59C00089C83 /* MediaEventGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEventGenerator.swift; sourceTree = ""; }; + 2EB164F4289AF63900089C83 /* MockExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockExtension.swift; sourceTree = ""; }; + 2EB164F6289AF6B900089C83 /* TestHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; + 2EC3C49229B98C6900B4308B /* MediaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTests.swift; sourceTree = ""; }; + 2EC3C49429B98CFF00B4308B /* TestableExtensionRuntime.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestableExtensionRuntime.swift; sourceTree = ""; }; + 2EC3C49629B98D2800B4308B /* MockDataStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockDataStore.swift; sourceTree = ""; }; + 2EC6026F28EB589600C07D5A /* MediaXDMEventHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaXDMEventHelperTests.swift; sourceTree = ""; }; + 2EC6027128EB797B00C07D5A /* AssertUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssertUtils.swift; sourceTree = ""; }; + 2ED7125428ADA958006A83D0 /* MediaRule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaRule.swift; sourceTree = ""; }; + 2ED7125528ADA958006A83D0 /* MediaEventTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEventTracker.swift; sourceTree = ""; }; + 2ED7125628ADA958006A83D0 /* MediaRuleEngine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaRuleEngine.swift; sourceTree = ""; }; + 2ED7125A28ADBEEC006A83D0 /* MediaContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaContext.swift; sourceTree = ""; }; + 2ED7125E28ADC424006A83D0 /* MediaRuleEngineTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaRuleEngineTests.swift; sourceTree = ""; }; + 2ED7125F28ADC424006A83D0 /* MediaEventTrackerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEventTrackerTests.swift; sourceTree = ""; }; + 2ED7126428ADD665006A83D0 /* MediaState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaState.swift; sourceTree = ""; }; + 2ED7126628ADDF5B006A83D0 /* MediaStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaStateTests.swift; sourceTree = ""; }; + 2ED7126828ADDFC0006A83D0 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; + 2ED7126A28B42B58006A83D0 /* video.mp4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = video.mp4; sourceTree = ""; }; + 2ED7126E28B4445E006A83D0 /* MediaAnalyticsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAnalyticsProvider.swift; sourceTree = ""; }; + 2ED7127228B444C8006A83D0 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + 2EDFBCF12899E06200D22B25 /* Media+PublicAPITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Media+PublicAPITests.swift"; sourceTree = ""; }; + 2EDFBCF22899E06200D22B25 /* MediaObjectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaObjectTests.swift; sourceTree = ""; }; + 2EDFBCF32899E06200D22B25 /* MediaPublicTrackerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaPublicTrackerTests.swift; sourceTree = ""; }; + 2EDFBCF82899E2F700D22B25 /* MediaConstants+Public.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MediaConstants+Public.swift"; sourceTree = ""; }; + 2EDFBCF92899E2F700D22B25 /* MediaType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = ""; }; + 2EDFBCFA2899E2F700D22B25 /* Media+PublicAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Media+PublicAPI.swift"; sourceTree = ""; }; + 2EDFBD042899E36C00D22B25 /* MediaPublicTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaPublicTracker.swift; sourceTree = ""; }; + 2EDFBD062899E3B400D22B25 /* MediaTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaTracker.swift; sourceTree = ""; }; + 2EDFBD092899E3DF00D22B25 /* StateInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateInfo.swift; sourceTree = ""; }; + 2EDFBD0A2899E3DF00D22B25 /* AdInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdInfo.swift; sourceTree = ""; }; + 2EDFBD0B2899E3DF00D22B25 /* MediaInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfo.swift; sourceTree = ""; }; + 2EDFBD0C2899E3DF00D22B25 /* AdBreakInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdBreakInfo.swift; sourceTree = ""; }; + 2EDFBD0D2899E3DF00D22B25 /* QoEInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QoEInfo.swift; sourceTree = ""; }; + 2EDFBD0E2899E3DF00D22B25 /* ChapterInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChapterInfo.swift; sourceTree = ""; }; + 2EE03B1028B7FF0F00176FF8 /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + 2EEA08E6291DD51100043C43 /* XDMData+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XDMData+Comparable.swift"; sourceTree = ""; }; + 44015C33FEFC2F875D4F6E18 /* Pods_UnitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_UnitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 44F1B2CD280B01D238562520 /* Pods_FunctionalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FunctionalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 569F85822F154D411B4ADEC4 /* Pods-TestAppiOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TestAppiOS.debug.xcconfig"; path = "Target Support Files/Pods-TestAppiOS/Pods-TestAppiOS.debug.xcconfig"; sourceTree = ""; }; + 5D3C48A3D451937CE4915A86 /* Pods_TestApptvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TestApptvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 636D6196D3D501CA7C1FC98A /* Pods-FunctionalTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FunctionalTests.debug.xcconfig"; path = "Target Support Files/Pods-FunctionalTests/Pods-FunctionalTests.debug.xcconfig"; sourceTree = ""; }; + 77817399750E272978A207DF /* Pods_AEPEdgeMedia.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AEPEdgeMedia.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9174C24A2D1A573EEDD8D92C /* Pods-AEPEdgeMedia.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AEPEdgeMedia.debug.xcconfig"; path = "Target Support Files/Pods-AEPEdgeMedia/Pods-AEPEdgeMedia.debug.xcconfig"; sourceTree = ""; }; + A5E55E991B358F0401D9C708 /* Pods-IntegrationTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-IntegrationTests.debug.xcconfig"; path = "Target Support Files/Pods-IntegrationTests/Pods-IntegrationTests.debug.xcconfig"; sourceTree = ""; }; + D676E12007639419F2E15AD7 /* Pods-FunctionalTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FunctionalTests.release.xcconfig"; path = "Target Support Files/Pods-FunctionalTests/Pods-FunctionalTests.release.xcconfig"; sourceTree = ""; }; + DCCBE2545470FEAD9ABD6398 /* Pods_TestAppiOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TestAppiOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E63FB88ECE18EDA538774428 /* Pods-TestAppiOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TestAppiOS.release.xcconfig"; path = "Target Support Files/Pods-TestAppiOS/Pods-TestAppiOS.release.xcconfig"; sourceTree = ""; }; + FFB5276F870C0E011C744CAD /* Pods_IntegrationTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_IntegrationTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2E4D751C2979E02E00396819 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E4D751D2979E02E00396819 /* AEPEdgeMedia.framework in Frameworks */, + 561CC7CE9CFC2B6318CD2E52 /* Pods_IntegrationTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040732888B0D200306323 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7978A40ADDA23DA53B6952D0 /* Pods_AEPEdgeMedia.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB0407D2888B0D200306323 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2EB040812888B0D200306323 /* AEPEdgeMedia.framework in Frameworks */, + 91162D1381B7B61A074079F4 /* Pods_UnitTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040A52888B46400306323 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2EB040A62888B46400306323 /* AEPEdgeMedia.framework in Frameworks */, + B4FEF674B4304E913DC7F33F /* Pods_FunctionalTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040B12889CD0500306323 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E19666328B8157D00298FD4 /* AEPEdgeMedia.framework in Frameworks */, + 90D4AFD8E67DC9BCB248A73C /* Pods_TestAppiOS.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040CC2889CD2D00306323 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E19666428B8158300298FD4 /* AEPEdgeMedia.framework in Frameworks */, + 6541D12AFD63BB69228C3FB6 /* Pods_TestApptvOS.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2E224FB929710881005FB095 /* Utils */ = { + isa = PBXGroup; + children = ( + 2E224FBC29710881005FB095 /* CountDownLatch.swift */, + 2E224FC82971EC11005FB095 /* EventHub+Test.swift */, + 2E224FC62971EB46005FB095 /* FileManager+Testable.swift */, + 2E224FBD29710881005FB095 /* FunctionalTestBase.swift */, + 2E224FC42971EA50005FB095 /* FunctionalTestConstant.swift */, + 2E224FBB29710881005FB095 /* FunctionalTestNetworkService.swift */, + 2E224FBA29710881005FB095 /* InstrumentedExtension.swift */, + 2E224FC92971EC11005FB095 /* UserDefaults+Test.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 2E4D75252979E06E00396819 /* IntegrationTests */ = { + isa = PBXGroup; + children = ( + 2E224FB929710881005FB095 /* Utils */, + 2E224FC2297108A9005FB095 /* Media+Edge+EdgeIdentityFunctionalTests.swift */, + ); + path = IntegrationTests; + sourceTree = ""; + }; + 2E4D752E2979E59F00396819 /* Utils */ = { + isa = PBXGroup; + children = ( + 2EC3C49629B98D2800B4308B /* MockDataStore.swift */, + 2EC3C49429B98CFF00B4308B /* TestableExtensionRuntime.swift */, + 2EC6027128EB797B00C07D5A /* AssertUtils.swift */, + 2E4D8172293954F6005A4543 /* FakeMediaEventProcessor.swift */, + 2E37D38928D4185B00B782F8 /* MediaSessionSpy.swift */, + 2EB164F4289AF63900089C83 /* MockExtension.swift */, + 2EB164F6289AF6B900089C83 /* TestHelpers.swift */, + 2ED7126828ADDFC0006A83D0 /* TestConstants.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 2E4D752F2979E65300396819 /* Utils */ = { + isa = PBXGroup; + children = ( + 2E3BF83128D9628B0043DD00 /* MediaEventProcessorSpy.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 2E4D816829357AF7005A4543 /* Scenarios */ = { + isa = PBXGroup; + children = ( + 2EAD52F1296F90C60099D82B /* AdChapterPlayback.swift */, + 2EAD52EF296F90C60099D82B /* AdPlayback.swift */, + 2E4D816A29357B82005A4543 /* BaseScenarioTest.swift */, + 2EAD52F0296F90C60099D82B /* ChapterPlayback.swift */, + 2EAD52EA296F8FDE0099D82B /* CustomStatePlayback.swift */, + 2EAD52E8296F8FC90099D82B /* CustomError.swift */, + 2E4D816929357B82005A4543 /* SimplePlayback.swift */, + 2EAD52EE296F90C60099D82B /* SpecialAdPlayback.swift */, + 2EAD52EC296F8FFA0099D82B /* Timeout.swift */, + 2E4B4A9929838CB900638DE7 /* CustomPingDuration.swift */, + ); + path = Scenarios; + sourceTree = ""; + }; + 2E58A92728BEB403004A9FA5 /* xdm */ = { + isa = PBXGroup; + children = ( + 2E58A93728BEB403004A9FA5 /* XDMAdvertisingDetails.swift */, + 2E58A92E28BEB403004A9FA5 /* XDMAdvertisingPodDetails.swift */, + 2E58A93028BEB403004A9FA5 /* XDMChapterDetails.swift */, + 2E3BF83428DBB59C0043DD00 /* XDMCustomMetadata.swift */, + 2E58A92928BEB403004A9FA5 /* XDMErrorDetails.swift */, + 2E58A92B28BEB403004A9FA5 /* XDMMediaCollection.swift */, + 2E37D38C28D54B2900B782F8 /* XDMMediaEventType.swift */, + 2E58A93128BEB403004A9FA5 /* XDMPlayerStateData.swift */, + 2E58A92D28BEB403004A9FA5 /* XDMQoeDataDetails.swift */, + 2E58A92F28BEB403004A9FA5 /* XDMSessionDetails.swift */, + 2E58A93328BEB403004A9FA5 /* XDMStreamType.swift */, + ); + path = xdm; + sourceTree = ""; + }; + 2EB0406C2888B0D200306323 = { + isa = PBXGroup; + children = ( + 2EB040982888B14C00306323 /* README.md */, + 2EB040932888B12400306323 /* Sources */, + 2EB040902888B12400306323 /* Tests */, + 2EB040E02894A4E300306323 /* TestApp */, + 2EB040772888B0D200306323 /* Products */, + 59FA184C245144D1DAE628CE /* Pods */, + 949D73A823375032D6E5C569 /* Frameworks */, + ); + sourceTree = ""; + }; + 2EB040772888B0D200306323 /* Products */ = { + isa = PBXGroup; + children = ( + 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */, + 2EB040802888B0D200306323 /* UnitTests.xctest */, + 2EB040AB2888B46400306323 /* FunctionalTests.xctest */, + 2EB040B42889CD0500306323 /* EdgeMediaTestApp.app */, + 2EB040D22889CD2D00306323 /* EdgeMediaTestApp.app */, + 2E4D75242979E02E00396819 /* IntegrationTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2EB040902888B12400306323 /* Tests */ = { + isa = PBXGroup; + children = ( + 2E4D75252979E06E00396819 /* IntegrationTests */, + 2EB164F1289AF59C00089C83 /* TestHelpers */, + 2EB0409F2888B43400306323 /* FunctionalTests */, + 2EB0409D2888B43400306323 /* UnitTests */, + ); + path = Tests; + sourceTree = ""; + }; + 2EB040932888B12400306323 /* Sources */ = { + isa = PBXGroup; + children = ( + 2EB0409B2888B35D00306323 /* AEPEdgeMedia.h */, + 2E58A92728BEB403004A9FA5 /* xdm */, + 2EDFBD082899E3DF00D22B25 /* MediaObject */, + 2EDFBCF72899E2F700D22B25 /* Public */, + 2EB040F32894AE0600306323 /* Double+Media.swift */, + 2EB040EE2894AE0500306323 /* Event+Media.swift */, + 2EB040F22894AE0600306323 /* Media.swift */, + 2ED7125A28ADBEEC006A83D0 /* MediaContext.swift */, + 2EB040F52894AE0600306323 /* MediaConstants.swift */, + 2ED7125528ADA958006A83D0 /* MediaEventTracker.swift */, + 2EB164ED289AF3B800089C83 /* MediaEventTracking.swift */, + 2E37D38428D290CE00B782F8 /* MediaEventProcessing.swift */, + 2E37D33C28CFDD7800B782F8 /* MediaEventProcessor.swift */, + 2EDFBD042899E36C00D22B25 /* MediaPublicTracker.swift */, + 2E37D34028CFE44400B782F8 /* MediaRealTimeSession.swift */, + 2ED7125428ADA958006A83D0 /* MediaRule.swift */, + 2ED7125628ADA958006A83D0 /* MediaRuleEngine.swift */, + 2ED7126428ADD665006A83D0 /* MediaState.swift */, + 2E37D33E28CFDDF900B782F8 /* MediaSession.swift */, + 2E3BF82928D9352F0043DD00 /* MediaXDMEvent.swift */, + 2E37D34228D123BE00B782F8 /* MediaXDMEventGenerator.swift */, + 2E37D34428D12A3500B782F8 /* MediaXDMEventHelper.swift */, + ); + path = Sources; + sourceTree = ""; + }; + 2EB0409D2888B43400306323 /* UnitTests */ = { + isa = PBXGroup; + children = ( + 2E4D752E2979E59F00396819 /* Utils */, + 2EDFBCF12899E06200D22B25 /* Media+PublicAPITests.swift */, + 2E0A1C3B2997107F0099C134 /* MediaContextTests.swift */, + 2E37D38628D4169100B782F8 /* MediaEventProcessorTests.swift */, + 2ED7125F28ADC424006A83D0 /* MediaEventTrackerTests.swift */, + 2EDFBCF22899E06200D22B25 /* MediaObjectTests.swift */, + 2EDFBCF32899E06200D22B25 /* MediaPublicTrackerTests.swift */, + 2ED7125E28ADC424006A83D0 /* MediaRuleEngineTests.swift */, + 2E459865290B7144003111EE /* MediaRealTimeSessionTests.swift */, + 2ED7126628ADDF5B006A83D0 /* MediaStateTests.swift */, + 2EC3C49229B98C6900B4308B /* MediaTests.swift */, + 2E459863290A2EEC003111EE /* MediaXDMEventTests.swift */, + 2E3BF82F28D961440043DD00 /* MediaXDMEventGeneratorTests.swift */, + 2EC6026F28EB589600C07D5A /* MediaXDMEventHelperTests.swift */, + 2E58A94828BEB523004A9FA5 /* XDMAdvertisingDetailsTests.swift */, + 2EA7BC2428C035FF001A7C2A /* XDMAdvertisingPodDetailsTests.swift */, + 2EA7BC2628C04675001A7C2A /* XDMErrorDetailsTests.swift */, + 2EA7BC2828C04796001A7C2A /* XDMChapterDetailsTests.swift */, + 2EA7BC2C28C11CD4001A7C2A /* XDMSessionDetailsTests.swift */, + 2EA7BC2E28C13924001A7C2A /* XDMMediaCollectionTests.swift */, + ); + path = UnitTests; + sourceTree = ""; + }; + 2EB0409F2888B43400306323 /* FunctionalTests */ = { + isa = PBXGroup; + children = ( + 2E4D752F2979E65300396819 /* Utils */, + 2E4D816829357AF7005A4543 /* Scenarios */, + ); + path = FunctionalTests; + sourceTree = ""; + }; + 2EB040E02894A4E300306323 /* TestApp */ = { + isa = PBXGroup; + children = ( + 2ED7127128B444B8006A83D0 /* Player */, + 2ED7126D28B44438006A83D0 /* Analytics */, + 2E19666828B8236100298FD4 /* SceneDelegate.swift */, + 2E19666528B8220900298FD4 /* AppDelegate.swift */, + 2EB040E32894A4E300306323 /* ContentView.swift */, + 2EE03B1028B7FF0F00176FF8 /* VideoPlayerView.swift */, + 2E19666B28B824C300298FD4 /* AssuranceView.swift */, + 2ED7126A28B42B58006A83D0 /* video.mp4 */, + 2EB040E22894A4E300306323 /* Assets.xcassets */, + 2E7F67EA28B82CDB005BD190 /* Info.plist */, + ); + name = TestApp; + path = TestApps/TestApp; + sourceTree = ""; + }; + 2EB164F1289AF59C00089C83 /* TestHelpers */ = { + isa = PBXGroup; + children = ( + 2E4D81702936942F005A4543 /* EdgeEventHelper.swift */, + 2EB164F2289AF59C00089C83 /* MediaEventGenerator.swift */, + 2EA7BC2228C02F79001A7C2A /* TestUtils.swift */, + 2EEA08E6291DD51100043C43 /* XDMData+Comparable.swift */, + 2EA1FB46291DAC9000C4FFFE /* XDMData+Equatable.swift */, + 2E459867290B8585003111EE /* XDMDataHelper.swift */, + ); + path = TestHelpers; + sourceTree = ""; + }; + 2ED7126D28B44438006A83D0 /* Analytics */ = { + isa = PBXGroup; + children = ( + 2ED7126E28B4445E006A83D0 /* MediaAnalyticsProvider.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + 2ED7127128B444B8006A83D0 /* Player */ = { + isa = PBXGroup; + children = ( + 2ED7127228B444C8006A83D0 /* VideoPlayer.swift */, + ); + path = Player; + sourceTree = ""; + }; + 2EDFBCF72899E2F700D22B25 /* Public */ = { + isa = PBXGroup; + children = ( + 2EDFBCFA2899E2F700D22B25 /* Media+PublicAPI.swift */, + 2EDFBCF82899E2F700D22B25 /* MediaConstants+Public.swift */, + 2EDFBD062899E3B400D22B25 /* MediaTracker.swift */, + 2EDFBCF92899E2F700D22B25 /* MediaType.swift */, + ); + path = Public; + sourceTree = ""; + }; + 2EDFBD082899E3DF00D22B25 /* MediaObject */ = { + isa = PBXGroup; + children = ( + 2EDFBD0C2899E3DF00D22B25 /* AdBreakInfo.swift */, + 2EDFBD0A2899E3DF00D22B25 /* AdInfo.swift */, + 2EDFBD0B2899E3DF00D22B25 /* MediaInfo.swift */, + 2EDFBD0E2899E3DF00D22B25 /* ChapterInfo.swift */, + 2EDFBD0D2899E3DF00D22B25 /* QoEInfo.swift */, + 2EDFBD092899E3DF00D22B25 /* StateInfo.swift */, + ); + path = MediaObject; + sourceTree = ""; + }; + 59FA184C245144D1DAE628CE /* Pods */ = { + isa = PBXGroup; + children = ( + 9174C24A2D1A573EEDD8D92C /* Pods-AEPEdgeMedia.debug.xcconfig */, + 0B55F129F98146BCD54F342D /* Pods-AEPEdgeMedia.release.xcconfig */, + 636D6196D3D501CA7C1FC98A /* Pods-FunctionalTests.debug.xcconfig */, + D676E12007639419F2E15AD7 /* Pods-FunctionalTests.release.xcconfig */, + A5E55E991B358F0401D9C708 /* Pods-IntegrationTests.debug.xcconfig */, + 02892AE8A4A0629382CD7B94 /* Pods-IntegrationTests.release.xcconfig */, + 569F85822F154D411B4ADEC4 /* Pods-TestAppiOS.debug.xcconfig */, + E63FB88ECE18EDA538774428 /* Pods-TestAppiOS.release.xcconfig */, + 0C34C0809714D8E227F8E454 /* Pods-TestApptvOS.debug.xcconfig */, + 2BA2F8686E9727B9638C7741 /* Pods-TestApptvOS.release.xcconfig */, + 08C6BFDAC0AFE7593216FAE5 /* Pods-UnitTests.debug.xcconfig */, + 02D1FDF0F82BB698F7852D94 /* Pods-UnitTests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 949D73A823375032D6E5C569 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 77817399750E272978A207DF /* Pods_AEPEdgeMedia.framework */, + 44F1B2CD280B01D238562520 /* Pods_FunctionalTests.framework */, + FFB5276F870C0E011C744CAD /* Pods_IntegrationTests.framework */, + DCCBE2545470FEAD9ABD6398 /* Pods_TestAppiOS.framework */, + 5D3C48A3D451937CE4915A86 /* Pods_TestApptvOS.framework */, + 44015C33FEFC2F875D4F6E18 /* Pods_UnitTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 2EB040712888B0D200306323 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 2EB0409C2888B35D00306323 /* AEPEdgeMedia.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 2E4D74FE2979E02E00396819 /* IntegrationTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2E4D75212979E02E00396819 /* Build configuration list for PBXNativeTarget "IntegrationTests" */; + buildPhases = ( + F0592369EDD5090D965157D7 /* [CP] Check Pods Manifest.lock */, + 2E4D75022979E02E00396819 /* Sources */, + 2E4D751C2979E02E00396819 /* Frameworks */, + 2E4D751F2979E02E00396819 /* Resources */, + 2E0A1C38299703C50099C134 /* ShellScript */, + F0683CFAC689D589DD88A6E3 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 2E4D74FF2979E02E00396819 /* PBXTargetDependency */, + ); + name = IntegrationTests; + productName = AEPEdgeMediaTests; + productReference = 2E4D75242979E02E00396819 /* IntegrationTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 2EB040752888B0D200306323 /* AEPEdgeMedia */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2EB0408A2888B0D200306323 /* Build configuration list for PBXNativeTarget "AEPEdgeMedia" */; + buildPhases = ( + F43D54211EACB4B05B3310EA /* [CP] Check Pods Manifest.lock */, + 2EB040712888B0D200306323 /* Headers */, + 2EB040722888B0D200306323 /* Sources */, + 2EB040732888B0D200306323 /* Frameworks */, + 2EB040742888B0D200306323 /* Resources */, + 2E0A1C352996E8370099C134 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AEPEdgeMedia; + productName = AEPEdgeMedia; + productReference = 2EB040762888B0D200306323 /* AEPEdgeMedia.framework */; + productType = "com.apple.product-type.framework"; + }; + 2EB0407F2888B0D200306323 /* UnitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2EB0408D2888B0D200306323 /* Build configuration list for PBXNativeTarget "UnitTests" */; + buildPhases = ( + 7F15104583162CE8042F3F36 /* [CP] Check Pods Manifest.lock */, + 2EB0407C2888B0D200306323 /* Sources */, + 2EB0407D2888B0D200306323 /* Frameworks */, + 2EB0407E2888B0D200306323 /* Resources */, + 2E0A1C3A299703D00099C134 /* ShellScript */, + 472839B3103353A04891290A /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 2EB040832888B0D200306323 /* PBXTargetDependency */, + ); + name = UnitTests; + productName = AEPEdgeMediaTests; + productReference = 2EB040802888B0D200306323 /* UnitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 2EB040A12888B46400306323 /* FunctionalTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2EB040A82888B46400306323 /* Build configuration list for PBXNativeTarget "FunctionalTests" */; + buildPhases = ( + ADF098F0DCC56C4FEA1FEC9A /* [CP] Check Pods Manifest.lock */, + 2EB040A42888B46400306323 /* Sources */, + 2EB040A52888B46400306323 /* Frameworks */, + 2EB040A72888B46400306323 /* Resources */, + 2E0A1C39299703CB0099C134 /* ShellScript */, + 8DF7E062244A3EDD0EB66537 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 2EB040A22888B46400306323 /* PBXTargetDependency */, + ); + name = FunctionalTests; + productName = AEPEdgeMediaTests; + productReference = 2EB040AB2888B46400306323 /* FunctionalTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 2EB040B32889CD0500306323 /* TestAppiOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2EB040C22889CD0600306323 /* Build configuration list for PBXNativeTarget "TestAppiOS" */; + buildPhases = ( + A08AA549A28B81760B16CFCF /* [CP] Check Pods Manifest.lock */, + 2EB040B02889CD0500306323 /* Sources */, + 2EB040B12889CD0500306323 /* Frameworks */, + 2EB040B22889CD0500306323 /* Resources */, + 2E0A1C37299703B60099C134 /* ShellScript */, + 9683F02A687D7AACFCB51DBC /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TestAppiOS; + productName = "TestApp (iOS)"; + productReference = 2EB040B42889CD0500306323 /* EdgeMediaTestApp.app */; + productType = "com.apple.product-type.application"; + }; + 2EB040C82889CD2D00306323 /* TestApptvOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2EB040CF2889CD2D00306323 /* Build configuration list for PBXNativeTarget "TestApptvOS" */; + buildPhases = ( + F286BB449BFF33C5BEF2BBF3 /* [CP] Check Pods Manifest.lock */, + 2EB040C92889CD2D00306323 /* Sources */, + 2EB040CC2889CD2D00306323 /* Frameworks */, + 2EB040CD2889CD2D00306323 /* Resources */, + 2E0A1C36299702F60099C134 /* ShellScript */, + 3E694DE201739FFC9E121BF0 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TestApptvOS; + productName = "TestApp (iOS)"; + productReference = 2EB040D22889CD2D00306323 /* EdgeMediaTestApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2EB0406D2888B0D200306323 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1340; + LastUpgradeCheck = 1340; + TargetAttributes = { + 2EB040752888B0D200306323 = { + CreatedOnToolsVersion = 13.4.1; + }; + 2EB0407F2888B0D200306323 = { + CreatedOnToolsVersion = 13.4.1; + }; + 2EB040A12888B46400306323 = { + LastSwiftMigration = 1340; + }; + 2EB040B32889CD0500306323 = { + CreatedOnToolsVersion = 13.4.1; + }; + }; + }; + buildConfigurationList = 2EB040702888B0D200306323 /* Build configuration list for PBXProject "AEPEdgeMedia" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2EB0406C2888B0D200306323; + productRefGroup = 2EB040772888B0D200306323 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2EB040752888B0D200306323 /* AEPEdgeMedia */, + 2EB0407F2888B0D200306323 /* UnitTests */, + 2EB040A12888B46400306323 /* FunctionalTests */, + 2E4D74FE2979E02E00396819 /* IntegrationTests */, + 2EB040B32889CD0500306323 /* TestAppiOS */, + 2EB040C82889CD2D00306323 /* TestApptvOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2E4D751F2979E02E00396819 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040742888B0D200306323 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2EB0409A2888B14D00306323 /* README.md in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB0407E2888B0D200306323 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040A72888B46400306323 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040B22889CD0500306323 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2EB040E92894A50900306323 /* Assets.xcassets in Resources */, + 2ED7126B28B42B58006A83D0 /* video.mp4 in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040CD2889CD2D00306323 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2EB040EA2894A50A00306323 /* Assets.xcassets in Resources */, + 2ED7126C28B42B58006A83D0 /* video.mp4 in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 2E0A1C352996E8370099C134 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -z ${ADB_SKIP_LINT} || ${ADB_SKIP_LINT} -ne \"YES\" ]]; then\n if which \"${PODS_ROOT}/SwiftLint/swiftlint\" >/dev/null; then\n ./Pods/SwiftLint/swiftlint lint --config ${SRCROOT}/.swiftlint.yml Sources\n else\n echo \"warning: SwiftLint is not installed, please run the pod install command from the project root directory.\"\n fi\nelse\n echo \"Skipping linting build phase as ADB_SKIP_LINT flag is YES.\"\nfi\n"; + }; + 2E0A1C36299702F60099C134 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -z ${ADB_SKIP_LINT} || ${ADB_SKIP_LINT} -ne \"YES\" ]]; then\n if which \"${PODS_ROOT}/SwiftLint/swiftlint\" >/dev/null; then\n ./Pods/SwiftLint/swiftlint lint --config ${SRCROOT}/.swiftlint.yml TestApps/TestApp\n else\n echo \"warning: SwiftLint is not installed, please run the pod install command from the project root directory.\"\n fi\nelse\n echo \"Skipping linting build phase as ADB_SKIP_LINT flag is YES.\"\nfi\n"; + }; + 2E0A1C37299703B60099C134 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -z ${ADB_SKIP_LINT} || ${ADB_SKIP_LINT} -ne \"YES\" ]]; then\n if which \"${PODS_ROOT}/SwiftLint/swiftlint\" >/dev/null; then\n ./Pods/SwiftLint/swiftlint lint --config ${SRCROOT}/.swiftlint.yml TestApps/TestApp\n else\n echo \"warning: SwiftLint is not installed, please run the pod install command from the project root directory.\"\n fi\nelse\n echo \"Skipping linting build phase as ADB_SKIP_LINT flag is YES.\"\nfi\n"; + }; + 2E0A1C38299703C50099C134 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -z ${ADB_SKIP_LINT} || ${ADB_SKIP_LINT} -ne \"YES\" ]]; then\n if which \"${PODS_ROOT}/SwiftLint/swiftlint\" >/dev/null; then\n ./Pods/SwiftLint/swiftlint lint --config Tests/.swiftlint.yml --lenient Tests/IntegrationTests Tests/TestHelpers\n else\n echo \"warning: SwiftLint is not installed, please run the pod install command from the project root directory.\"\n fi\nelse\n echo \"Skipping linting build phase as ADB_SKIP_LINT flag is YES.\"\nfi\n"; + }; + 2E0A1C39299703CB0099C134 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -z ${ADB_SKIP_LINT} || ${ADB_SKIP_LINT} -ne \"YES\" ]]; then\n if which \"${PODS_ROOT}/SwiftLint/swiftlint\" >/dev/null; then\n ./Pods/SwiftLint/swiftlint lint --config Tests/.swiftlint.yml --lenient Tests/FunctionalTests Tests/TestHelpers\n else\n echo \"warning: SwiftLint is not installed, please run the pod install command from the project root directory.\"\n fi\nelse\n echo \"Skipping linting build phase as ADB_SKIP_LINT flag is YES.\"\nfi\n"; + }; + 2E0A1C3A299703D00099C134 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -z ${ADB_SKIP_LINT} || ${ADB_SKIP_LINT} -ne \"YES\" ]]; then\n if which \"${PODS_ROOT}/SwiftLint/swiftlint\" >/dev/null; then\n ./Pods/SwiftLint/swiftlint lint --config Tests/.swiftlint.yml --lenient Tests/UnitTests Tests/TestHelpers\n else\n echo \"warning: SwiftLint is not installed, please run the pod install command from the project root directory.\"\n fi\nelse\n echo \"Skipping linting build phase as ADB_SKIP_LINT flag is YES.\"\nfi\n"; + }; + 3E694DE201739FFC9E121BF0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TestApptvOS/Pods-TestApptvOS-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TestApptvOS/Pods-TestApptvOS-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TestApptvOS/Pods-TestApptvOS-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 472839B3103353A04891290A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-UnitTests/Pods-UnitTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-UnitTests/Pods-UnitTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-UnitTests/Pods-UnitTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 7F15104583162CE8042F3F36 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-UnitTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8DF7E062244A3EDD0EB66537 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-FunctionalTests/Pods-FunctionalTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-FunctionalTests/Pods-FunctionalTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-FunctionalTests/Pods-FunctionalTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9683F02A687D7AACFCB51DBC /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TestAppiOS/Pods-TestAppiOS-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TestAppiOS/Pods-TestAppiOS-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TestAppiOS/Pods-TestAppiOS-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + A08AA549A28B81760B16CFCF /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-TestAppiOS-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + ADF098F0DCC56C4FEA1FEC9A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-FunctionalTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F0592369EDD5090D965157D7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-IntegrationTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F0683CFAC689D589DD88A6E3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-IntegrationTests/Pods-IntegrationTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-IntegrationTests/Pods-IntegrationTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-IntegrationTests/Pods-IntegrationTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + F286BB449BFF33C5BEF2BBF3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-TestApptvOS-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F43D54211EACB4B05B3310EA /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-AEPEdgeMedia-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2E4D75022979E02E00396819 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E4D75042979E02E00396819 /* XDMDataHelper.swift in Sources */, + 2E4D75062979E02E00396819 /* TestUtils.swift in Sources */, + 2E4D75072979E02E00396819 /* XDMData+Equatable.swift in Sources */, + 2E4D75092979E02E00396819 /* InstrumentedExtension.swift in Sources */, + 2E4D750A2979E02E00396819 /* FunctionalTestNetworkService.swift in Sources */, + 2E4D750E2979E02E00396819 /* FunctionalTestConstant.swift in Sources */, + 2E4D75102979E02E00396819 /* UserDefaults+Test.swift in Sources */, + 2E4D75122979E02E00396819 /* FileManager+Testable.swift in Sources */, + 2E4D75132979E02E00396819 /* FunctionalTestBase.swift in Sources */, + 2E4D75142979E02E00396819 /* CountDownLatch.swift in Sources */, + 2E4D75162979E02E00396819 /* XDMData+Comparable.swift in Sources */, + 2E4D75172979E02E00396819 /* EdgeEventHelper.swift in Sources */, + 2E4D75192979E02E00396819 /* EventHub+Test.swift in Sources */, + 2E4D751B2979E02E00396819 /* Media+Edge+EdgeIdentityFunctionalTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040722888B0D200306323 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E37D34128CFE44400B782F8 /* MediaRealTimeSession.swift in Sources */, + 2EDFBD022899E30800D22B25 /* Media+PublicAPI.swift in Sources */, + 2E3BF82B28D935370043DD00 /* MediaXDMEvent.swift in Sources */, + 2E58A94328BEB403004A9FA5 /* XDMStreamType.swift in Sources */, + 2EDFBD012899E30500D22B25 /* MediaType.swift in Sources */, + 2EDFBD002899E30200D22B25 /* MediaConstants+Public.swift in Sources */, + 2E58A93E28BEB403004A9FA5 /* XDMAdvertisingPodDetails.swift in Sources */, + 2E58A94728BEB403004A9FA5 /* XDMAdvertisingDetails.swift in Sources */, + 2EDFBD132899E3DF00D22B25 /* QoEInfo.swift in Sources */, + 2EDFBD0F2899E3DF00D22B25 /* StateInfo.swift in Sources */, + 2E58A93928BEB403004A9FA5 /* XDMErrorDetails.swift in Sources */, + 2E58A93F28BEB403004A9FA5 /* XDMSessionDetails.swift in Sources */, + 2E37D34528D12A3500B782F8 /* MediaXDMEventHelper.swift in Sources */, + 2EB040FB2894AE0600306323 /* Media.swift in Sources */, + 2E37D33D28CFDD7800B782F8 /* MediaEventProcessor.swift in Sources */, + 2E37D33F28CFDDF900B782F8 /* MediaSession.swift in Sources */, + 2EB040FC2894AE0600306323 /* Double+Media.swift in Sources */, + 2EB164F0289AF56400089C83 /* MediaPublicTracker.swift in Sources */, + 2E37D38528D290CE00B782F8 /* MediaEventProcessing.swift in Sources */, + 2ED7126528ADD665006A83D0 /* MediaState.swift in Sources */, + 2EDFBD102899E3DF00D22B25 /* AdInfo.swift in Sources */, + 2EB040FE2894AE0600306323 /* MediaConstants.swift in Sources */, + 2ED7125B28ADBEEC006A83D0 /* MediaContext.swift in Sources */, + 2EB164EF289AF55800089C83 /* MediaTracker.swift in Sources */, + 2EDFBD142899E3DF00D22B25 /* ChapterInfo.swift in Sources */, + 2E37D34328D123BE00B782F8 /* MediaXDMEventGenerator.swift in Sources */, + 2ED7125828ADA958006A83D0 /* MediaEventTracker.swift in Sources */, + 2E58A93D28BEB403004A9FA5 /* XDMQoeDataDetails.swift in Sources */, + 2E3BF83528DBB59C0043DD00 /* XDMCustomMetadata.swift in Sources */, + 2E58A94028BEB403004A9FA5 /* XDMChapterDetails.swift in Sources */, + 2EB040F72894AE0600306323 /* Event+Media.swift in Sources */, + 2ED7125728ADA958006A83D0 /* MediaRule.swift in Sources */, + 2E37D38D28D54B2900B782F8 /* XDMMediaEventType.swift in Sources */, + 2EB164EE289AF3B800089C83 /* MediaEventTracking.swift in Sources */, + 2EDFBD122899E3DF00D22B25 /* AdBreakInfo.swift in Sources */, + 2ED7125928ADA958006A83D0 /* MediaRuleEngine.swift in Sources */, + 2E58A93B28BEB403004A9FA5 /* XDMMediaCollection.swift in Sources */, + 2E58A94128BEB403004A9FA5 /* XDMPlayerStateData.swift in Sources */, + 2EDFBD112899E3DF00D22B25 /* MediaInfo.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB0407C2888B0D200306323 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E4D8173293954F6005A4543 /* FakeMediaEventProcessor.swift in Sources */, + 2EC3C49529B98CFF00B4308B /* TestableExtensionRuntime.swift in Sources */, + 2EA7BC2928C04796001A7C2A /* XDMChapterDetailsTests.swift in Sources */, + 2E459866290B7144003111EE /* MediaRealTimeSessionTests.swift in Sources */, + 2E37D38828D416C100B782F8 /* MediaEventProcessorTests.swift in Sources */, + 2EDFBCF42899E06300D22B25 /* Media+PublicAPITests.swift in Sources */, + 2EA7BC2F28C13924001A7C2A /* XDMMediaCollectionTests.swift in Sources */, + 2EEA08E7291DD51100043C43 /* XDMData+Comparable.swift in Sources */, + 2EA7BC2728C04675001A7C2A /* XDMErrorDetailsTests.swift in Sources */, + 2E0A1C3C2997107F0099C134 /* MediaContextTests.swift in Sources */, + 2EDFBCF52899E06300D22B25 /* MediaObjectTests.swift in Sources */, + 2E58A94928BEB523004A9FA5 /* XDMAdvertisingDetailsTests.swift in Sources */, + 2EA1FB47291DAC9000C4FFFE /* XDMData+Equatable.swift in Sources */, + 2EB164F7289AF6B900089C83 /* TestHelpers.swift in Sources */, + 2E3BF83028D961440043DD00 /* MediaXDMEventGeneratorTests.swift in Sources */, + 2EC6027228EB797B00C07D5A /* AssertUtils.swift in Sources */, + 2EA7BC2328C02F79001A7C2A /* TestUtils.swift in Sources */, + 2EC3C49329B98C6900B4308B /* MediaTests.swift in Sources */, + 2EDFBCF62899E06300D22B25 /* MediaPublicTrackerTests.swift in Sources */, + 2ED7126928ADDFC0006A83D0 /* TestConstants.swift in Sources */, + 2EC6027028EB589600C07D5A /* MediaXDMEventHelperTests.swift in Sources */, + 2ED7126728ADDF5B006A83D0 /* MediaStateTests.swift in Sources */, + 2EB164F5289AF63900089C83 /* MockExtension.swift in Sources */, + 2EB164F3289AF59C00089C83 /* MediaEventGenerator.swift in Sources */, + 2ED7126028ADC424006A83D0 /* MediaRuleEngineTests.swift in Sources */, + 2ED7126128ADC424006A83D0 /* MediaEventTrackerTests.swift in Sources */, + 2EA7BC2D28C11CD4001A7C2A /* XDMSessionDetailsTests.swift in Sources */, + 2E459864290A2EEC003111EE /* MediaXDMEventTests.swift in Sources */, + 2EA7BC2528C035FF001A7C2A /* XDMAdvertisingPodDetailsTests.swift in Sources */, + 2E37D38B28D4186000B782F8 /* MediaSessionSpy.swift in Sources */, + 2E459868290B8585003111EE /* XDMDataHelper.swift in Sources */, + 2EC3C49729B98D2800B4308B /* MockDataStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040A42888B46400306323 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E4D816F2935CA5B005A4543 /* XDMDataHelper.swift in Sources */, + 2E224FCC2971EC29005FB095 /* TestUtils.swift in Sources */, + 2E4D752C2979E34E00396819 /* Timeout.swift in Sources */, + 2E4D752A2979E34E00396819 /* ChapterPlayback.swift in Sources */, + 2E4D75272979E34600396819 /* BaseScenarioTest.swift in Sources */, + 2E4B4A9A29838CB900638DE7 /* CustomPingDuration.swift in Sources */, + 2E4D75282979E34E00396819 /* CustomStatePlayback.swift in Sources */, + 2EA1FB48291DAC9000C4FFFE /* XDMData+Equatable.swift in Sources */, + 2E4D75292979E34E00396819 /* CustomError.swift in Sources */, + 2E4D816E29357BEC005A4543 /* MediaEventGenerator.swift in Sources */, + 2E4D816D29357BDE005A4543 /* MediaEventProcessorSpy.swift in Sources */, + 2E4D75262979E34100396819 /* AdPlayback.swift in Sources */, + 2EEA08E8291DD51100043C43 /* XDMData+Comparable.swift in Sources */, + 2E4D81712936942F005A4543 /* EdgeEventHelper.swift in Sources */, + 2E4D752B2979E34E00396819 /* SimplePlayback.swift in Sources */, + 2EAD52F5296F90C60099D82B /* AdChapterPlayback.swift in Sources */, + 2E4D752D2979E34E00396819 /* SpecialAdPlayback.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040B02889CD0500306323 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E19666628B8220900298FD4 /* AppDelegate.swift in Sources */, + 2ED7127328B444C8006A83D0 /* VideoPlayer.swift in Sources */, + 2EB040EB2894A50D00306323 /* ContentView.swift in Sources */, + 2E19666C28B824C300298FD4 /* AssuranceView.swift in Sources */, + 2ED7126F28B4445E006A83D0 /* MediaAnalyticsProvider.swift in Sources */, + 2EE03B1128B7FF0F00176FF8 /* VideoPlayerView.swift in Sources */, + 2E19666928B8236100298FD4 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2EB040C92889CD2D00306323 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2E19666728B8220900298FD4 /* AppDelegate.swift in Sources */, + 2ED7127428B444C8006A83D0 /* VideoPlayer.swift in Sources */, + 2EB040EC2894A50E00306323 /* ContentView.swift in Sources */, + 2E19666D28B824C300298FD4 /* AssuranceView.swift in Sources */, + 2ED7127028B4445E006A83D0 /* MediaAnalyticsProvider.swift in Sources */, + 2EE03B1228B7FF0F00176FF8 /* VideoPlayerView.swift in Sources */, + 2E19666A28B8236100298FD4 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2E4D74FF2979E02E00396819 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2EB040752888B0D200306323 /* AEPEdgeMedia */; + targetProxy = 2E4D75002979E02E00396819 /* PBXContainerItemProxy */; + }; + 2EB040832888B0D200306323 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2EB040752888B0D200306323 /* AEPEdgeMedia */; + targetProxy = 2EB040822888B0D200306323 /* PBXContainerItemProxy */; + }; + 2EB040A22888B46400306323 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2EB040752888B0D200306323 /* AEPEdgeMedia */; + targetProxy = 2EB040A32888B46400306323 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2E4D75222979E02E00396819 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A5E55E991B358F0401D9C708 /* Pods-IntegrationTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.AEPEdgeMediaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 2E4D75232979E02E00396819 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 02892AE8A4A0629382CD7B94 /* Pods-IntegrationTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.AEPEdgeMediaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 2EB040882888B0D200306323 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvsimulator appletvos"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 2EB040892888B0D200306323 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvsimulator appletvos"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 2EB0408B2888B0D200306323 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9174C24A2D1A573EEDD8D92C /* Pods-AEPEdgeMedia.debug.xcconfig */; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = "1.0.0-beta"; + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvsimulator appletvos"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 2EB0408C2888B0D200306323 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0B55F129F98146BCD54F342D /* Pods-AEPEdgeMedia.release.xcconfig */; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = "1.0.0-beta"; + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvsimulator appletvos"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 2EB0408E2888B0D200306323 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 08C6BFDAC0AFE7593216FAE5 /* Pods-UnitTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.AEPEdgeMediaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 2EB0408F2888B0D200306323 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 02D1FDF0F82BB698F7852D94 /* Pods-UnitTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.AEPEdgeMediaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 2EB040A92888B46400306323 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 636D6196D3D501CA7C1FC98A /* Pods-FunctionalTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.AEPEdgeMediaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 2EB040AA2888B46400306323 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D676E12007639419F2E15AD7 /* Pods-FunctionalTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.AEPEdgeMediaTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphonesimulator iphoneos appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + 2EB040C32889CD0600306323 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 569F85822F154D411B4ADEC4 /* Pods-TestAppiOS.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TestApps/TestApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.TestApp; + PRODUCT_NAME = EdgeMediaTestApp; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TVOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 2EB040C42889CD0600306323 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E63FB88ECE18EDA538774428 /* Pods-TestAppiOS.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TestApps/TestApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.TestApp; + PRODUCT_NAME = EdgeMediaTestApp; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TVOS_DEPLOYMENT_TARGET = 10.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 2EB040D02889CD2D00306323 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C34C0809714D8E227F8E454 /* Pods-TestApptvOS.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TestApps/TestApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.TestApp; + PRODUCT_NAME = EdgeMediaTestApp; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvsimulator appletvos"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 13.0; + }; + name = Debug; + }; + 2EB040D12889CD2D00306323 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 2BA2F8686E9727B9638C7741 /* Pods-TestApptvOS.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TestApps/TestApp/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.adobe.aep.edge.media.TestApp; + PRODUCT_NAME = EdgeMediaTestApp; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvsimulator appletvos"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 13.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2E4D75212979E02E00396819 /* Build configuration list for PBXNativeTarget "IntegrationTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2E4D75222979E02E00396819 /* Debug */, + 2E4D75232979E02E00396819 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2EB040702888B0D200306323 /* Build configuration list for PBXProject "AEPEdgeMedia" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EB040882888B0D200306323 /* Debug */, + 2EB040892888B0D200306323 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2EB0408A2888B0D200306323 /* Build configuration list for PBXNativeTarget "AEPEdgeMedia" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EB0408B2888B0D200306323 /* Debug */, + 2EB0408C2888B0D200306323 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2EB0408D2888B0D200306323 /* Build configuration list for PBXNativeTarget "UnitTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EB0408E2888B0D200306323 /* Debug */, + 2EB0408F2888B0D200306323 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2EB040A82888B46400306323 /* Build configuration list for PBXNativeTarget "FunctionalTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EB040A92888B46400306323 /* Debug */, + 2EB040AA2888B46400306323 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2EB040C22889CD0600306323 /* Build configuration list for PBXNativeTarget "TestAppiOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EB040C32889CD0600306323 /* Debug */, + 2EB040C42889CD0600306323 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2EB040CF2889CD2D00306323 /* Build configuration list for PBXNativeTarget "TestApptvOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EB040D02889CD2D00306323 /* Debug */, + 2EB040D12889CD2D00306323 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 2EB0406D2888B0D200306323 /* Project object */; +} diff --git a/AEPEdgeMedia.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/AEPEdgeMedia.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/AEPEdgeMedia.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/AEPEdgeMedia.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/AEPEdgeMedia.xcscheme b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/AEPEdgeMedia.xcscheme new file mode 100644 index 0000000..c518df1 --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/AEPEdgeMedia.xcscheme @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/FunctionalTests.xcscheme b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/FunctionalTests.xcscheme new file mode 100644 index 0000000..b16826f --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/FunctionalTests.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/IntegrationTests.xcscheme b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/IntegrationTests.xcscheme new file mode 100644 index 0000000..1ab7ba1 --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/IntegrationTests.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/TestAppiOS.xcscheme b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/TestAppiOS.xcscheme new file mode 100644 index 0000000..7658a3e --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/TestAppiOS.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/TestApptvOS.xcscheme b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/TestApptvOS.xcscheme new file mode 100644 index 0000000..6064972 --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/TestApptvOS.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/UnitTests.xcscheme b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/UnitTests.xcscheme new file mode 100644 index 0000000..944477f --- /dev/null +++ b/AEPEdgeMedia.xcodeproj/xcshareddata/xcschemes/UnitTests.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AEPEdgeMedia.xcworkspace/contents.xcworkspacedata b/AEPEdgeMedia.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..da884a8 --- /dev/null +++ b/AEPEdgeMedia.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/AEPEdgeMedia.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/AEPEdgeMedia.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/AEPEdgeMedia.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100755 index 0000000..a8e3ed4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Adobe Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contribute to a positive environment for our project and community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best, not just for us as individuals but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others’ private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies when an individual is representing the project or its community both within project spaces and in public spaces. Examples of representing a project or community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by first contacting the project team. Oversight of Adobe projects is handled by the Adobe Open Source Office, which has final say in any violations and enforcement of this Code of Conduct and can be reached at Grp-opensourceoffice@adobe.com. All complaints will be reviewed and investigated promptly and fairly. + +The project team must respect the privacy and security of the reporter of any incident. + +Project maintainers who do not follow or enforce the Code of Conduct may face temporary or permanent repercussions as determined by other members of the project's leadership or the Adobe Open Source Office. + +## Enforcement Guidelines + +Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem to be in violation of this Code of Conduct: + +**1. Correction** + +Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +Consequence: A private, written warning from project maintainers describing the violation and why the behavior was unacceptable. A public apology may be requested from the violator before any further involvement in the project by violator. + +**2. Warning** + +Community Impact: A relatively minor violation through a single incident or series of actions. + +Consequence: A written warning from project maintainers that includes stated consequences for continued unacceptable behavior. Violator must refrain from interacting with the people involved for a specified period of time as determined by the project maintainers, including, but not limited to, unsolicited interaction with those enforcing the Code of Conduct through channels such as community spaces and social media. Continued violations may lead to a temporary or permanent ban. + +**3. Temporary Ban** + +Community Impact: A more serious violation of community standards, including sustained unacceptable behavior. + +Consequence: A temporary ban from any interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Failure to comply with the temporary ban may lead to a permanent ban. + +**4. Permanent Ban** + +Community Impact: Demonstrating a consistent pattern of violation of community standards or an egregious violation of community standards, including, but not limited to, sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +Consequence: A permanent ban from any interaction with the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, +available at [https://contributor-covenant.org/version/2/1][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/2/1 diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100755 index 0000000..f071e0c --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,5 @@ +© Copyright 2015-2022 Adobe. All rights reserved. + +Adobe holds the copyright for all the files found in this repository. + +See the LICENSE file for licensing information. diff --git a/Documentation/api-reference.md b/Documentation/api-reference.md new file mode 100644 index 0000000..757daf9 --- /dev/null +++ b/Documentation/api-reference.md @@ -0,0 +1,1284 @@ +# Adobe Experience Platform Media for Edge Network Extension - iOS + +## Prerequisites + +To set up the extension and start using these APIs, see the [Getting Started Guide](getting-started.md). + +## API reference + +| APIs | +| ----------------------------------------------------- | +| [extensionVersion](#extensionVersion) | +| [registerExtension](#registerExtension) | +| [createTracker](#createTracker) | +| [createTrackerWithConfig](#createTrackerWithConfig) | +| [createMediaObjectWith](#createMediaObjectWith) | +| [createAdBreakObjectWith](#createAdBreakObjectWith) | +| [createAdObjectWith](#createAdObjectWith) | +| [createChapterObjectWith](#createChapterObjectWith) | +| [createQoEObjectWith](#createQoEObjectWith) | +| [createStateObjectWith](#createStateObjectWith) | + +## Media Tracker API reference + +| APIs | +| ----------------------------------------------------- | +| [trackSessionStart](#trackSessionStart) | +| [trackPlay](#trackPlay) | +| [trackPause](#trackPause) | +| [trackComplete](#trackComplete) | +| [trackSessionEnd](#trackSessionEnd) | +| [trackError](#trackError) | +| [trackEvent](#trackEvent) | +| [updateCurrentPlayhead](#updateCurrentPlayhead) | +| [updateQoEObject](#updateQoEObject) | + +------ + +### extensionVersion + +The extensionVersion API returns the version of the Media for Edge Network extension. + +#### Swift + +##### Syntax +```swift +static var extensionVersion: String +``` + +##### Example +```swift +let extensionVersion = EdgeMedia.extensionVersion +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (nonnull NSString*) extensionVersion; +``` + +##### Example +```objectivec +NSString *extensionVersion = [AEPMobileEdgeMedia extensionVersion]; +``` +------ + +### registerExtension + +Registers the Media for Edge Network extension with the Mobile Core extension. + +The extension registration occurs by passing the Media for Edge Network extension to the [MobileCore.registerExtensions](https://developer.adobe.com/client-sdks/documentation/mobile-core/api-reference/#registerextensions) API. + +#### Swift + +##### Syntax +```swift +static func registerExtensions(_ extensions: [NSObject.Type], + _ completion: (() -> Void)? = nil) +``` + +##### Example +```swift +import AEPEdgeMedia + +... +MobileCore.registerExtensions([Media.self]) +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (void) registerExtensions: (NSArray* _Nonnull) extensions + completion: (void (^ _Nullable)(void)) completion; +``` + +##### Example +```objectivec +@import AEPEdgeMedia; + +... +[AEPMobileCore registerExtensions:@[AEPMobileEdgeMedia.class] completion:nil]; +``` + +------ + +### createTracker + +Creates a media tracker instance that tracks the playback session. The created tracker should be used to track the streaming content and it sends periodic pings to the Media Collection Service. + + +#### Swift + +##### Syntax +```swift +static func createTracker() +``` + +##### Example +```swift +let tracker = Media.createTracker() // Use the instance for tracking media playback session. +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (void) createTracker +``` + +##### Example +```objectivec +id tracker; +_tracker = [AEPMobileEdgeMedia createTracker]; // Use the instance for tracking media playback session. +``` + +------ + +### createTrackerWithConfig + +Creates a media tracker instance based on the provided configuration to track the playback session. + +| Key | Description | Value | Required | +| --- | --- | --- | --- | +| "config.channel" | The channel name for media. Set this to overwrite the channel name configured in the Data Collection UI for media tracked with this tracker instance. | String | No | +| "config.mainpinginterval" | Overwrites the default main content tracking interval `(in seconds)`. The value should be in the allowed range `[10-50] seconds`. The default value is 10 seconds. | Int | No | +| "config.adpinginterval" | Overwrites the default ad content tracking interval `(in seconds)`. The value should be in the allowed range `[1-10] seconds`. The default value is 10 seconds. | Int | No | + +#### Swift + +##### Syntax +```swift +static func createTrackerWith(config: [String: Any]?) +``` + +##### Example +```swift + +var config: [String: Any] = [:] +config[MediaConstants.TrackerConfig.CHANNEL] = "custom-channel" // Overwrites channel configured in the Data Collection UI. +​config[MediaConstants.TrackerConfig.AD_PING_INTERVAL] = 1 // Overwrites ad content ping interval to 1 second. +config[MediaConstants.TrackerConfig.MAIN_PING_INTERVAL] = 30 // Overwrites main content ping interval to 30 seconds. + +let tracker = Media.createTrackerWith(config: config) // Use the instance for tracking media playback session. +``` + +#### Objective-C + +##### Syntax +```objectivec ++(id _Nonnull) createTrackerWithConfig:(NSDictionary * _Nullable) +``` + +##### Example +```objectivec +id _tracker; +NSMutableDictionary* config = [NSMutableDictionary dictionary]; + +config[AEPMediaTrackerConfig.CHANNEL] = @"custom-channel"; // Overrides channel configured in the Data Collection UI + +_tracker = [AEPMobileEdgeMedia createTrackerWithConfig:config]; // Use the instance for tracking media playback session. +``` + +------ + +### createMediaObjectWith + +Creates an instance of the Media object which is a dictionary that contains information about the media. + +| Parameter | Description | Required | +| --- | --- | --- | +| name | The friendly name of the media | Yes | +| id | The unique identifier for the media | Yes | +| length | The length of the media in seconds | Yes | +| streamType | [StreamType](#streamtype) | Yes | +| mediaType | [MediaType](#mediatype) | Yes | + + +#### Swift + +##### Syntax +```swift +static func createMediaObjectWith(name: String, + id: String, + length: Double, + streamType: String, + mediaType: MediaType) -> [String: Any]? +``` + +##### Example +```swift +let mediaObject = Media.createMediaObjectWith(name: "video-name", + id: "videoId", + length: 60, + streamType: MediaConstants.StreamType.VOD, + mediaType: MediaType.Video) +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (NSDictionary * _Nullable) createMediaObjectWith:(NSString * _Nonnull) id:(NSString * _Nonnull) length:(double) streamType:(NSString * _Nonnull) mediaType:(enum AEPMediaType) +``` + +##### Example +```objectivec +NSDictionary *mediaObject = [AEPMobileEdgeMedia createMediaObjectWith:@"video-name" + id:@"video-id" + length:60 + streamType:AEPMediaStreamType.VOD + mediaType:AEPMediaTypeVideo]; +``` + +### createAdBreakObjectWith + +Creates an instance of the AdBreak object which is a dictionary that contains information about the ad break. + +| Parameter | Description | Required | +| --- | --- | --- | +| name | The friendly name of ad break such as pre-roll, mid-roll, and post-roll | Yes | +| position | The numeric position of the ad break within the content, starting with 1 | Yes | +| startTime | The playhead value in seconds at the start of the ad break | Yes | + +#### Swift + +##### Syntax +```swift +static func createAdBreakObjectWith(name: String, + position: Int, + startTime: Double) -> [String: Any]? +``` + +##### Example +```swift +let adBreakObject = Media.createAdBreakObjectWith(name: "adbreak-name", + position: 1, + startTime: 0) +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (NSDictionary * _Nullable) createAdBreakObjectWith:(NSString * _Nonnull)position:(NSInteger) startTime:(double) +``` + +##### Example +```objectivec +NSDictionary *adBreakObject = [AEPMobileEdgeMedia createAdBreakObjectWith:@"adbreak-name" + position:1 + startTime:0]; +``` + +### createAdObjectWith + +Creates an instance of the Ad object which is a dictionary that contains information about the ad. + +| Parameter | Description | Required | +| --- | --- | --- | +| name | The friendly name of the Ad | Yes | +| id | The unique identifier for the Ad | Yes | +| position | The numeric position of the Ad within the ad break, starting with 1 | Yes | +| length | The length of Ad in seconds | Yes | + +#### Swift + +##### Syntax +```swift +static func createAdObjectWith(name: String, + id: String, + position: Int, + length: Double) -> [String: Any]? +``` + +##### Example +```swift +let adObject = Media.createObjectWith(name: "ad-name", + id: "ad-id", + position: 0, + length: 30) +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (NSDictionary * _Nullable) createAdObjectWith: (NSString * _Nonnull + id:(NSString * _Nonnull) + position:(NSInteger) + length:(double) +``` + +##### Example +```objectivec +NSDictionary *adObject = [AEPMobileEdgeMedia createAdObjectWith:@"ad-name" + id:@"ad-id" + position:0 + length:30]; +``` + +### createChapterObjectWith + +Creates an instance of the Chapter object which is a dictionary that contains information about the chapter. + +| Parameter | Description | Required | +| --- | --- | --- | +| name | The friendly name of the Chapter | Yes | +| position | The numeric position of the Chapter within the content, starting with 1 | Yes | +| length | The length of Chapter in seconds | Yes | +| startTime | The playhead value at the start of the chapter | Yes | + +#### Swift + +##### Syntax +```swift +static func createChapterObjectWith(name: String, + position: Int, + length: Double, + startTime: Double) -> [String: Any]? +``` + +##### Example +```swift +let chapterObject = Media.createChapterObjectWith(name: "chapter_name", + position: 1, + length: 60, + startTime: 0) +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (NSDictionary * _Nullable) createChapterObjectWith:(NSString * _Nonnull) + position:(NSInteger) + length:(double) + startTime:(double) +``` + +##### Example +```objectivec +NSDictionary *chapterObject = [AEPMobileEdgeMedia createChapterObjectWith:@"chapter_name" + position:1 + length:60 + startTime:0]; +``` + +### createQoEObjectWith + +Creates an instance of the QoE (Quality of Experience) object which is a dictionary that contains information about the quality of experience. + +| Parameter | Description | Required | +| --- | --- | --- | +| bitrate | The bitrate of media in bits per second | Yes | +| startupTime | The start up time of media in seconds | Yes | +| fps | The current frames per second | Yes | +| droppedFrames | The number of dropped frames so far | Yes | + +> **Note** +> All the QoE values bitrate, startupTime, fps, droppedFrames would be converted to Int64 for reporting purposes. + +#### Swift + +##### Syntax +```swift +static func createQoEObjectWith(bitrate: Double, + startupTime: Double, + fps: Double, + droppedFrames: Double) -> [String: Any]? +``` + +##### Example +```swift +let qoeObject = Media.createQoEObjectWith(bitrate: 500000, + startupTime: 2, + fps: 24, + droppedFrames: 10) +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (NSDictionary * _Nullable) createQoEObjectWith:(double) + startTime:(double) + fps:(double) + droppedFrames:(double) +``` + +##### Example +```objectivec +NSDictionary *qoeObject = [AEPMobileEdgeMedia createQoEObjectWith:500000 + startTime:2 + fps:24 + droppedFrames:10]; +``` + +### createStateObjectWith +Creates an instance of the Player State object which is a dictionary that contains information about the player state. + +| Parameter | Description | Required | +| --- | --- | --- | +| name | The player state name. Use [Player State constants](#player-state-constants) to track standard player states | Yes | + +#### Swift + +##### Syntax +```swift +static func createStateObjectWith(stateName: String) -> [String: Any] +``` + +##### Example +```swift +let fullScreenState = Media.createStateObjectWith(stateName: "fullscreen") +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (NSDictionary * _Nullable) createStateObjectWith:(NSString * _Nonnull) +``` + +##### Example +```objectivec +NSDictionary* fullScreenState = [AEPMobileEdgeMedia createStateObjectWith:AEPMediaPlayerState.FULLSCREEN] +``` + + +## Media Tracker API Reference + +> **Note** +> The following APIs are **tracker instance** dependent. Please create tracker instance using [`createTracker`](#createTracker) or [`createTrackerWithConfig`](#createTrackerWithConfig) and call the following APIs. + +### trackSessionStart +Tracks the intention to start playback. This starts a tracking session on the media tracker instance. To resume a previously closed session, see the [media resume guide](#media-resume). + +| Parameter | Description | Required | +| --- | --- | --- | +| mediaInfo | Media information created using the [`createMediaObjectWith`](#createMediaObjectWith) method | Yes | +| contextData | Optional Media context data. For standard metadata keys, use [standard video constants](#standard-video-metadata-constants) or [standard audio constants](#standard-audio-metadata-constants). | No | + +#### Swift + +##### Syntax +```swift +public func trackSessionStart(info: [String: Any], metadata: [String: String]? = nil) +``` + +##### Example +```swift +let mediaObject = Media.createMediaObjectWith(name: "video-name", id: "videoId", length: 60, streamType: MediaConstants.StreamType.VOD, mediaType: MediaType.Video) + +var videoMetadata: [String: String] = [:] +// Sample implementation for using video standard metadata keys +videoMetadata[MediaConstants.VideoMetadataKeys.SHOW] = "Sample show" +videoMetadata[MediaConstants.VideoMetadataKeys.SEASON] = "Sample season" + +// Sample implementation for using custom metadata keys +videoMetadata["isUserLoggedIn"] = "false" +videoMetadata["tvStation"] = "Sample TV station" + +tracker.trackSessionStart(info: mediaObject, metadata: videoMetadata) +``` + +#### Objective-C + +##### Syntax +```objectivec ++ (void) trackSessionStart:(NSDictionary * _Nonnull) metadata:(NSDictionary * _Nullable) +``` + +##### Example +```objectivec +NSDictionary *mediaObject = [AEPMobileEdgeMedia createMediaObjectWith:@"video-name" id:@"video-id" length:60 streamType:AEPMediaStreamType.VOD mediaType:AEPMediaTypeVideo]; + +NSMutableDictionary *videoMetadata = [[NSMutableDictionary alloc] init]; +// Sample implementation for using standard video metadata keys +[videoMetadata setObject:@"Sample show" forKey:AEPVideoMetadataKeys.SHOW]; +[videoMetadata setObject:@"Sample Season" forKey:AEPVideoMetadataKeys.SEASON]; + +// Sample implementation for using custom metadata keys +[videoMetadata setObject:@"false" forKey:@"isUserLoggedIn"]; +[videoMetadata setObject:@"Sample TV station" forKey:@"tvStation"]; + +[_tracker trackSessionStart:mediaObject metadata:videoMetadata]; +``` + +### trackPlay +Tracks the media play, or resume, after a previous pause. + +#### Swift + +##### Syntax +```swift +func trackPlay() +``` + +##### Example +```swift +tracker.trackPlay() +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) trackPlay; +``` + +##### Example +```objectivec +[_tracker trackPlay]; +``` + +### trackPause +Tracks the media pause. + +#### Swift + +##### Syntax +```swift +func trackPause() +``` + +##### Example +```swift +tracker.trackPause() +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) trackPause +``` + +##### Example +```objectivec +[_tracker trackPause]; +``` + +### trackComplete +Tracks the completion of the media playback session. Call this method only when the media has been completely viewed. If the viewing session is ended before the media is completely viewed, use [`trackSessionEnd`](#trackSessionEnd) instead. + +#### Swift + +##### Syntax +```swift +func trackComplete() +``` + +##### Example +```swift +tracker.trackComplete() +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) trackComplete +``` + +##### Example +```objectivec +[_tracker trackComplete]; +``` + +### trackSessionEnd +Tracks the end of a media playback session. Call this method when the viewing session ends, even if the user has not viewed the media to completion. If the media is viewed to completion, use [`trackComplete`](#trackComplete) instead. + +#### Swift + +##### Syntax +```swift +func trackSessionEnd() +``` + +##### Example +```swift +tracker.trackSessionEnd() +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) trackSessionEnd +``` + +##### Example +```objectivec +[_tracker trackSessionEnd]; +``` + +### trackError +Tracks an error in media playback. + +| Parameter | Description | Required | +| --- | --- | --- | +| errorID | The custom error Identifier | Yes | + + +#### Swift + +##### Syntax +```swift +func trackError(errorId: String) +``` + +##### Example +```swift +tracker.trackError(errorId: "errorId") +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) trackError:(NSString * _Nonnull) +``` + +##### Example +```objectivec +[_tracker trackError:@"errorId"]; +``` + +### trackEvent +Tracks media events. + +| Parameter | Description | Required | +| --- | --- | --- | +| event | The media event being tracked, use [Media event constants](#media-events-constants) | Yes| +| info | For an `AdBreakStart` event, the AdBreak information is created by using the [`createAdBreakObjectWith`](#createAdBreakObjectWith) method.
For an `AdStart` event, the Ad information is created by using the [`createAdObjectWith`](#createAdObjectWith) method.
For a `ChapterStart` event, the Chapter information is created by using the [`createChapterObjectWith`](#createChapterObjectWith) method.
For a `StateStart` and `StateEnd` event, the State information is created by using the [`createStateObjectWith`](#createStateObjectWith) method. | Yes/No* | +| metadata | Optional context data can be provided for `AdStart` and `ChapterStart` events. This is not required for other events. | No | + +> **Note** +> * info is a required parameter for `AdBreakStart`, `AdStart`, `ChapterStart`, `StateStart`, `StateEnd` events. Not set for any other event types. + +#### Swift + +##### Syntax +```swift +func trackEvent(event: MediaEvent, info: [String: Any]?, metadata: [String: String]?) +``` + +##### Example +Tracking ad breaks +```swift +// AdBreakStart + let adBreakObject = Media.createAdBreakObjectWith(name: "adbreak-name", position: 1, startTime: 0) + tracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakObject, metadata: nil) + +// AdBreakComplete + tracker.trackEvent(event: MediaEvent.AdBreakComplete, info: nil, metadata: nil) +``` + +Tracking ads +```swift +// AdStart + let adObject = Media.createObjectWith(name: "adbreak-name", id: "ad-id", position: 0, length: 30) + +// Standard metadata keys provided by adobe. + var adMetadata: [String: String] = [:] + adMetadata[MediaConstants.AdMetadataKeys.ADVERTISER] = "Sample Advertiser" + adMetadata[MediaConstants.AdMetadataKeys.CAMPAIGN_ID] = "Sample Campaign" + +// Custom metadata keys + adMetadata["affiliate"] = "Sample affiliate" + + tracker.trackEvent(event: MediaEvent.AdStart, info: adObject, metadata: adMetadata) + +// AdComplete + tracker.trackEvent(event: MediaEvent.AdComplete, info: nil, metadata: nil) + +// AdSkip + tracker.trackEvent(event: MediaEvent.AdSkip, info: nil, metadata: nil) +``` + +Tracking chapters +```swift +// ChapterStart + let chapterObject = Media.createChapterObjectWith(name: "chapter_name", position: 1, length: 60, startTime: 0) + let chapterDictionary = ["segmentType": "Sample segment type"] + + tracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterObject, metadata: chapterDictionary) + +// ChapterComplete + tracker.trackEvent(event: MediaEvent.ChapterComplete, info: nil, metadata: nil) + +// ChapterSkip + tracker.trackEvent(event: MediaEvent.ChapterSkip, info: nil, metadata: nil) +``` + +Tracking player states +```swift +// StateStart + let fullScreenState = Media.createStateObjectWith(stateName: MediaConstants.PlayerState.FULLSCREEN) + tracker.trackEvent(event: MediaEvent.StateStart, info: fullScreenState, metadata: nil) + +// StateEnd + let fullScreenState = Media.createStateObjectWith(stateName: MediaConstants.PlayerState.FULLSCREEN) + tracker.trackEvent(event: MediaEvent.StateEnd, info: fullScreenState, metadata: nil) +``` + +Tracking playback events +```swift +// BufferStart + tracker.trackEvent(event: MediaEvent.BufferStart, info: nil, metadata: nil) + +// BufferComplete + tracker.trackEvent(event: MediaEvent.BufferComplete, info: nil, metadata: nil) + +// SeekStart + tracker.trackEvent(event: MediaEvent.SeekStart, info: nil, metadata: nil) + +// SeekComplete + tracker.trackEvent(event: MediaEvent.SeekComplete, info: nil, metadata: nil) +``` + +Tracking bitrate change +```swift +// If the new bitrate value is available provide it to the tracker. + let qoeObject = Media.createQoEObjectWith(bitrate: 500000, startupTime: 2, fps: 24, droppedFrames: 10) + tracker.updateQoEObject(qoeObject) + +// Bitrate change + tracker.trackEvent(event: MediaEvent.BitrateChange, info: nil, metadata: nil) +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) trackEvent:(enum AEPMediaEvent) info:(NSDictionary * _Nullable) metadata:(NSDictionary * _Nullable) +``` + +##### Example +Tracking ad breaks +```objectivec +// AdBreakStart + NSDictionary *adBreakObject = [AEPMobileMedia createAdBreakObjectWith:@"adbreak-name" position:1 startTime:0]; + [_tracker trackEvent:AEPMediaEventAdBreakStart info:adBreakObject metadata:nil]; + +// AdBreakComplete + [_tracker trackEvent:AEPMediaEventAdBreakComplete info:nil metadata:nil]; +``` + +Tracking ads +```objectivec +// AdStart + NSDictionary *adObject = [AEPMobileMedia createAdObjectWith:@"ad-name" id:@"ad-id" position:0 length:30]; + NSMutableDictionary* adMetadata = [[NSMutableDictionary alloc] init]; + +// Standard metadata keys provided by adobe. + [adMetadata setObject:@"Sample Advertiser" forKey:AEPAdMetadataKeys.ADVERTISER]; + [adMetadata setObject:@"Sample Campaign" forKey:AEPAdMetadataKeys.CAMPAIGN_ID]; + +// Custom metadata keys + [adMetadata setObject:@"Sample affiliate" forKey:@"affiliate"]; + + [_tracker trackEvent:AEPMediaEventAdStart info:adObject metadata:adMetadata]; + +// AdComplete + [_tracker trackEvent:AEPMediaEventAdComplete info:nil metadata:nil]; + +// AdSkip + [_tracker trackEvent:AEPMediaEventAdSkip info:nil metadata:nil]; +``` + +Tracking chapters +```objectivec +// ChapterStart + NSDictionary *chapterObject = [AEPMobileMedia createChapterObjectWith:@"chapter_name" position:1 length:60 startTime:0]; + + NSMutableDictionary *chapterMetadata = [[NSMutableDictionary alloc] init]; + [chapterMetadata setObject:@"Sample segment type" forKey:@"segmentType"]; + + [_tracker trackEvent:AEPMediaEventChapterStart info:chapterObject metadata:chapterMetadata]; + +// ChapterComplete + [_tracker trackEvent:AEPMediaEventChapterComplete info:nil metadata:nil]; + +// ChapterSkip + [_tracker trackEvent:AEPMediaEventChapterSkip info:nil metadata:nil]; +``` + +Tracking player states +```objectivec +// StateStart + NSDictionary* fullScreenState = [AEPMobileMedia createStateObjectWith:AEPMediaPlayerState.FULLSCREEN]; + [_tracker trackEvent:AEPMediaEventStateStart info:fullScreenState metadata:nil]; + +// StateEnd + NSDictionary* fullScreenState = [AEPMobileMedia createStateObjectWith:AEPMediaPlayerState.FULLSCREEN]; + [_tracker trackEvent:AEPMediaEventStateEnd info:fullScreenState metadata:nil]; +``` + +Tracking playback events +```objectivec +// BufferStart + [_tracker trackEvent:AEPMediaEventBufferStart info:nil metadata:nil]; + +// BufferComplete + [_tracker trackEvent:AEPMediaEventBufferComplete info:nil metadata:nil]; + +// SeekStart + [_tracker trackEvent:AEPMediaEventSeekStart info:nil metadata:nil]; + +// SeekComplete + [_tracker trackEvent:AEPMediaEventSeekComplete info:nil metadata:nil]; +``` + +Tracking bitrate change +```objectivec +// If the new bitrate value is available provide it to the tracker. + NSDictionary *qoeObject = [AEPMobileMedia createQoEObjectWith:50000 startTime:2 fps:24 droppedFrames:10]; + +// Bitrate change + [_tracker trackEvent:AEPMediaEventBitrateChange info:nil metadata:nil]; +``` + +### updateCurrentPlayhead + +Provides the current media playhead value to the media tracker instance. For accurate tracking, call this method every time the playhead value changes. If the player does not notify playhead value changes, call this method once every second with the most recent playhead value. + +| Parameter | Description | Required | +| --- | --- | --- | +| time | Current playhead value in seconds.

For video-on-demand (VOD), the value is specified in seconds from the beginning of the media item.

For live streaming, if the player does not provide information about the content duration, the value can be specified as the number of seconds since midnight UTC of that day.| Yes | + +> **Note** +> When using progress markers, the content duration is required and the playhead value needs to be updated as the number of seconds from the beginning of the media item, starting with 0. + +#### Swift + +##### Syntax +```swift +func updateCurrentPlayhead(time: Double) +``` + +##### Example +```swift +tracker.updateCurrentPlayhead(1); +``` + +Live streaming example +```swift +//Calculation for number of seconds since midnight UTC of the day +let secondsSince1970: TimeInterval = (Date().timeIntervalSince1970) +let timeFromMidnightInSecond = secondsSince1970.truncatingRemainder(dividingBy: 86400) + +tracker.updateCurrentPlayhead(time: timeFromMidnightInSecond) +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) updateCurrentPlayhead:(double) +``` + +##### Example +```objectivec +[_tracker updateCurrentPlayhead:1]; +``` + +### updateQoEObject +Provides the media tracker with the current Quality of Experience (QoE) information. For accurate tracking, call this method every time the media player provides the updated QoE information. + +| Parameter | Description | Required | +| --- | --- | --- | +| qoeObject | Current QoE information that was created by using the [`createQoEObjectWith`](#createQoEObjectWith) method. | Yes | + +#### Swift + +##### Syntax +```swift +func updateQoEObject(qoe: [String: Any]) +``` + +##### Example +```swift +let qoeObject = Media.createQoEObjectWith(bitrate: 500000, startupTime: 2, fps: 24, droppedFrames: 10) +tracker.updateQoEObject(qoe: qoeObject) +``` + +#### Objective-C + +##### Syntax +```objectivec +- (void) updateQoEObject:(NSDictionary * _Nonnull) +``` + +##### Example +```objectivec +NSDictionary *qoeObject = [AEPMobileMedia createQoEObjectWith:50000 startTime:2 fps:24 droppedFrames:10] +[_tracker updateQoEObject:qoeObject]; +``` + +## Media Constants + +### MediaType + +Defines the type of media that is currently being tracked. It can be either `MediaType.Video` or `MediaType.Audio`. + +##### Definition +```swift +@objc(AEPMediaType) +public enum MediaType: Int, RawRepresentable { + //Constant defining media type for Video streams + case Audio + //Constant defining media type for Audio streams + case Video +} +``` +#### Swift + +##### Example +```swift +var mediaObject = Media.createMediaObjectWith(name: "video-name", + id: "videoId", + length: "60", + streamType: MediaConstants.StreamType.VOD, + mediaType: MediaType.Video) +``` + +#### Objective-C + +##### Example +```objectivec + +NSDictionary *mediaObject = [AEPMobileMedia createMediaObjectWith:@"video-name" + id:@"video-id" + length:60 + streamType:AEPMediaStreamType.VOD + mediaType:AEPMediaTypeVideo]; +``` + +### StreamType + +Defines the type of streamed content that is currently being tracked. Use the available constants or custom defined stream type values. + +##### Definition +```swift + +public class MediaConstants: NSObject { + @objc(AEPMediaStreamType) + public class StreamType: NSObject { + // Constant defining stream type for VOD streams. + public static let VOD = "vod" + // Constant defining stream type for Live streams. + public static let LIVE = "live" + // Constant defining stream type for Linear streams. + public static let LINEAR = "linear" + // Constant defining stream type for Podcast streams. + public static let PODCAST = "podcast" + // Constant defining stream type for Audiobook streams. + public static let AUDIOBOOK = "audiobook" + // Constant defining stream type for AOD streams. + public static let AOD = "aod" + } +} +``` + +#### Swift + +##### Example +```swift +var mediaObject = Media.createMediaObjectWith(name: "video-name", + id: "videoId", + length: "60", + streamType: MediaConstants.StreamType.VOD, + mediaType: MediaType.Video) +``` + +#### Objective-C + +##### Example +```objectivec + +NSDictionary *mediaObject = [AEPMobileMedia createMediaObjectWith:@"video-name" + id:@"video-id" + length:60 + streamType:AEPMediaStreamType.VOD + mediaType:AEPMediaTypeVideo]; +``` + +### Player state constants +Defines the state of the media player that is currently being tracked. Use the available constant values or custom defined player state values. + +```swift +public class MediaConstants: NSObject { + @objc(AEPMediaPlayerState) + public class PlayerState: NSObject { + public static let FULLSCREEN = "fullscreen" + public static let PICTURE_IN_PICTURE = "pictureInPicture" + public static let CLOSED_CAPTION = "closeCaption" + public static let IN_FOCUS = "inFocus" + public static let MUTE = "mute" + } +} +``` +#### Swift + +##### Example +```swift +let inFocusState = Media.createStateObjectWith(stateName: MediaConstants.PlayerState.IN_FOCUS) +tracker.trackEvent(event: MediaEvent.StateStart, info: inFocusState, metadata: nil) +``` + +#### Objective-C + +##### Example +```objectivec +NSDictionary* inFocusState = [AEPMobileMedia createStateObjectWith:AEPMediaPlayerState.IN_FOCUS]; +[_tracker trackEvent:AEPMediaEventStateStart info:muteState metadata:nil]; +``` + +### Standard video metadata constants + +Defines the standard video constants used as keys when creating or modifying video metadata dictionaries. Use the available constant values or custom defined video metadata key values. + +```swift +public class MediaConstants: NSObject { + @objc(AEPVideoMetadataKeys) + public class VideoMetadataKeys: NSObject { + public static let AD_LOAD = "adLoad" + public static let ASSET_ID = "assetID" + public static let AUTHORIZED = "isAuthenticated" + public static let DAY_PART = "dayPart" + public static let EPISODE = "episode" + public static let FEED = "feed" + public static let FIRST_AIR_DATE = "firstAirDate" + public static let FIRST_DIGITAL_DATE = "firstDigitalDate" + public static let GENRE = "genre" + public static let MVPD = "mvpd" + public static let NETWORK = "network" + public static let ORIGINATOR = "originator" + public static let RATING = "rating" + public static let SEASON = "season" + public static let SHOW = "show" + public static let SHOW_TYPE = "showType" + public static let STREAM_FORMAT = "streamFormat" + } +} +``` + +#### Swift + +##### Example +```swift +var mediaObject = Media.createMediaObjectWith(name: "video-name", id: "videoId", length: "60", streamType: MediaConstants.StreamType.VOD, mediaType: MediaType.Video) + +var videoMetadata: [String: String] = [:] +// Standard Video Metadata +videoMetadata[MediaConstants.VideoMetadataKeys.SHOW] = "Sample show" +videoMetadata[MediaConstants.VideoMetadataKeys.SEASON] = "Sample season" + +tracker.trackSessionStart(info: mediaObject, metadata: videoMetadata) +``` + +#### Objective-C + +##### Example +```objectivec +NSDictionary *mediaObject = [AEPMobileEdgeMedia createMediaObjectWith:@"video-name" id:@"video-id" length:60 streamType:AEPMediaStreamType.VOD mediaType:AEPMediaTypeVideo]; + +NSMutableDictionary *videoMetadata = [[NSMutableDictionary alloc] init]; +// Standard Video Metadata +[videoMetadata setObject:@"Sample show" forKey:AEPVideoMetadataKeys.SHOW]; +[videoMetadata setObject:@"Sample Season" forKey:AEPVideoMetadataKeys.SEASON]; + +[_tracker trackSessionStart:mediaObject metadata:videoMetadata]; +``` + +### Standard audio metadata constants + +Defines the standard audio constants used as keys when creating or modifying audio metadata dictionaries. Use the available constant values or custom defined audio metadata key values. + +```swift +public class MediaConstants: NSObject { + @objc(AEPAudioMetadataKeys) + public class AudioMetadataKeys: NSObject { + public static let ALBUM = "album" + public static let ARTIST = "artist" + public static let AUTHOR = "author" + public static let LABEL = "label" + public static let PUBLISHER = "publisher" + public static let STATION = "station" + } +} +``` + +#### Swift + +##### Example +```swift +var audioObject = Media.createMediaObjectWith(name: "audio-name", id: "audioId", length: 30, streamType: MediaConstants.StreamType.AOD, mediaType: MediaType.AUDIO) + +var audioMetadata: [String: String] = [:] +// Standard Audio Metadata +audioMetadata[MediaConstants.AudioMetadataKeys.ARTIST] = "Sample artist" +audioMetadata[MediaConstants.AudioMetadataKeys.ALBUM] = "Sample album" + +tracker.trackSessionStart(info: audioObject, metadata: audioMetadata) +``` + +#### Objective-C + +##### Example +```objectivec +NSDictionary *audioObject = [AEPMobileMedia createMediaObjectWith:@"audio-name" id:@"audioid" length:30 streamType:AEPMediaStreamType.AOD mediaType:AEPMediaTypeAudio]; + +NSMutableDictionary *audioMetadata = [[NSMutableDictionary alloc] init]; +// Standard Audio Metadata +[audioMetadata setObject:@"Sample artist" forKey:AEPAudioMetadataKeys.ARTIST]; +[audioMetadata setObject:@"Sample album" forKey:AEPAudioMetadataKeys.ALBUM]; + +[_tracker trackSessionStart:audioObject metadata:audioMetadata]; +``` + +### Standard ad metadata constants + +Defines the standard ad metadata constants used as keys when creating or modifying ad metadata dictionaries. Use the available constant values or custom defined ad metadata key values. + +```swift +public class MediaConstants: NSObject { + @objc(AEPAdMetadataKeys) + public class AdMetadataKeys: NSObject { + public static let ADVERTISER = "advertiser" + public static let CAMPAIGN_ID = "campaignID" + public static let CREATIVE_ID = "creativeID" + public static let CREATIVE_URL = "creativeURL" + public static let PLACEMENT_ID = "placementID" + public static let SITE_ID = "siteID" + } +} + +``` + +#### Swift + +##### Example +```swift +let adObject = Media.createAdObjectWith(name: "ad-name", id: "ad-id", position: 0, length: 30) +var adMetadata: [String: String] = [:] +// Standard Ad Metadata +adMetadata[MediaConstants.AdMetadataKeys.ADVERTISER] = "Sample Advertiser" +adMetadata[MediaConstants.AdMetadataKeys.CAMPAIGN_ID] = "Sample Campaign" + +tracker.trackEvent(event: MediaEvent.AdStart, info: adObject, metadata: adMetadata) +``` + +#### Objective-C + +##### Example +```objectivec +NSDictionary *adObject = [AEPMobileEdgeMedia createAdObjectWith:@"ad-name" id:@"ad-id" position:0 length:30]; + +NSMutableDictionary *adMetadata = [[NSMutableDictionary alloc] init]; +// Standard Ad Metadata +[adMetadata setObject:@"Sample Advertiser" forKey:AEPAdMetadataKeys.ADVERTISER]; +[adMetadata setObject:@"Sample Campaign" forKey:AEPAdMetadataKeys.CAMPAIGN_ID]; + +[_tracker trackEvent:AEPMediaEventAdStart info:adObject metadata:adMetadata]; +``` + +### Media event constants + +Defines the media event that is currently being tracked. Only the available constant values are allowed. + +```swift +@objc(AEPMediaEvent) +public enum MediaEvent: Int, RawRepresentable { + // event type for AdBreak start + case AdBreakStart + // event type for AdBreak Complete + case AdBreakComplete + // event type for Ad Start + case AdStart + // event type for Ad Complete + case AdComplete + // event type for Ad Skip + case AdSkip + // event type for Chapter Start + case ChapterStart + // event type for Chapter Complete + case ChapterComplete + // event type for Chapter Skip + case ChapterSkip + // event type for Seek Start + case SeekStart + // event type for Seek Complete + case SeekComplete + // event type for Buffer Start + case BufferStart + // event type for Buffer Complete + case BufferComplete + // event type for change in Bitrate + case BitrateChange + // event type for Player State Start + case StateStart + // event type for Player State End + case StateEnd +} +``` + +#### Swift + +##### Example +```swift +tracker.trackEvent(event: MediaEvent.BitrateChange, info: nil, metadata: nil) +``` + +#### Objective-C + +##### Example +```objectivec +[_tracker trackEvent:AEPMediaEventBitrateChange info:nil metadata:nil]; +``` + +### Media resume +Constant to denote that the current tracking session is resuming a previously closed session. This information must be provided when starting a tracking session. + +#### Swift + +##### Syntax +```swift +public class MediaConstants: NSObject { + @objc(AEPMediaObjectKey) + public class MediaObjectKey: NSObject { + public static let RESUMED = "media.resumed" + } +} +``` + +##### Example +```swift +var mediaObject = Media.createMediaObjectWith(name: "video-name", id: "videoId", length: "60", streamType: MediaConstants.StreamType.VOD, mediaType: MediaType.Video) +mediaObject[MediaConstants.MediaObjectKey.RESUMED] = true + +tracker.trackSessionStart(info: mediaObject, metadata: nil) +``` + +#### Objective-C + +##### Syntax +```objectivec +@interface AEPMediaObjectKey : NSObject ++ (NSString * _Nonnull)RESUMED +``` + +##### Example +```objectivec +NSDictionary *mediaObject = [AEPMobileMedia createMediaObjectWith:@"video-name" id:@"video-id" length:60 streamType:AEPMediaStreamType.VOD mediaType:AEPMediaTypeVideo]; + +// Attach media resumed information. +NSMutableDictionary *obj = [mediaObject mutableCopy]; +[obj setObject:@YES forKey:AEPMediaObjectKey.RESUMED]; + +[_tracker trackSessionStart:obj metadata:nil]; +``` diff --git a/Documentation/getting-started.md b/Documentation/getting-started.md new file mode 100644 index 0000000..e70a822 --- /dev/null +++ b/Documentation/getting-started.md @@ -0,0 +1,156 @@ +## Getting started + +The Adobe Experience Platform Media for Edge Network mobile extension has the following dependencies, which must be installed prior to installing the extension: +- [AEPCore](https://github.com/adobe/aepsdk-core-ios) +- [AEPEdge](https://github.com/adobe/aepsdk-edge-ios) +- [AEPEdgeIdentity](https://github.com/adobe/aepsdk-edgeidentity-ios) + +## Configuration + +### Configure Dependencies +Configure the Edge, EdgeIdentity extensions in the mobile property using the Data Collection UI. + +> **Note** +> If this is your first time setting up Edge extensions and using Data Collection UI, please follow this [tutorial](https://github.com/adobe/aepsdk-edge-ios/tree/main/Documentation/Tutorials) to learn about Adobe Experience Platform and how to setup required schemas, datasets, datastreams and creating mobile property etc. + +---- + +### Configure AEPEdgeMedia extension +Currently AEPEdgeMedia doesn't have a Data Collection extension and needs to be configured programmatically. + +#### Configuration Keys +| Name | Key | Value | Required | +| --- | --- | --- | --- | +| Channel | "edgemedia.channel" | String | **Yes** | +| Player Name | "edgemedia.playerName" | String | **Yes** | +| Application Version | "edgemedia.appVersion" | String | **No** | + +##### Swift +```swift +let mediaConfiguration = [String: Any]() +mediaConfiguration ["edgemedia.channel"] = "" +mediaConfiguration ["edgemedia.playerName"] = "" +mediaConfiguration ["edgemedia.appVersion"] = "" + +MobileCore.updateConfigurationWith(configDict: mediaConfiguration) + ``` + +##### Objective-C +```objectivec +NSMutableDictionary* mediaConfiguration = [NSMutableDictionary dictionary]; +config["edgemedia.channel"] = @""; +config["edgemedia.playerName"] = @""; +config["edgemedia.appVersion"] = @""; + + [AEPMobileCore updateConfiguration:mediaConfiguration]; +``` +---- + +## Add the AEPEdgeMedia extension to your app + +### Download AEPEdgeMedia extension + +> **Note** +> The following instructions are for setting up an application using Adobe Experience Platform Edge Network mobile extensions. If an application will include both Edge Network and Adobe Solution extensions, both the Identity for Edge Network and Identity for Experience Cloud ID Service extensions are required. For more details, see the [Frequently Asked Questions](https://developer.adobe.com/client-sdks/documentation/identity-for-edge-network/faq/) page. + +#### Add the AEPEdgeMedia and other dependency extensions to your project: +> **Note** +> Try to use the [latest extension versions](https://developer.adobe.com/client-sdks/documentation/current-sdk-versions/#ios--swift) to have access to all our latest features and fixes. + +#### Using [Cocoapods]("https://cocoapods.org/") + +1. Add following pods in your `Podfile`: + + ```ruby + use_frameworks! + target 'YourTargetApp' do + pod 'AEPCore' + pod 'AEPEdge' + pod 'AEPEdgeIdentity' + pod 'AEPEdgeMedia', :git => 'https://github.com/adobe/aepsdk-edgemedia-ios.git', :tag => '1.0.0-beta' + end + ``` + +2. Replace the target (`YourTargetApp`) with your actual app target name. + +3. Install the pod dependencies by typing the following command in your Podfile directory: + ```bash + $ pod install + ``` + +#### Using [Swift Package Manager](https://github.com/apple/swift-package-manager) + +To add the AEPEdgeMedia Package to your application, from the Xcode menu select: + +`File > Add Packages...` + +> **Note** +> The menu options may vary depending on the version of Xcode being used. + +Enter the URL for the AEPMedia package repository: `https://github.com/adobe/aepsdk-edgemedia-ios.git`. + +When prompted, input a specific version or a range of versions for Version rule. + +Alternatively, if your project has a `Package.swift` file, you can add AEPEdgeMedia directly to your dependencies: + +``` +dependencies: [ + .package(url: "https://github.com/adobe/aepsdk-edge-ios.git", .upToNextMajor(from: "1.4.0")), + .package(url: "https://github.com/adobe/aepsdk-edgeidentity-ios.git", .upToNextMajor(from: "1.0.0")), + .package(url: "https://github.com/adobe/aepsdk-edgemedia-ios.git", .upToNextMajor(from: "1.0.0-beta")) +] +``` + +#### Using Binaries + +Run `make archive` from the root directory to generate `.xcframeworks` for each module under the `build` folder. Drag and drop all `.xcframeworks` to your app target in Xcode. + +---- + +### Import the AEPEdgeMedia along with the dependencies and register the extensions with `MobileCore`: + +#### Swift + ```swift + // AppDelegate.swift + import AEPCore + import AEPEdge + import AEPEdgeIdentity + import AEPEdgeMedia + ``` + + ```swift + // AppDelegate.swift + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + MobileCore.registerExtensions([Edge.self, Identity.self, Media.self], { + MobileCore.configureWith(appId: "yourEnvironmentID") + // Configure EdgeMedia extension + let mediaConfiguration: [String: Any] = [ + "edgemedia.channel": "", + "edgemedia.playerName": "", + "edgemedia.appVersion": "" + ] + MobileCore.updateConfigurationWith(configDict: mediaConfiguration) + }) + ... + } + ``` + +#### Objective-C + ```objectivec + // AppDelegate.h + @import AEPCore; + @import AEPEdge; + @import AEPEdgeIdentity; + @import AEPEdgeMedia; + ``` + + ```objectivec + // AppDelegate.m + - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [AEPMobileCore registerExtensions:@[AEPMobileEdge.class, AEPMobileEdgeIdentity.class, AEPMobileEdgeMedia.class] completion:^{ + ... + }]; + [AEPMobileCore configureWithAppId: @"yourEnvironmentID"]; + ... + } + ``` diff --git a/Documentation/migration-guide.md b/Documentation/migration-guide.md new file mode 100644 index 0000000..b8b0da1 --- /dev/null +++ b/Documentation/migration-guide.md @@ -0,0 +1,183 @@ +## Migrating from AEPMedia to AEPEdgeMedia + +This is the complete migration guide from AEPMedia to AEPEdgeMedia SDK. + +| Quick Links | +| --- | +| [Configuration](#configuration) | +| [Add extensions to your app](#add-the-aepedgemedia-extension-to-your-app)
  • [Dependencies](#dependencies)
  • [Download extension with dependencies](#download-extension-with-dependencies)
  • [Import and register extensions](#import-and-register-extensions)
| +| [Granular ad tracking](#granular-ad-tracking) | +| [Downloaded content tracking](#downloaded-content-tracking) | +| [API Reference](#api-reference)| + +------ + +## Configuration + +### AEPMedia +| Name | Key | Value | Required | +| --- | --- | --- | --- | +| Collection API Server | "media.trackingServer" | String | Yes | +| Channel | "media.channel" | String | No | +| Player Name | "media.playerName" | String | No | +| Application Version | "media.appVersion" | String | No | + +### AEPEdgeMedia +| Name | Key | Value | Required | +| --- | --- | --- | --- | +| Channel | "edgemedia.channel" | String | Yes | +| Player Name | "edgemedia.playerName" | String | Yes | +| Application Version | "edgemedia.appVersion" | String | No | + +Please refer [AEPEdgeMedia configuration](getting-started.md/#configuration) for more details. + +------ + +## Add the AEPEdgeMedia extension to your app + +### Dependencies + +| AEPMedia | AEPEdgeMedia| +| --- | --- | +|```AEPCore, AEPIdentity, AEPAnalytics```|```AEPCore, AEPEdge, AEPEdgeIdentity```| + +------ + +### Download extension with dependencies + +#### 1. Using Cocoapods:
+ +Update pod file in your project + +```diff + pod 'AEPCore' +- pod 'AEPAnalytics' +- pod 'AEPMedia' ++ pod 'AEPEdge' ++ pod 'AEPEdgeIdentity' ++ pod 'AEPEdgeMedia', :git => 'https://github.com/adobe/aepsdk-edgemedia-ios.git', :tag => '1.0.0-beta' +``` + +#### 2. Using SPM: + +Import the package: + +a. Using repository URL + +```diff +- https://github.com/adobe/aepsdk-media-ios.git ++ https://github.com/adobe/aepsdk-edgemedia-ios.git +``` + +b. Using `Package.swift` file + +Make changes to your dependencies as shown below: + +```diff + dependencies: [ + .package(url: "https://github.com/adobe/aepsdk-core-ios.git", .upToNextMajor(from: "3.7.0")), +- .package(url: "https://github.com/adobe/aepsdk-analytics-ios.git", .upToNextMajor(from: "3.0.0")), +- .package(url: "https://github.com/adobe/aepsdk-media-ios.git", .upToNextMajor(from: "3.0.0")) ++ .package(url: "https://github.com/adobe/aepsdk-edge-ios.git", .upToNextMajor(from: "1.4.0")), ++ .package(url: "https://github.com/adobe/aepsdk-edgeidentity-ios.git", .upToNextMajor(from: "1.0.0")), ++ .package(url: "https://github.com/adobe/aepsdk-edgemedia-ios.git", .upToNextMajor(from: "1.0.0-beta")) + ] +``` + +------ + +### Import and register extensions + +##### Swift + +```diff +// AppDelegate.swift +import AEPCore +- import AEPIdentity +- import AEPAnalytics +- import AEPMedia ++ import AEPEdge ++ import AEPEdgeIdentity ++ import AEPEdgeMedia +``` + +```diff +// AppDelegate.swift +func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { +- MobileCore.registerExtensions([Identity.self, Analytics.self, Media.self], { ++ MobileCore.registerExtensions([Edge.self, Identity.self, Media.self], { + MobileCore.configureWith(appId: "yourEnvironmentID") + }) + ... +} +``` + +
+ Using both AEPMedia and AEPEdgeMedia for a side-by-side comparison? +
+

If you wish to use both the extensions together during migration time for a side-by-side comparison, use the Swift module name along with the extension class names for registration, as well as for any classes that use API s from both the modules.

+ +**Example** + +```swift +// AppDelegate.swift +import AEPCore +import AEPIdentity +import AEPAnalytics +import AEPMedia +import AEPEdge +import AEPEdgeIdentity +import AEPEdgeMedia +``` + +```swift +// AppDelegate.swift +func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { +MobileCore.registerExtensions([ + Edge.self, + AEPEdgeMedia.Media.self, + AEPEdgeIdentity.Identity.self, + AEPMedia.Media.self, + AEPIdentity.Identity.self, + Analytics.self, + ], { + MobileCore.configureWith(appId: "yourEnvironmentID") + }) + ... +} +``` +
+ +------ + +### Granular ad tracking + +AEPMedia allowed for ad content tracking of `1 second` when setting the `MediaConstants.MediaObjectKey.GRANULAR_AD_TRACKING` key in the media object. AEPEdgeMedia is even more customizable and now the ad content tracking interval can be set using the tracker configuration to a value between `[1-10] seconds`. For more details, refer to the [createTrackerWithConfig API](api-reference.md/#createTrackerWithConfig). + +```diff +- let tracker = Media.createTracker() ++ var trackerConfig: [String: Any] = [:] ++ trackerConfig[MediaConstants.TrackerConfig.AD_PING_INTERVAL] = 1 ++ let tracker = Media.createTrackerWith(config: trackerConfig) + +guard var mediaObject = guard let mediaObject = Media.createMediaObjectWith(name: "name", id: "id", length: 30, streamType: "vod", mediaType: MediaType.Video) else { + return +} +- mediaObject[MediaConstants.MediaObjectKey.GRANULAR_AD_TRACKING] = true + +tracker.trackSessionStart(info: mediaObject, metadata: videoMetadata) +``` +------ + +### Downloaded content tracking + +AEPMedia supports offline tracking for downloaded videos by setting the `MediaConstants.TrackerConfig.DOWNLOADED_CONTENT` key in the tracker configuration and calling `createTrackerWithConfig` API. + +AEPEdgemedia currently does not support this workflow. + +------ + +## API reference +The AEPEdgeMedia SDK has similar APIs with AEPMedia. Please refer the [API reference docs](api-reference.md) to check out the APIs and their usage. + +------ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..1810998 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'cocoapods', '= 1.10.0' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..a266c01 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,96 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.6) + rexml + activesupport (5.2.8.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + claide (1.1.0) + cocoapods (1.10.0) + addressable (~> 2.6) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.10.0) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 1.4.0, < 2.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.6.6) + nap (~> 1.0) + ruby-macho (~> 1.4) + xcodeproj (>= 1.19.0, < 2.0) + cocoapods-core (1.10.0) + activesupport (> 5.0, < 6) + addressable (~> 2.6) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (1.6.3) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.2.0) + escape (0.0.4) + ethon (0.16.0) + ffi (>= 1.15.0) + ffi (1.15.5) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.8.3) + i18n (1.12.0) + concurrent-ruby (~> 1.0) + json (2.6.3) + minitest (5.17.0) + molinillo (0.6.6) + nanaimo (0.3.0) + nap (1.1.0) + netrc (0.11.0) + public_suffix (5.0.1) + rexml (3.2.5) + ruby-macho (1.4.0) + thread_safe (0.3.6) + typhoeus (1.4.0) + ethon (>= 0.9.0) + tzinfo (1.2.11) + thread_safe (~> 0.1) + xcodeproj (1.22.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) + +PLATFORMS + ruby + +DEPENDENCIES + cocoapods (= 1.10.0) + +BUNDLED WITH + 2.4.7 diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..ca7188d --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Adobe + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..894b528 --- /dev/null +++ b/Makefile @@ -0,0 +1,95 @@ + +export EXTENSION_NAME = AEPEdgeMedia +export APP_NAME = TestApp +PROJECT_NAME = $(EXTENSION_NAME) +TARGET_NAME_XCFRAMEWORK = $(EXTENSION_NAME).xcframework +SCHEME_NAME_XCFRAMEWORK = $(EXTENSION_NAME) +TEST_APP_IOS_SCHEME = TestAppiOS +TEST_APP_TVOS_SCHEME = TestApptvOS + + +CURR_DIR := ${CURDIR} +IOS_SIMULATOR_ARCHIVE_PATH = $(CURR_DIR)/build/ios_simulator.xcarchive/Products/Library/Frameworks/ +IOS_SIMULATOR_ARCHIVE_DSYM_PATH = $(CURR_DIR)/build/ios_simulator.xcarchive/dSYMs/ +IOS_ARCHIVE_PATH = $(CURR_DIR)/build/ios.xcarchive/Products/Library/Frameworks/ +IOS_ARCHIVE_DSYM_PATH = $(CURR_DIR)/build/ios.xcarchive/dSYMs/ +TVOS_SIMULATOR_ARCHIVE_PATH = ./build/tvos_simulator.xcarchive/Products/Library/Frameworks/ +TVOS_SIMULATOR_ARCHIVE_DSYM_PATH = $(CURR_DIR)/build/tvos_simulator.xcarchive/dSYMs/ +TVOS_ARCHIVE_PATH = ./build/tvos.xcarchive/Products/Library/Frameworks/ +TVOS_ARCHIVE_DSYM_PATH = $(CURR_DIR)/build/tvos.xcarchive/dSYMs/ + +setup: + (pod install) + +setup-tools: install-githook + +pod-repo-update: + (pod repo update) + +# pod repo update may fail if there is no repo (issue fixed in v1.8.4). Use pod install --repo-update instead +pod-install: + (pod install --repo-update) + +ci-pod-install: + (bundle exec pod install --repo-update) + +pod-update: pod-repo-update + (pod update) + +open: + open $(PROJECT_NAME).xcworkspace + +clean: + (rm -rf build) + +build-app: setup + @echo "######################################################################" + @echo "### Building $(TEST_APP_IOS_SCHEME)" + @echo "######################################################################" + xcodebuild clean build -workspace $(PROJECT_NAME).xcworkspace -scheme $(TEST_APP_IOS_SCHEME) -destination 'generic/platform=iOS Simulator' + + @echo "######################################################################" + @echo "### Building $(TEST_APP_TVOS_SCHEME)" + @echo "######################################################################" + xcodebuild clean build -workspace $(PROJECT_NAME).xcworkspace -scheme $(TEST_APP_TVOS_SCHEME) -destination 'generic/platform=tvOS Simulator' + +archive: pod-update + xcodebuild archive -workspace $(PROJECT_NAME).xcworkspace -scheme $(SCHEME_NAME_XCFRAMEWORK) -archivePath "./build/ios.xcarchive" -sdk iphoneos -destination="iOS" SKIP_INSTALL=NO BUILD_LIBRARIES_FOR_DISTRIBUTION=YES + xcodebuild archive -workspace $(PROJECT_NAME).xcworkspace -scheme $(SCHEME_NAME_XCFRAMEWORK) -archivePath "./build/tvos.xcarchive" -sdk appletvos -destination="tvOS" SKIP_INSTALL=NO BUILD_LIBRARIES_FOR_DISTRIBUTION=YES + xcodebuild archive -workspace $(PROJECT_NAME).xcworkspace -scheme $(SCHEME_NAME_XCFRAMEWORK) -archivePath "./build/ios_simulator.xcarchive" -sdk iphonesimulator -destination="iOS Simulator" SKIP_INSTALL=NO BUILD_LIBRARIES_FOR_DISTRIBUTION=YES + xcodebuild archive -workspace $(PROJECT_NAME).xcworkspace -scheme $(SCHEME_NAME_XCFRAMEWORK) -archivePath "./build/tvos_simulator.xcarchive" -sdk appletvsimulator -destination="tvOS Simulator" SKIP_INSTALL=NO BUILD_LIBRARIES_FOR_DISTRIBUTION=YES + xcodebuild -create-xcframework -framework $(IOS_SIMULATOR_ARCHIVE_PATH)$(PROJECT_NAME).framework -debug-symbols $(IOS_SIMULATOR_ARCHIVE_DSYM_PATH)$(PROJECT_NAME).framework.dSYM \ + -framework $(TVOS_SIMULATOR_ARCHIVE_PATH)$(PROJECT_NAME).framework -debug-symbols $(TVOS_SIMULATOR_ARCHIVE_DSYM_PATH)$(PROJECT_NAME).framework.dSYM \ + -framework $(IOS_ARCHIVE_PATH)$(PROJECT_NAME).framework -debug-symbols $(IOS_ARCHIVE_DSYM_PATH)$(PROJECT_NAME).framework.dSYM \ + -framework $(TVOS_ARCHIVE_PATH)$(PROJECT_NAME).framework -debug-symbols $(TVOS_ARCHIVE_DSYM_PATH)$(PROJECT_NAME).framework.dSYM \ + -output ./build/$(TARGET_NAME_XCFRAMEWORK) + +test-ios: + @echo "######################################################################" + @echo "### Testing iOS" + @echo "######################################################################" + xcodebuild test -workspace $(PROJECT_NAME).xcworkspace -scheme $(PROJECT_NAME) -destination 'platform=iOS Simulator,name=iPhone 8' -derivedDataPath build/outn -resultBundlePath iosresults.xcresult -enableCodeCoverage YES + +test-tvos: + @echo "######################################################################" + @echo "### Testing tvOS" + @echo "######################################################################" + xcodebuild test -workspace $(PROJECT_NAME).xcworkspace -scheme $(PROJECT_NAME) -destination 'platform=tvOS Simulator,name=Apple TV' -derivedDataPath build/outn -resultBundlePath tvosresults.xcresult -enableCodeCoverage YES + +install-githook: + git config core.hooksPath .githooks + +lint-autocorrect: + (./Pods/SwiftLint/swiftlint --fix) + +lint: + (./Pods/SwiftLint/swiftlint lint Sources TestApps/$(APP_NAME)) + +check-version: + (sh ./Script/version.sh $(VERSION)) + +test-SPM-integration: + (sh ./Script/test-SPM.sh) + +test-podspec: + (sh ./Script/test-podspec.sh) diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2658ca3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import PackageDescription + +let package = Package( + name: "AEPEdgeMedia", + platforms: [.iOS(.v10), .tvOS(.v10)], + products: [ + .library(name: "AEPEdgeMedia", targets: ["AEPEdgeMedia"]) + ], + dependencies: [ + .package(url: "https://github.com/adobe/aepsdk-core-ios.git", .upToNextMajor(from: "3.7.0")), + .package(url: "https://github.com/adobe/aepsdk-edge-ios.git", .upToNextMajor(from: "1.6.0")) + ], + targets: [ + .target(name: "AEPEdgeMedia", + dependencies: ["AEPCore", "AEPEdge"], + path: "Sources") + ] +) diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..6bb453e --- /dev/null +++ b/Podfile @@ -0,0 +1,56 @@ +platform :ios, '10.0' + +# Comment the next line if you don't want to use dynamic frameworks +use_frameworks! + +workspace 'AEPEdgeMedia' +project 'AEPEdgeMedia.xcodeproj' + +pod 'SwiftLint', '0.44.0' + +target 'AEPEdgeMedia' do + pod 'AEPCore' + pod 'AEPServices' +end + +target 'UnitTests' do + pod 'AEPCore' + pod 'AEPServices' +end + +target 'FunctionalTests' do + pod 'AEPCore' + pod 'AEPServices' +end + +target 'IntegrationTests' do + pod 'AEPCore' + pod 'AEPServices' + pod 'AEPEdge' + pod 'AEPEdgeIdentity' +end + +target 'TestAppiOS' do + pod 'AEPCore' + pod 'AEPEdge' + pod 'AEPEdgeIdentity' + pod 'AEPAssurance' + pod 'AEPServices' +end + +target 'TestApptvOS' do + pod 'AEPCore' + pod 'AEPEdge' + pod 'AEPEdgeIdentity' + pod 'AEPServices' +end + +post_install do |pi| + pi.pods_project.targets.each do |t| + t.build_configurations.each do |bc| + bc.build_settings['TVOS_DEPLOYMENT_TARGET'] = '10.0' + bc.build_settings['SUPPORTED_PLATFORMS'] = 'iphoneos iphonesimulator appletvos appletvsimulator' + bc.build_settings['TARGETED_DEVICE_FAMILY'] = "1,2,3" + end + end +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..25c027d --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,46 @@ +PODS: + - AEPAssurance (3.0.1): + - AEPCore (>= 3.1.0) + - AEPServices (>= 3.1.0) + - AEPCore (3.7.4): + - AEPRulesEngine (>= 1.1.0) + - AEPServices (>= 3.7.4) + - AEPEdge (1.6.0): + - AEPCore (>= 3.7.0) + - AEPEdgeIdentity (>= 1.2.0) + - AEPEdgeIdentity (1.2.0): + - AEPCore (>= 3.7.0) + - AEPRulesEngine (1.2.0) + - AEPServices (3.7.4) + - SwiftLint (0.44.0) + +DEPENDENCIES: + - AEPAssurance + - AEPCore + - AEPEdge + - AEPEdgeIdentity + - AEPServices + - SwiftLint (= 0.44.0) + +SPEC REPOS: + trunk: + - AEPAssurance + - AEPCore + - AEPEdge + - AEPEdgeIdentity + - AEPRulesEngine + - AEPServices + - SwiftLint + +SPEC CHECKSUMS: + AEPAssurance: b25880cd4b14f22c61a1dce19807bd0ca0fe9b17 + AEPCore: 4f2d6af62f492e87a6cc9cbf4c89ae6f0ea89d81 + AEPEdge: e4364a56d358c517f7d4cef87570ac4e7652d3a2 + AEPEdgeIdentity: 6bb2c1e62d48cdc988b4d492e8e6d563f0ced73d + AEPRulesEngine: 71228dfdac24c9ded09be13e3257a7eb22468ccc + AEPServices: 1c66ce125f9b3bbd46e42687b6929cd584bffdf4 + SwiftLint: e96c0a8c770c7ebbc4d36c55baf9096bb65c4584 + +PODFILE CHECKSUM: 3f206d908541e1172789950622421137fd54bd8f + +COCOAPODS: 1.11.3 diff --git a/README.md b/README.md index 0844e08..4265bb5 100644 --- a/README.md +++ b/README.md @@ -1 +1,38 @@ -# Adobe Experience Platform Edge Media +# Adobe Experience Platform Media For Edge Network Extension + +[![CircleCI](https://img.shields.io/circleci/project/github/adobe/aepsdk-edgemedia-ios/main.svg?logo=circleci)](https://circleci.com/gh/adobe/workflows/aepsdk-edgemedia-ios) +[![Code Coverage](https://img.shields.io/codecov/c/github/adobe/aepsdk-edgemedia-ios/main.svg?logo=codecov)](https://codecov.io/gh/adobe/aepsdk-edgemedia-ios/branch/main) + +## BETA ACKNOWLEDGEMENT + +The Media for Edge Network extension is currently in Beta. Use of this code is by invitation only and not otherwise supported by Adobe. Please contact your Adobe Customer Success Manager to learn more. + +By using the Beta, you hereby acknowledge that the Beta is provided "as is" without warranty of any kind. Adobe shall have no obligation to maintain, correct, update, change, modify or otherwise support the Beta. You are advised to use caution and not to rely in any way on the correct functioning or performance of such Beta and/or accompanying materials. + +## About this project + +The AEP Media Analytics for Edge Network mobile extension provides clients with robust measurement for audio, video and advertisements when using the [Adobe Experience Platform Mobile SDK](https://developer.adobe.com/client-sdks) and the Edge Network extension. + +## Requirements +- Xcode 13.x (or newer) +- Swift 5.x (or newer) + +## Install AEPEdgeMedia + +To install and start using AEPEdgeMedia extension, check out the [getting started guide](Documentation/getting-started.md) and the [API reference](Documentation/api-reference.md). + +## Migrating from AEPMedia + +Please refer [Migrating from AEPMedia to AEPEdgeMedia](Documentation/migration-guide.md) + +## Documentation + +Additional documentation for usage and SDK architecture can be found under the [Documentation](Documentation) directory. + +## Contributing + +Contributions are welcomed! Read the [Contributing Guide](./.github/CONTRIBUTING.md) for more information. + +## Licensing + +This project is licensed under the Apache V2 License. See [LICENSE](LICENSE) for more information. diff --git a/SECURITY.md b/SECURITY.md new file mode 100755 index 0000000..26363e3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policies and Procedures + +## Reporting an Issue + +If you need to report a security issue please visit [Notifying Adobe of Security Issues](https://helpx.adobe.com/ca/security/alertus.html) + +## Disclosure Policy + +For more information on our disclosure policy please visit [Vulnerability Disclosure Program Policy](https://helpx.adobe.com/security/policy.html) \ No newline at end of file diff --git a/Script/test-SPM.sh b/Script/test-SPM.sh new file mode 100755 index 0000000..cc11294 --- /dev/null +++ b/Script/test-SPM.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# +# Copyright 2022 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately + +PROJECT_NAME=TestProject + +# Clean up. +rm -rf $PROJECT_NAME + +mkdir -p $PROJECT_NAME && cd $PROJECT_NAME + +# Create the package. +swift package init + +# Create the Package.swift. +echo "// swift-tools-version:5.3 +// The swift-tools-version declares the minimum version of Swift required to build this package. +import PackageDescription +let package = Package( + name: \"TestProject\", + defaultLocalization: \"en-US\", + platforms: [ + .iOS(.v10), .tvOS(.v10) + ], + products: [ + .library( + name: \"TestProject\", + targets: [\"TestProject\"] + ) + ], + dependencies: [ + .package(name: \"AEPCore\", url: \"https://github.com/adobe/aepsdk-core-ios.git\", .branch(\"main\")), + .package(name: \"AEPEdge\", url: \"https://github.com/adobe/aepsdk-edge-ios.git\", .branch(\"main\")), + .package(name: \"AEPEdgeIdentity\", url: \"https://github.com/adobe/aepsdk-edgeidentity-ios.git\", .branch(\"main\")), + .package(name: \"AEPEdgeMedia\", path: \"../\") + ], + targets: [ + .target( + name: \"TestProject\", + dependencies: [ + .product(name: \"AEPCore\", package: \"AEPCore\"), + .product(name: \"AEPIdentity\", package: \"AEPCore\"), + .product(name: \"AEPLifecycle\", package: \"AEPCore\"), + .product(name: \"AEPServices\", package: \"AEPCore\"), + .product(name: \"AEPSignal\", package: \"AEPCore\"), + .product(name: \"AEPEdge\", package: \"AEPEdge\"), + .product(name: \"AEPEdgeIdentity\", package: \"AEPEdgeIdentity\"), + .product(name: \"AEPEdgeMedia\", package: \"AEPEdgeMedia\"), + ]) + ] +) +" >Package.swift + +swift package update + +# Archive for generic iOS device +echo '############# Archive for generic iOS device ###############' +xcodebuild archive -scheme TestProject -destination 'generic/platform=iOS' + +# Build for generic iOS device +echo '############# Build for generic iOS device ###############' +xcodebuild build -scheme TestProject -destination 'generic/platform=iOS' + +# Build for x86_64 iOS simulator +echo '############# Build for x86_64 iOS simulator ###############' +xcodebuild build -scheme TestProject -destination 'generic/platform=iOS Simulator' ARCHS=x86_64 + +# Archive for generic tvOS device +echo '############# Archive for generic tvOS device ###############' +xcodebuild archive -scheme TestProject -destination 'generic/platform=tvOS' + +# Build for generic tvOS device +echo '############# Build for generic tvOS device ###############' +xcodebuild build -scheme TestProject -destination 'generic/platform=tvOS' + +# Build for x86_64 tvOS simulator +echo '############# Build for x86_64 tvOS simulator ###############' +xcodebuild build -scheme TestProject -destination 'generic/platform=tvOS Simulator' ARCHS=x86_64 + +# Clean up. +cd ../ +rm -rf $PROJECT_NAME diff --git a/Script/test-podspec.sh b/Script/test-podspec.sh new file mode 100644 index 0000000..daee5b9 --- /dev/null +++ b/Script/test-podspec.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# +# Copyright 2022 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +set -e # Any subsequent(*) commands which fail will cause the shell script to exit immediately + +PROJECT_NAME=TestProject + +# Clean up. +rm -rf $PROJECT_NAME + +mkdir -p $PROJECT_NAME && cd $PROJECT_NAME + +# Create a new Xcode project. +swift package init +swift package generate-xcodeproj + +# Create a Podfile with our pod as dependency. +echo " +platform :ios, '10.0' +target '$PROJECT_NAME' do + use_frameworks! + pod 'AEPCore', '~> 3.0' + pod 'AEPIdentity', '~> 3.0' + pod 'AEPServices', '~> 3.0' + pod 'AEPRulesEngine', '~> 1.0' + pod 'AEPEdge', '~> 1.0' + pod 'AEPEdgeMedia', :path => '../AEPEdgeMedia.podspec' +end +" >>Podfile + +# Install the pods. +pod install + +# Archive for generic iOS device +echo '############# Archive for generic iOS device ###############' +xcodebuild archive -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS' + +# Build for generic iOS device +echo '############# Build for generic iOS device ###############' +xcodebuild clean build -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS' + +# Archive for x86_64 simulator +echo '############# Archive for iOS simulator ###############' +xcodebuild archive -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS Simulator' + +# Build for x86_64 simulator +echo '############# Build for iOS simulator ###############' +xcodebuild clean build -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=iOS Simulator' + +# Clean up. +cd ../ +rm -rf $PROJECT_NAME + +# tvOS +mkdir -p $PROJECT_NAME && cd $PROJECT_NAME +# Create a new Xcode project. +swift package init +swift package generate-xcodeproj + +# Create a Podfile with our pod as dependency. +echo " +platform :tvos, '10.0' +target '$PROJECT_NAME' do + use_frameworks! + pod 'AEPCore', '~> 3.7' + pod 'AEPIdentity', '~> 3.7' + pod 'AEPServices', '~> 3.7' + pod 'AEPRulesEngine', '~> 1.1' + pod 'AEPEdge', '~> 1.4' + pod 'AEPEdgeMedia', :path => '../AEPEdgeMedia.podspec' +end +" >>Podfile + +# Install the pods. +pod install +# Archive for generic tvOS device +echo '############# Archive for generic tvOS device ###############' +xcodebuild archive -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=tvOS' + +# Build for generic tvOS device +echo '############# Build for generic tvOS device ###############' +xcodebuild build -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=tvOS' + +# Archive for generic tvOS device +echo '############# Archive for generic tvOS device ###############' +xcodebuild archive -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=tvOS Simulator' + +# Build for generic tvOS simulator +echo '############# Build for x86_64 tvOS simulator ###############' +xcodebuild build -scheme TestProject-Package -workspace TestProject.xcworkspace -destination 'generic/platform=tvOS Simulator' + +# Clean up. +cd ../ +rm -rf $PROJECT_NAME diff --git a/Script/version.sh b/Script/version.sh new file mode 100755 index 0000000..70ee70d --- /dev/null +++ b/Script/version.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Copyright 2022 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. + +set -e + +if which jq >/dev/null; then + echo "jq is installed" +else + echo "error: jq not installed.(brew install jq)" +fi + +NC='\033[0m' +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' + +echo "Target version - ${BLUE}$1${NC}" +echo "------------------AEPEdgeMedia-------------------" +PODSPEC_VERSION=$(pod ipc spec AEPEdgeMedia.podspec | jq '.version' | tr -d '"') +echo "Local podspec version - ${BLUE}${PODSPEC_VERSION}${NC}" +SOURCE_CODE_VERSION=$(cat ./Sources/MediaConstants.swift | egrep '\s*EXTENSION_VERSION\s*=\s*\"(.*)\"' | ruby -e "puts gets.scan(/\"(.*)\"/)[0] " | tr -d '"') +echo "Souce code version - ${BLUE}${SOURCE_CODE_VERSION}${NC}" + +if [[ "$1" == "$PODSPEC_VERSION" ]] && [[ "$1" == "$SOURCE_CODE_VERSION" ]]; then + echo "${GREEN}Pass!${NC}" +else + echo "${RED}[Error]${NC} Version do not match!" + exit -1 +fi +exit 0 diff --git a/Sources/AEPEdgeMedia.h b/Sources/AEPEdgeMedia.h new file mode 100644 index 0000000..3cfefa1 --- /dev/null +++ b/Sources/AEPEdgeMedia.h @@ -0,0 +1,23 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +#import + +//! Project version number for AEPEdgeMedia. +FOUNDATION_EXPORT double AEPEdgeMediaVersionNumber; + +//! Project version string for AEPEdgeMedia. +FOUNDATION_EXPORT const unsigned char AEPEdgeMediaVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/Sources/Double+Media.swift b/Sources/Double+Media.swift new file mode 100644 index 0000000..9c4c17c --- /dev/null +++ b/Sources/Double+Media.swift @@ -0,0 +1,20 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +// Adds functionality to compare the equality of two doubles to within the specified delta. +extension Double { + func isAlmostEqual(_ doubleToCompare: Double, accuracy: Double = 0.000001) -> Bool { + return fabs(self - doubleToCompare) < accuracy + } +} diff --git a/Sources/Event+Media.swift b/Sources/Event+Media.swift new file mode 100644 index 0000000..86ff7aa --- /dev/null +++ b/Sources/Event+Media.swift @@ -0,0 +1,62 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import Foundation + +extension Event { + /// Returns tracker id associated with Event + var trackerId: String? { + return data?[MediaConstants.Tracker.ID] as? String + } + + /// Returns client generated session id associated with Event + var sessionId: String? { + return data?[MediaConstants.Tracker.SESSION_ID] as? String + } + + /// Returns tracker config associated with EVENT_SOURCE_TRACKER_REQUEST Event + var trackerConfig: [String: Any]? { + guard source == MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST else { + return nil + } + return data?[MediaConstants.Tracker.EVENT_PARAM] as? [String: Any] + } + + var param: [String: Any]? { + return data?[MediaConstants.Tracker.EVENT_PARAM] as? [String: Any] + } + + var metadata: [String: String]? { + return data?[MediaConstants.Tracker.EVENT_METADATA] as? [String: String] + } + + var name: String? { + return data?[MediaConstants.Tracker.EVENT_NAME] as? String + } + + var eventTs: Int64? { + return data?[MediaConstants.Tracker.EVENT_TIMESTAMP] as? Int64 + } + + var requestEventId: String? { + return data?[MediaConstants.Edge.EventData.REQUEST_EVENT_ID] as? String + } + + var backendSessionId: String? { + guard let payload = data?[MediaConstants.Edge.EventData.PAYLOAD] as? [[String: Any]], !payload.isEmpty else { + return nil + } + + return payload[0][MediaConstants.Edge.EventData.SESSION_ID] as? String + } +} diff --git a/Sources/Media.swift b/Sources/Media.swift new file mode 100644 index 0000000..6437356 --- /dev/null +++ b/Sources/Media.swift @@ -0,0 +1,135 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import Foundation + +@objc(AEPMobileEdgeMedia) +public class Media: NSObject, Extension { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "Media" + + public var runtime: ExtensionRuntime + public var name = MediaConstants.EXTENSION_NAME + public var friendlyName = MediaConstants.FRIENDLY_NAME + public static var extensionVersion = MediaConstants.EXTENSION_VERSION + public var metadata: [String: String]? + + #if DEBUG + var trackers: [String: MediaEventTracking] + var mediaEventProcessor: MediaEventProcessor + #else + private var trackers: [String: MediaEventTracking] + private var mediaEventProcessor: MediaEventProcessor + #endif + + // MARK: Extension + /// Initializes the Media extension and it's dependencies + public required init(runtime: ExtensionRuntime) { + self.runtime = runtime + self.mediaEventProcessor = MediaEventProcessor(dispatcher: runtime.dispatch(event:)) + self.trackers = [:] + } + + /// Invoked when the Media extension has been registered by the `EventHub` + public func onRegistered() { + registerListener(type: MediaConstants.Media.EVENT_TYPE, source: MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST, listener: handleMediaTrackerRequest) + registerListener(type: MediaConstants.Media.EVENT_TYPE, source: MediaConstants.Media.EVENT_SOURCE_TRACK_MEDIA, listener: handleMediaTrack) + registerListener(type: EventType.configuration, source: EventSource.responseContent, listener: handleConfigurationResponseEvent) + registerListener(type: EventType.edge, source: MediaConstants.Media.EVENT_SOURCE_MEDIA_EDGE_SESSION, listener: handleMediaEdgeSessionDetails) + registerListener(type: EventType.edge, source: MediaConstants.Media.EVENT_SOURCE_EDGE_ERROR_RESPONSE, listener: handleEdgeErrorResponse) + registerListener(type: EventType.genericIdentity, source: EventSource.requestReset, listener: handleResetIdentitiesEvent) + } + + /// Invoked when the Media extension has been unregistered by the `EventHub`, currently a no-op. + public func onUnregistered() { } + + // Media extension is always ready for processing `Event` + /// - Parameter event: an `Event` + public func readyForEvent(_ event: Event) -> Bool { + return true + } + + /// Handles the session ID returned by the media backend response dispatched by the edge extension + /// - Parameter: + /// - event: The new media edge session response event with media backend ID + public func handleMediaEdgeSessionDetails(_ event: Event) { + guard event.data != nil, let requestEventId = event.requestEventId else { + return + } + + mediaEventProcessor.notifyBackendSessionId(requestEventId: requestEventId, backendSessionId: event.backendSessionId) + } + + /// Handles the error response event dispatched by the edge extension + /// - Parameter: + /// - event: The error response event + public func handleEdgeErrorResponse(_ event: Event) { + guard let eventData = event.data, let requestEventId = event.requestEventId else { + return + } + + mediaEventProcessor.notifyErrorResponse(requestEventId: requestEventId, data: eventData) + } + + /// Processes Configuration response content events to retrieve the configuration data. + /// - Parameter: + /// - event: The configuration response event + private func handleConfigurationResponseEvent(_ event: Event) { + mediaEventProcessor.updateMediaState(configurationSharedStateData: retrieveConfigurationStateForEvent(event)) + } + + /// Handler for media tracker creation events + /// - Parameter event: an event containing data for creating tracker + private func handleMediaTrackerRequest(event: Event) { + guard let trackerId = event.trackerId, !trackerId.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Public tracker ID is invalid, unable to create internal tracker.") + return + } + + let trackerConfig = event.trackerConfig ?? [:] + + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Creating an internal tracker with tracker ID: \(trackerId).") + trackers[trackerId] = MediaEventTracker(eventProcessor: mediaEventProcessor, config: trackerConfig) + } + + /// Handler for media track events + /// - Parameter event: an event containing media event data for processing + private func handleMediaTrack(event: Event) { + guard let trackerId = event.trackerId, !trackerId.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Public tracker ID is invalid, unable to get internal tracker.") + return + } + + guard let tracker = trackers[trackerId] else { + Log.error(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Unable to find internal tracker for the given tracker ID: (\(trackerId)).") + return + } + + tracker.track(event: event) + } + + /// Processes Reset identites event + /// - Parameter event: The Reset identities event + private func handleResetIdentitiesEvent(_ event: Event) { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Clearing all tracking sessions.") + mediaEventProcessor.abortAllSessions() + trackers.removeAll() + } + + /// Fetched latest configuration for given event + /// - Parameter event: the `Event` being processed + private func retrieveConfigurationStateForEvent(_ event: Event) -> [String: Any]? { + return getSharedState(extensionName: MediaConstants.Configuration.SHARED_STATE_NAME, event: event)?.value + } +} diff --git a/Sources/MediaConstants.swift b/Sources/MediaConstants.swift new file mode 100644 index 0000000..650f792 --- /dev/null +++ b/Sources/MediaConstants.swift @@ -0,0 +1,168 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +internal extension MediaConstants { + static let EXTENSION_NAME = "com.adobe.edge.media" + static let FRIENDLY_NAME = "Edge Media Analytics" + static let EXTENSION_VERSION = "1.0.0-beta" + static let DATASTORE_NAME = EXTENSION_NAME + static let DATABASE_NAME = EXTENSION_NAME + static let LOG_TAG = FRIENDLY_NAME + + enum Configuration { + static let SHARED_STATE_NAME = "com.adobe.module.configuration" + static let MEDIA_CHANNEL = "edgemedia.channel" + static let MEDIA_PLAYER_NAME = "edgemedia.playerName" + static let MEDIA_APP_VERSION = "edgemedia.appVersion" + } + + enum Media { + static let EVENT_TYPE = "com.adobe.eventtype.edgemedia" + static let EVENT_SOURCE_TRACKER_REQUEST = "com.adobe.eventsource.edgemedia.requesttracker" + static let EVENT_SOURCE_TRACK_MEDIA = "com.adobe.eventsource.edgemedia.trackmedia" + static let EVENT_SOURCE_SESSION_CREATED = "com.adobe.eventsource.edgemedia.sessioncreated" + static let EVENT_NAME_CREATE_TRACKER = "Media::CreateTrackerRequest" + static let EVENT_NAME_TRACK_MEDIA = "Media::TrackMedia" + static let EVENT_NAME_SESSION_CREATED = "Media::SessionCreated" + static let EVENT_SOURCE_MEDIA_EDGE_SESSION = "media-analytics:new-session" + static let EVENT_SOURCE_EDGE_ERROR_RESPONSE = "com.adobe.eventSource.errorResponseContent" + + } + + enum EventName { + static let SESSION_START = "sessionstart" + static let SESSION_END = "sessionend" + static let PLAY = "play" + static let PAUSE = "pause" + static let COMPLETE = "complete" + static let BUFFER_START = "bufferstart" + static let BUFFER_COMPLETE = "buffercomplete" + static let SEEK_START = "seekstart" + static let SEEK_COMPLETE = "seekcomplete" + static let ADBREAK_START = "adbreakstart" + static let ADBREAK_COMPLETE = "adbreakcomplete" + static let AD_START = "adstart" + static let AD_COMPLETE = "adcomplete" + static let AD_SKIP = "adskip" + static let CHAPTER_START = "chapterstart" + static let CHAPTER_COMPLETE = "chaptercomplete" + static let CHAPTER_SKIP = "chapterskip" + static let BITRATE_CHANGE = "bitratechange" + static let ERROR = "error" + static let QOE_UPDATE = "qoeupdate" + static let PLAYHEAD_UPDATE = "playheadupdate" + static let STATE_START = "statestart" + static let STATE_END = "stateend" + } + + enum MediaInfo { + static let NAME = "media.name" + static let ID = "media.id" + static let LENGTH = "media.length" + static let MEDIA_TYPE = "media.type" + static let STREAM_TYPE = "media.streamtype" + static let RESUMED = "media.resumed" + static let PREROLL_TRACKING_WAITING_TIME = "media.prerollwaitingtime" + static let GRANULAR_AD_TRACKING = "media.granularadtracking" + } + enum AdBreakInfo { + static let NAME = "adbreak.name" + static let POSITION = "adbreak.position" + static let START_TIME = "adbreak.starttime" + } + enum AdInfo { + static let ID = "ad.id" + static let NAME = "ad.name" + static let POSITION = "ad.position" + static let LENGTH = "ad.length" + } + + enum ChapterInfo { + static let NAME = "chapter.name" + static let POSITION = "chapter.position" + static let START_TIME = "chapter.starttime" + static let LENGTH = "chapter.length" + } + + enum QoEInfo { + static let BITRATE = "qoe.bitrate" + static let DROPPED_FRAMES = "qoe.droppedframes" + static let FPS = "qoe.fps" + static let STARTUP_TIME = "qoe.startuptime" + } + + enum ErrorInfo { + static let ID = "error.id" + static let SOURCE = "error.source" + } + + enum StateInfo { + static let STATE_NAME_KEY = "state.name" + static let STATE_LIMIT = 10 + } + + enum Tracker { + static let ID = "trackerid" + static let SESSION_ID = "sessionid" + static let CREATED = "trackercreated" + static let EVENT_NAME = "event.name" + static let EVENT_PARAM = "event.param" + static let EVENT_METADATA = "event.metadata" + static let EVENT_TIMESTAMP = "event.timestamp" + static let EVENT_INTERNAL = "event.internal" + static let PLAYHEAD = "time.playhead" + static let BACKEND_SESSION_ID = "mediaservice.sessionid" + } + + enum PingInterval { + static let OFFLINE_TRACKING_MS: Int64 = 50 * 1000 // 50 sec + static let REALTIME_TRACKING_MS: Int64 = 10 * 1000 // 10 sec + } + + enum XDMKeys { + static let XDM = "xdm" + static let EVENT_TYPE = "eventType" + static let TIMESTAMP = "timestamp" + static let MEDIA_COLLECTION = "mediaCollection" + static let CUSTOM_METADATA = "customMetadata" + } + + enum ErrorSource { + static let PLAYER = "player" + static let EXTERNAL = "external" + } + + enum Edge { + static let MEDIA_CUSTOM_PATH_PREFIX = "/va/v1/" + + enum EventData { + static let SESSION_ID = "sessionId" + static let PAYLOAD = "payload" + static let REQUEST_EVENT_ID = "requestEventId" + static let REQUEST = "request" + static let PATH = "path" + } + + enum ErrorKeys { + static let STATUS = "status" + static let TYPE = "type" + } + + enum ErrorData { + static let ERROR_CODE_400 = 400 + static let ERROR_TYPE_VA_EDGE_400 = "https://ns.adobe.com/aep/errors/va-edge-0400-400" + } + + } +} diff --git a/Sources/MediaContext.swift b/Sources/MediaContext.swift new file mode 100644 index 0000000..c7cedf9 --- /dev/null +++ b/Sources/MediaContext.swift @@ -0,0 +1,221 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices + +class MediaContext { + // swiftlint:disable identifier_name + enum MediaPlaybackState: String { + case Play + case Pause + case Buffer + case Seek + case Init + } + + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaContext" + private var buffering = false + private var seeking = false + private var trackedStates: [String: Bool] = [:] + private var playState = MediaPlaybackState.Init + + private(set) var mediaInfo: MediaInfo + private(set) var mediaMetadata: [String: String] + + private(set) var adBreakInfo: AdBreakInfo? + private(set) var adInfo: AdInfo? + private(set) var adMetadata: [String: String] = [:] + + private(set) var chapterInfo: ChapterInfo? + private(set) var chapterMetadata: [String: String] = [:] + + private(set) var errorInfo: [String: String]? + + var playhead = 0.0 + var qoeInfo: QoEInfo? + + init(mediaInfo: MediaInfo, metadata: [String: String]?) { + self.mediaInfo = mediaInfo + self.mediaMetadata = metadata ?? [:] + } + + /// Sets `AdBreakInfo` for the AdBreak being tracked + /// - Parameters: + /// - info: `AdBreakInfo` object. + func setAdBreakInfo(_ info: AdBreakInfo) { + adBreakInfo = info + } + + /// Clears AdBreakInfo. + func clearAdBreakInfo() { + adBreakInfo = nil + } + + /// Sets `AdInfo` and metadata for the Ad being tracked + /// - Parameters: + /// - info: `AdInfo` object. + /// - metadata: Custom metadata associated with the Ad. + func setAdInfo(_ info: AdInfo, metadata: [String: String]) { + adInfo = info + adMetadata = metadata + } + + /// Clears `AdInfo` and metadata. + func clearAdInfo() { + adInfo = nil + adMetadata = [:] + } + + /// Sets `ChapterInfo` and metadata for the Chapter being tracked + /// - Parameters: + /// - info: `ChapterInfo` object. + /// - metadata: Custom metadata associated with the Chapter. + func setChapterInfo(_ info: ChapterInfo, metadata: [String: String]) { + chapterInfo = info + chapterMetadata = metadata + } + + /// Clears `ChapterInfo` and metadata. + func clearChapterInfo() { + chapterInfo = nil + chapterMetadata = [:] + } + + /// Enter `MediaPlaybackState` when a valid state play/pause/buffer/stall is passed. + /// Play and Pause can only be entered into and are mutually exclusive, while Buffer and Seek may be entered and exited. + /// - Parameters: + /// - state: `MediaPlaybackState` value. + func enterPlaybackState(state: MediaPlaybackState) { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Enter playback state: (\(state))") + switch state { + case .Play, .Pause: + playState = state + case .Buffer: + buffering = true + case .Seek: + seeking = true + default: + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Cannot enter playback state: (\(state)), invalid playback state.") + } + } + + /// Exit `MediaPlaybackState` when a valid state play/pause/buffer/stall is passed. + /// Buffer and Seek may be exited and entered, while Play and Pause are mutually exclusive and can only be entered but not exited. + /// - Parameters: + /// - state: MediaPlaybackState value. + func exitPlaybackState(state: MediaPlaybackState) { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Exit playback state: (\(state))") + switch state { + case .Buffer: + buffering = false + case .Seek: + seeking = false + default: + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Cannot exit playback state: (\(state)), invalid playback state.") + } + } + + /// Returns `true` if the player is in a particular `MediaPlaybackState`. + /// - Parameters: + /// - state: MediaPlaybackState value. + func isInMediaPlaybackState(state: MediaPlaybackState) -> Bool { + switch state { + case .Init, .Play, .Pause: + return (playState == state) + case .Buffer: + return buffering + case .Seek: + return seeking + } + } + + /// Returns `true` if the player is in seeking, buffering state or not in play state. + func isIdle() -> Bool { + return !isInMediaPlaybackState(state: .Play) || + isInMediaPlaybackState(state: .Seek) || + isInMediaPlaybackState(state: .Buffer) + } + + /// Starts tracking customState. + /// - Parameters: + /// - info: `StateInfo` object that contains custom state name. + @discardableResult + func startState(info: StateInfo) -> Bool { + if !hasTrackedState(info: info) && didReachMaxStateLimit() { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Failed to start state," + + " already tracked max states (\(MediaConstants.StateInfo.STATE_LIMIT)) for the current session.") + return false + } + + if isInState(info: info) { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Failed to start state, state (\(info.stateName)) is already being tracked.") + return false + } + + trackedStates[info.stateName] = true + return true + } + + /// Stops tracking customState if the state is actively being tracked. + /// - Parameters: + /// - info: `StateInfo` object that contains custom state name. + @discardableResult + func endState(info: StateInfo) -> Bool { + if !isInState(info: info) { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Failed to end state, state (\(info.stateName)) is not being tracked in the current session.") + return false + } + + trackedStates[info.stateName] = false + return true + } + + /// Returns `true` if the state is actively being tracked or not. + /// - Parameters: + /// - info: `StateInfo` object that contains custom state name. + func isInState(info: StateInfo) -> Bool { + return trackedStates[info.stateName] ?? false + } + + /// Returns `true` if the state is actively being tracked or is inactive but had been already tracked. + /// - Parameters: + /// - info: `StateInfo` object that contains custom state name + func hasTrackedState(info: StateInfo) -> Bool { + return trackedStates[info.stateName] != nil + } + + /// Returns all the states that are actively being tracked. + /// - Parameters: + /// - info: `StateInfo` object that contains custom state name. + func getActiveTrackedStates() -> [StateInfo] { + var activeStates: [StateInfo] = [] + + for state in trackedStates where state.value { + if let stateInfo = StateInfo(stateName: state.key) { + activeStates.append(stateInfo) + } + } + + return activeStates + } + + /// Returns `true` if the maximum allowed number of custom states to be tracked in a session has been reached. + func didReachMaxStateLimit() -> Bool { + return trackedStates.count >= MediaConstants.StateInfo.STATE_LIMIT + } + + /// Delete all the tracked custom states. + func clearStates() { + trackedStates.removeAll() + } +} diff --git a/Sources/MediaEventProcessing.swift b/Sources/MediaEventProcessing.swift new file mode 100644 index 0000000..f5aa6df --- /dev/null +++ b/Sources/MediaEventProcessing.swift @@ -0,0 +1,35 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +protocol MediaEventProcessing { + + /// Creates a new `session` and return its `sessionId`. + /// - Parameters: + /// - trackerConfig: The tracker configuration. + /// - trackerSessionId: A `UUID` string representing tracker session ID which can used be for debugging. + /// - Returns: Unique SessionId for the session. + func createSession(trackerConfig: [String: Any], trackerSessionId: String?) -> String? + + /// Process the Media Session with id `sessionId` + /// + /// - Parameters: + /// - sessionId: The id of session to process. + /// - event: a `MediaXDMEvent` containing media event name and media experience XDM data. + func processEvent(sessionId: String, event: MediaXDMEvent) + + /// Ends the session with id `sessionId` + /// - Parameters: + /// - sessionId: The id of session to end. + func endSession(sessionId: String) +} diff --git a/Sources/MediaEventProcessor.swift b/Sources/MediaEventProcessor.swift new file mode 100644 index 0000000..9f80298 --- /dev/null +++ b/Sources/MediaEventProcessor.swift @@ -0,0 +1,155 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import Foundation + +class MediaEventProcessor: MediaEventProcessing { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaEventProcessor" + private let dispatchQueue = DispatchQueue(label: "MediaEventProcessor.DispatchQueue") + private let mediaState: MediaState + #if DEBUG + var mediaSessions: [String: MediaSession] = [:] + var uuid: String { + return UUID().uuidString + } + #else + private var mediaSessions: [String: MediaSession] = [:] + private var uuid: String { + return UUID().uuidString + } + #endif + + private var dispatcher: ((_ event: Event) -> Void)? + + init(dispatcher: ((_ event: Event) -> Void)?) { + self.mediaState = MediaState() + self.dispatcher = dispatcher + } + + /// Creates session with provided tracker configuration + /// - Parameters: + /// - trackerConfig: tracker configuration. + /// - trackerSessionId: A `UUID` string representing tracker session ID which can be used for debugging. + func createSession(trackerConfig: [String: Any], trackerSessionId: String?) -> String? { + dispatchQueue.sync { + let sessionId = uuid + let session = MediaRealTimeSession(id: sessionId, trackerSessionId: trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: dispatcher) + + mediaSessions[sessionId] = session + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Created a new session (\(sessionId))") + return sessionId + } + } + + /// Queues the media experience`Event` with XDM data for session with `sessionId` + /// - Parameters: + /// - sessionId: UniqueId of session to which media experience`Event` belongs. + /// - event: a `MediaXDMEvent` containing media event name and media experience XDM. + func processEvent(sessionId: String, event: MediaXDMEvent) { + dispatchQueue.async { + guard let session = self.mediaSessions[sessionId] else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Can not process session (\(sessionId)). SessionId is invalid.") + return + } + + session.queue(event: event) + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Successfully queued event (\(event.eventType) for Session (\(sessionId)).") + } + } + + /// Ends the session `sessionId`. + /// + /// - Parameter sessionId: Unique session id for session to end. + func endSession(sessionId: String) { + dispatchQueue.async { + guard let session = self.mediaSessions[sessionId] else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Cannot end media session (\(sessionId)). SessionId is invalid.") + return + } + + session.end { + self.mediaSessions.removeValue(forKey: sessionId) + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Successfully ended media session (\(sessionId))") + } + + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Scheduled end for media session (\(sessionId))") + } + } + + /// Aborts all the active sessions. + func abortAllSessions() { + dispatchQueue.async { + self.mediaSessions.forEach(self.abort) + } + } + + /// Update Media state and notify sessions + /// - Parameter configurationSharedStateData: Dictionary containing configuration shared state data + func updateMediaState(configurationSharedStateData: [String: Any]?) { + dispatchQueue.async { + self.mediaState.updateConfigurationSharedState(configurationSharedStateData) + self.mediaSessions.forEach { sessionId, _ in self.notifyMediaStateUpdate(sessionId: sessionId) } + } + } + + /// Notify media sessions with backend session id + /// - Parameters: + /// - requestEventId: UUID `String` denoting edge request event id. + /// - backendSessionId: UUID `String` returned by the backend. + func notifyBackendSessionId(requestEventId: String, backendSessionId: String?) { + dispatchQueue.async { + self.mediaSessions.forEach { sessionId, _ in self.mediaSessions[sessionId]?.handleSessionUpdate(requestEventId: requestEventId, backendSessionId: backendSessionId) } + } + } + + /// Notify media sessions with error responses from the backend + /// - Parameters: + /// - requestEventId: UUID denoting edge request event id. + /// - data: dictionary containing errors returned by the backend. + func notifyErrorResponse(requestEventId: String, data: [String: Any?]) { + dispatchQueue.async { + self.mediaSessions.forEach { sessionId, _ in self.mediaSessions[sessionId]?.handleErrorResponse(requestEventId: requestEventId, data: data) } + } + + } + + /// Notify MediaState updates + /// - Parameter sessionId: Unique sessionId of session + private func notifyMediaStateUpdate(sessionId: String) { + guard let session = self.mediaSessions[sessionId] else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Cannot notify states changes for media session (\(sessionId)). SessionId is invalid.") + return + } + + session.handleMediaStateUpdate() + } + + /// Abort the session `sessionId`. + /// + /// - Parameter sessionId: Unique sessionId of session to be aborted. + private func abort(sessionId: String, session: MediaSession) { + guard let session = self.mediaSessions[sessionId] else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Cannot abort media session (\(sessionId)). SessionId is invalid.") + return + } + + session.abort { + self.mediaSessions.removeValue(forKey: sessionId) + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Successfully aborted media session (\(sessionId)).") + } + + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Scheduled abort for media session (\(sessionId).") + } +} diff --git a/Sources/MediaEventTracker.swift b/Sources/MediaEventTracker.swift new file mode 100644 index 0000000..63dd8f1 --- /dev/null +++ b/Sources/MediaEventTracker.swift @@ -0,0 +1,988 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ +import AEPCore +import AEPServices +import Foundation + +// swiftlint:disable type_body_length +class MediaEventTracker: MediaEventTracking { + + // MARK: Rule Name + // swiftlint:disable identifier_name + enum RuleName: Int { + case MediaStart + case MediaComplete + case MediaSkip + case AdBreakStart + case AdBreakComplete + case AdStart + case AdComplete + case AdSkip + case ChapterStart + case ChapterComplete + case ChapterSkip + case Play + case Pause + case SeekStart + case SeekComplete + case BufferStart + case BufferComplete + case BitrateChange + case Error + case QoEUpdate + case PlayheadUpdate + case StateStart + case StateEnd + } + + static let eventToRuleMap: [String: RuleName] = [ + MediaConstants.EventName.SESSION_START: RuleName.MediaStart, + MediaConstants.EventName.COMPLETE: RuleName.MediaComplete, + MediaConstants.EventName.SESSION_END: RuleName.MediaSkip, + + MediaConstants.EventName.ADBREAK_START: RuleName.AdBreakStart, + MediaConstants.EventName.ADBREAK_COMPLETE: RuleName.AdBreakComplete, + + MediaConstants.EventName.AD_START: RuleName.AdStart, + MediaConstants.EventName.AD_COMPLETE: RuleName.AdComplete, + MediaConstants.EventName.AD_SKIP: RuleName.AdSkip, + + MediaConstants.EventName.CHAPTER_START: RuleName.ChapterStart, + MediaConstants.EventName.CHAPTER_COMPLETE: RuleName.ChapterComplete, + MediaConstants.EventName.CHAPTER_SKIP: RuleName.ChapterSkip, + + MediaConstants.EventName.PLAY: RuleName.Play, + MediaConstants.EventName.PAUSE: RuleName.Pause, + MediaConstants.EventName.SEEK_START: RuleName.SeekStart, + MediaConstants.EventName.SEEK_COMPLETE: RuleName.SeekComplete, + MediaConstants.EventName.BUFFER_START: RuleName.BufferStart, + MediaConstants.EventName.BUFFER_COMPLETE: RuleName.BufferComplete, + + MediaConstants.EventName.BITRATE_CHANGE: RuleName.BitrateChange, + MediaConstants.EventName.ERROR: RuleName.Error, + MediaConstants.EventName.QOE_UPDATE: RuleName.QoEUpdate, + MediaConstants.EventName.PLAYHEAD_UPDATE: RuleName.PlayheadUpdate, + MediaConstants.EventName.STATE_START: RuleName.StateStart, + MediaConstants.EventName.STATE_END: RuleName.StateEnd + ] + + enum ErrorMessage: String { + case ErrNotInMedia = "Media tracker is not in active tracking session, call 'API:trackSessionStart' to begin a new tracking session." + case ErrInMedia = "Media tracker is in active tracking session, call 'API:trackSessionEnd' or 'API:trackComplete' to end current tracking session." + case ErrInBuffer = "Media tracker is tracking buffer events, call 'API:trackEvent(BufferComplete)' first to stop tracking buffer events." + case ErrNotInBuffer = "Media tracker is not tracking buffer events, call 'API:trackEvent(BufferStart)' before 'API:trackEvent(BufferComplete)'." + case ErrInSeek = "Media tracker is tracking seek events, call 'API:trackEvent(SeekComplete)' first to stop tracking seek events." + case ErrNotInSeek = "Media tracker is not tracking seek events, call 'API:trackEvent(SeekStart)' before 'API:trackEvent(SeekComplete)'." + case ErrNotInAdBreak = "Media tracker is not tracking any AdBreak, call 'API:trackEvent(AdBreakStart)' to begin tracking AdBreak" + case ErrNotInAd = "Media tracker is not tracking any Ad, call 'API:trackEvent(AdStart)' to begin tracking Ad" + case ErrNotInChapter = "Media tracker is not tracking any Chapter, call 'API:trackEvent(ChapterStart)' to begin tracking Chapter" + case ErrInvalidMediaInfo = "MediaInfo passed into 'API:trackSessionStart' is invalid." + case ErrInvalidAdBreakInfo = "AdBreakInfo passed into 'API:trackEvent(AdBreakStart)' is invalid." + case ErrDuplicateAdBreakInfo = "Media tracker is currently tracking the AdBreak passed into 'API:trackEvent(AdBreakStart)'." + case ErrInvalidAdInfo = "AdInfo passed into 'API:trackEvent(AdStart)' is invalid." + case ErrDuplicateAdInfo = "Media tracker is currently tracking the Ad passed into 'API:trackEvent(AdStart)'." + case ErrInvalidChapterInfo = "ChapterInfo passed into 'API:trackEvent(ChapterStart)' is invalid." + case ErrDuplicateChapterInfo = "Media tracker is currently tracking the Chapter passed into 'API:trackEvent(ChapterStart)'." + case ErrInvalidQoEInfo = "QoEInfo passed into 'API:updateQoEInfo' is invalid." + case ErrInvalidErrorId = "ErrorId passed into 'API:trackError' is invalid. Please pass valid non-empty non-nil string for ErrorId." + case ErrInvalidPlayhead = "Playhead value not present in 'API:updatePlayhead' event data." + case ErrInvalidPlaybackState = "Media tracker is tracking an AdBreak but not tracking any Ad and will drop any calls to track player state (Play, Pause, Buffer or Seek) in this state." + case ErrInvalidStateInfo = "StateInfo passed into 'API:trackEvent(StartStart)' or 'API:trackEvent(StartEnd)' is invalid." + case ErrInTrackedState = "Media tracker is already tracking a state with the same state name." + case ErrNotInTrackedState = "Media tracker is not tracking a state with the given state name." + case ErrTrackedStatesLimitReached = "Media tracker has reached maximum number of states per session (10)." + } + + private static let KEY_INFO = "key_info" + private static let KEY_METADATA = "key_metadata" + private static let KEY_EVENT_TS = "key_eventts" + private static let KEY_EVENT = "key_event" + + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaEventTracker" + private static let IDLE_TIMEOUT_MS: Int64 = 1800 * 1000 // 30 min + private static let MEDIA_SESSION_TIMEOUT_MS: Int64 = 86400 * 1000 // 24 hours + private static let CONTENT_START_DURATION_MS: Int64 = 1 * 1000 // 1 sec + + #if DEBUG + var inPrerollInterval = false + var trackerIdle = false + var mediaContext: MediaContext? + #else + private var inPrerollInterval = false + private var trackerIdle = false + private var mediaContext: MediaContext? + #endif + + private let eventProcessor: MediaEventProcessing + private var xdmEventGenerator: MediaXDMEventGenerator? + private let trackerConfig: [String: Any]? + private var mediaIdle = false + private var prerollQueuedRules: [(name: RuleName, context: [String: Any])] = [] + private var contentStarted = false + private static let INVALID_TS: Int64 = -1 + private var prerollRefTS: Int64 = INVALID_TS + private var contentStartRefTS: Int64 = INVALID_TS + private var mediaSessionStartTS: Int64 = INVALID_TS + private var mediaIdleStartTS: Int64 = INVALID_TS + private let ruleEngine: MediaRuleEngine + + init(eventProcessor: MediaEventProcessing, config: [String: Any]) { + self.eventProcessor = eventProcessor + self.trackerConfig = config + ruleEngine = MediaRuleEngine() + setupRules() + } + + private func reset() { + self.xdmEventGenerator = nil + self.mediaContext = nil + + self.trackerIdle = false + self.mediaIdle = false + + inPrerollInterval = false + prerollQueuedRules.removeAll() + contentStarted = false + prerollRefTS = Self.INVALID_TS + contentStartRefTS = Self.INVALID_TS + mediaSessionStartTS = Self.INVALID_TS + mediaIdleStartTS = Self.INVALID_TS + } + + /// Handles all the track API calls. + /// - Parameters: + /// - event: Event for the track API consisting of eventName, playhead, timeStamp, params and metadata. + @discardableResult + func track(event: Event) -> Bool { + + guard let rule = Self.eventToRuleMap[event.name ?? ""] else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Event name is missing/invalid in track event data.") + return false + } + + guard let eventTs = event.eventTs else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Event timestamp is missing in track event data.") + return false + } + + var ruleContext: [String: Any] = [:] + ruleContext[Self.KEY_EVENT] = event + ruleContext[Self.KEY_EVENT_TS] = eventTs + + if let param = event.param { + ruleContext[Self.KEY_INFO] = param + } + + if let metadata = event.metadata { + ruleContext[Self.KEY_METADATA] = cleanMetadata(data: metadata) + } + + if rule != RuleName.PlayheadUpdate { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Processing event - \(String(describing: event.name))") + } + + if prerollDeferRule(rule: rule, context: ruleContext) { + return true + } + + return processRule(rule: rule, context: ruleContext) + } + + /// Processes rules through preset conditions set in the state machine. + /// - Parameters: + /// - rule: EventName corresponding to API call. + /// - context: Data passed with the corresponding API call. + @discardableResult + private func processRule(rule: RuleName, context: [String: Any]) -> Bool { + let result = ruleEngine.processRule(name: rule.rawValue, context: context) + + if !result.success { + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - ProcessRule - \(result.errorMsg)") + } + + return result.success + } + + // swiftlint:disable function_body_length + /// Setup state machine i.e. conditions and actions for each Rule. + /// - Parameters: + /// - rule: EventName corresponding to API call. + /// - context: Data passed with the corresponding API call. + private func setupRules() { + ruleEngine.onEnterRule(enterFn: cmdEnterAction(rule:context:)) + ruleEngine.onExitRule(exitFn: cmdExitAction(rule:context:)) + + let mediaStart = MediaRule(name: RuleName.MediaStart.rawValue, description: "API::trackSessionStart") + mediaStart.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInMedia.rawValue) + .addPredicate(predicateFn: isValidMediaInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidMediaInfo.rawValue) + .addAction(actionFn: cmdMediaStart(rule:context:)) + ruleEngine.add(rule: mediaStart) + + let mediaComplete = MediaRule(name: RuleName.MediaComplete.rawValue, description: "API::trackComplete") + mediaComplete.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addAction(actionFn: cmdAdSkip(rule:context:)) + .addAction(actionFn: cmdAdBreakSkip(rule:context:)) + .addAction(actionFn: cmdChapterSkip(rule:context:)) + .addAction(actionFn: cmdMediaComplete(rule:context:)) + ruleEngine.add(rule: mediaComplete) + + let mediaSkip = MediaRule(name: RuleName.MediaSkip.rawValue, description: "API::trackSessionEnd") + mediaSkip.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addAction(actionFn: cmdAdSkip(rule:context:)) + .addAction(actionFn: cmdAdBreakSkip(rule:context:)) + .addAction(actionFn: cmdChapterSkip(rule:context:)) + .addAction(actionFn: cmdMediaSkip(rule:context:)) + ruleEngine.add(rule: mediaSkip) + + let error = MediaRule(name: RuleName.Error.rawValue, description: "API::trackError") + error.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isValidErrorInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidErrorId.rawValue) + .addAction(actionFn: cmdError(rule:context:)) + ruleEngine.add(rule: error) + + let play = MediaRule(name: RuleName.Play.rawValue, description: "API::trackPlay") + play.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: allowPlaybackStateChange(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidPlaybackState.rawValue) + .addAction(actionFn: cmdSeekComplete(rule:context:)) + .addAction(actionFn: cmdBufferComplete(rule:context:)) + .addAction(actionFn: cmdPlay(rule:context:)) + ruleEngine.add(rule: play) + + let pause = MediaRule(name: RuleName.Pause.rawValue, description: "API::trackPause") + pause.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: allowPlaybackStateChange(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidPlaybackState.rawValue) + .addPredicate(predicateFn: isBuffering(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInBuffer.rawValue) + .addPredicate(predicateFn: isSeeking(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInSeek.rawValue) + .addAction(actionFn: cmdPause(rule:context:)) + ruleEngine.add(rule: pause) + + let bufferStart = MediaRule(name: RuleName.BufferStart.rawValue, description: "API::trackEvent(BufferStart)") + bufferStart.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: allowPlaybackStateChange(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidPlaybackState.rawValue) + .addPredicate(predicateFn: isBuffering(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInBuffer.rawValue) + .addPredicate(predicateFn: isSeeking(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInSeek.rawValue) + .addAction(actionFn: cmdBufferStart(rule:context:)) + ruleEngine.add(rule: bufferStart) + + let bufferComplete = MediaRule(name: RuleName.BufferComplete.rawValue, description: "API::trackEvent(BufferComplete)") + bufferComplete.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: allowPlaybackStateChange(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidPlaybackState.rawValue) + .addPredicate(predicateFn: isBuffering(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInBuffer.rawValue) + .addAction(actionFn: cmdBufferComplete(rule:context:)) + ruleEngine.add(rule: bufferComplete) + + let seekStart = MediaRule(name: RuleName.SeekStart.rawValue, description: "API::trackEvent(SeekStart)") + seekStart.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: allowPlaybackStateChange(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidPlaybackState.rawValue) + .addPredicate(predicateFn: isSeeking(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInSeek.rawValue) + .addPredicate(predicateFn: isBuffering(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInBuffer.rawValue) + .addAction(actionFn: cmdSeekStart(rule:context:)) + ruleEngine.add(rule: seekStart) + + let seekComplete = MediaRule(name: RuleName.SeekComplete.rawValue, description: "API::trackEvent(SeekComplete)") + seekComplete.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: allowPlaybackStateChange(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidPlaybackState.rawValue) + .addPredicate(predicateFn: isSeeking(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInSeek.rawValue) + .addAction(actionFn: cmdSeekComplete(rule:context:)) + ruleEngine.add(rule: seekComplete) + + let adBreakStart = MediaRule(name: RuleName.AdBreakStart.rawValue, description: "API::trackEvent(AdBreakStart)") + adBreakStart.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isValidAdBreakInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidAdBreakInfo.rawValue) + .addPredicate(predicateFn: isDifferentAdBreakInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrDuplicateAdBreakInfo.rawValue) + .addAction(actionFn: cmdAdSkip(rule:context:)) + .addAction(actionFn: cmdAdBreakSkip(rule:context:)) + .addAction(actionFn: cmdAdBreakStart(rule:context:)) + ruleEngine.add(rule: adBreakStart) + + let adBreakComplete = MediaRule(name: RuleName.AdBreakComplete.rawValue, description: "API::trackEvent(AdBreakComplete)") + adBreakComplete.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isInAdBreak(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInAdBreak.rawValue) + .addAction(actionFn: cmdAdSkip(rule:context:)) + .addAction(actionFn: cmdAdBreakComplete(rule:context:)) + ruleEngine.add(rule: adBreakComplete) + + let adStart = MediaRule(name: RuleName.AdStart.rawValue, description: "API::trackEvent(AdStart)") + adStart.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isInAdBreak(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInAdBreak.rawValue) + .addPredicate(predicateFn: isValidAdInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidAdInfo.rawValue) + .addPredicate(predicateFn: isDifferentAdInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrDuplicateAdInfo.rawValue) + .addAction(actionFn: cmdAdSkip(rule:context:)) + .addAction(actionFn: cmdAdStart(rule:context:)) + ruleEngine.add(rule: adStart) + + let adComplete = MediaRule(name: RuleName.AdComplete.rawValue, description: "API::trackEvent(AdComplete)") + adComplete.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isInAdBreak(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInAdBreak.rawValue) + .addPredicate(predicateFn: isInAd(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInAd.rawValue) + .addAction(actionFn: cmdAdComplete(rule:context:)) + ruleEngine.add(rule: adComplete) + + let adSkip = MediaRule(name: RuleName.AdSkip.rawValue, description: "API::trackEvent(AdSkip)") + adSkip.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isInAdBreak(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInAdBreak.rawValue) + .addPredicate(predicateFn: isInAd(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInAd.rawValue) + .addAction(actionFn: cmdAdSkip(rule:context:)) + ruleEngine.add(rule: adSkip) + + let chapterStart = MediaRule(name: RuleName.ChapterStart.rawValue, description: "API::trackEvent(ChapterStart)") + chapterStart.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isValidChapterInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidChapterInfo.rawValue) + .addPredicate(predicateFn: isDifferentChapterInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrDuplicateChapterInfo.rawValue) + .addAction(actionFn: cmdChapterSkip(rule:context:)) + .addAction(actionFn: cmdChapterStart(rule:context:)) + ruleEngine.add(rule: chapterStart) + + let chapterComplete = MediaRule(name: RuleName.ChapterComplete.rawValue, description: "API::trackEvent(ChapterComplete") + chapterComplete.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isInChapter(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInChapter.rawValue) + .addAction(actionFn: cmdChapterComplete(rule:context:)) + ruleEngine.add(rule: chapterComplete) + + let chapterSkip = MediaRule(name: RuleName.ChapterSkip.rawValue, description: "API::trackEvent(ChapterSkip") + chapterSkip.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isInChapter(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInChapter.rawValue) + .addAction(actionFn: cmdChapterSkip(rule:context:)) + ruleEngine.add(rule: chapterSkip) + + let bitrateChange = MediaRule(name: RuleName.BitrateChange.rawValue, description: "API::trackEvent(BitrateChange)") + bitrateChange.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addAction(actionFn: cmdBitrateChange(rule:context:)) + ruleEngine.add(rule: bitrateChange) + + let qoeUpdate = MediaRule(name: RuleName.QoEUpdate.rawValue, description: "API::trackEvent(UpdateQoEInfo)") + qoeUpdate.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isValidQoEInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidQoEInfo.rawValue) + .addAction(actionFn: cmdQoEUpdate(rule:context:)) + ruleEngine.add(rule: qoeUpdate) + + let playheadUpdate = MediaRule(name: RuleName.PlayheadUpdate.rawValue, description: "API::trackEvent(UpdatePlayhead)") + playheadUpdate.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addAction(actionFn: cmdPlayheadUpdate(rule:context:)) + ruleEngine.add(rule: playheadUpdate) + + let stateStart = MediaRule(name: RuleName.StateStart.rawValue, description: "API::trackEvent(StateStart)") + stateStart.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isValidStateInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidStateInfo.rawValue) + .addPredicate(predicateFn: isTrackingState(rule:context:), expectedValue: false, errorMsg: ErrorMessage.ErrInTrackedState.rawValue) + .addPredicate(predicateFn: allowStateTrack(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrTrackedStatesLimitReached.rawValue) + .addAction(actionFn: cmdStateStart(rule:context:)) + ruleEngine.add(rule: stateStart) + + let stateEnd = MediaRule(name: RuleName.StateEnd.rawValue, description: "API::trackEvent(StateEnd)") + stateEnd.addPredicate(predicateFn: isInMedia(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInMedia.rawValue) + .addPredicate(predicateFn: isValidStateInfo(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrInvalidStateInfo.rawValue) + .addPredicate(predicateFn: isTrackingState(rule:context:), expectedValue: true, errorMsg: ErrorMessage.ErrNotInTrackedState.rawValue) + .addAction(actionFn: cmdStateEnd(rule:context:)) + ruleEngine.add(rule: stateEnd) + + } + + // MARK: Rule Predicates + private func isInMedia(rule: MediaRule, context: [String: Any]) -> Bool { + return mediaContext != nil + } + + private func isInAdBreak(rule: MediaRule, context: [String: Any]) -> Bool { + return mediaContext?.adBreakInfo != nil + } + + private func isInAd(rule: MediaRule, context: [String: Any]) -> Bool { + return mediaContext?.adInfo != nil + } + + private func isInChapter(rule: MediaRule, context: [String: Any]) -> Bool { + return mediaContext?.chapterInfo != nil + } + + private func isBuffering(rule: MediaRule, context: [String: Any]) -> Bool { + return mediaContext?.isInMediaPlaybackState(state: .Buffer) ?? false + } + + private func isSeeking(rule: MediaRule, context: [String: Any]) -> Bool { + return mediaContext?.isInMediaPlaybackState(state: .Seek) ?? false + } + + private func isTrackingState(rule: MediaRule, context: [String: Any]) -> Bool { + guard let state = StateInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + return mediaContext?.isInState(info: state) ?? false + } + + private func allowStateTrack(rule: MediaRule, context: [String: Any]) -> Bool { + guard let state = StateInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + return (mediaContext?.hasTrackedState(info: state) ?? false) || !(mediaContext?.didReachMaxStateLimit() ?? true) + } + + private func isValidMediaInfo(rule: MediaRule, context: [String: Any]) -> Bool { + return MediaInfo(info: context[Self.KEY_INFO] as? [String: Any]) != nil + } + + private func isValidAdBreakInfo(rule: MediaRule, context: [String: Any]) -> Bool { + return AdBreakInfo(info: context[Self.KEY_INFO] as? [String: Any]) != nil + } + + private func isValidAdInfo(rule: MediaRule, context: [String: Any]) -> Bool { + return AdInfo(info: context[Self.KEY_INFO] as? [String: Any]) != nil + } + + private func isValidChapterInfo(rule: MediaRule, context: [String: Any]) -> Bool { + return ChapterInfo(info: context[Self.KEY_INFO] as? [String: Any]) != nil + } + + private func isValidQoEInfo(rule: MediaRule, context: [String: Any]) -> Bool { + return QoEInfo(info: context[Self.KEY_INFO] as? [String: Any]) != nil + } + + private func isValidErrorInfo(rule: MediaRule, context: [String: Any]) -> Bool { + guard let errorInfo = context[Self.KEY_INFO] as? [String: Any] else { + return false + } + + guard let errorId = errorInfo[MediaConstants.ErrorInfo.ID] as? String, !errorId.isEmpty else { + return false + } + + return true + } + + private func isValidStateInfo(rule: MediaRule, context: [String: Any]) -> Bool { + return StateInfo(info: context[Self.KEY_INFO] as? [String: Any]) != nil + } + + private func isDifferentAdBreakInfo(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext else { + return false + } + + if mediaContext.adBreakInfo == nil { + return true + } + + let currAdBreak = mediaContext.adBreakInfo + let newAdBreak = AdBreakInfo(info: context[Self.KEY_INFO] as? [String: Any] ?? [:]) + return currAdBreak != newAdBreak + } + + private func isDifferentAdInfo(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext else { + return false + } + + if mediaContext.adInfo == nil { + return true + } + + let currAd = mediaContext.adInfo + let newAd = AdInfo(info: context[Self.KEY_INFO] as? [String: Any] ?? [:]) + return currAd != newAd + } + + private func isDifferentChapterInfo(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext else { + return false + } + + if mediaContext.chapterInfo == nil { + return true + } + + let currChapter = mediaContext.chapterInfo + let newChapter = ChapterInfo(info: context[Self.KEY_INFO] as? [String: Any] ?? [:]) + return currChapter != newChapter + } + + private func allowPlaybackStateChange(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext else { + return false + } + // Change of Playback State not allowed inside AdBreak but outside Ad + return (mediaContext.adBreakInfo == nil) || (mediaContext.adInfo != nil) + } + + // MARK: Rule Actions + private func cmdEnterAction(rule: MediaRule, context: [String: Any]) -> Bool { + xdmEventGenerator?.setRefTS(ts: getRefTS(context: context)) + + return true + } + + private func cmdExitAction(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext else { + // the mediaContext instance is expected to nil for trackComplete and trackSessionEnd + // fail for rest of the events + if rule.name != RuleName.MediaComplete.rawValue && rule.name != RuleName.MediaSkip.rawValue { + Log.trace(label: Self.LOG_TAG, "cmdExitAction - Cannot process (\(rule.description)) event since media context is nil") + return false + } + + return true + } + + // Force the state to play when adstart is received before any play/pause. + // Happens usually for preroll ad. Manually switch state to play as the backend + // automatically switches state to play after adstart. + if rule.name == RuleName.AdStart.rawValue { + if mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Init) && + !mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) && + !mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek) { + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + } + } + + // If BufferComplete / SeekComplete is called before first play / pause, + // manually switch to pause as there is not way to go back to init state. + if rule.name == RuleName.BufferComplete.rawValue || rule.name == RuleName.SeekComplete.rawValue { + if mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Init) { + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Pause) + } + } + + cmdIdleDetection(rule: rule, context: context) + cmdSessionTimeoutDetection(rule: rule, context: context) + cmdContentStartDetection(rule: rule, context: context) + + // Flush the playback state after AdStart and AdBreakComplete + let shouldFlush = (rule.name == RuleName.AdStart.rawValue) || (rule.name == RuleName.AdBreakComplete.rawValue) + + xdmEventGenerator?.processPlayback(doFlush: shouldFlush) + + return true + } + + private func cmdMediaStart(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaInfo = MediaInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + + guard let refEvent = context[Self.KEY_EVENT] as? Event else { + return false + } + + let metadata = getMetadata(context: context) + + let refTS = getRefTS(context: context) + + mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: metadata) + guard let mediaContext = mediaContext else { + Log.trace(label: Self.LOG_TAG, "cmdMediaStart - Cannot process SessionStart event since media context is null") + return false + } + + xdmEventGenerator = MediaXDMEventGenerator(context: mediaContext, eventProcessor: eventProcessor, config: trackerConfig ?? [:], refEvent: refEvent, refTS: refTS) + xdmEventGenerator?.processSessionStart() + + inPrerollInterval = mediaInfo.prerollWaitingTime != 0 + prerollRefTS = refTS + mediaSessionStartTS = refTS + + return true + } + + private func cmdMediaComplete(rule: MediaRule, context: [String: Any]) -> Bool { + xdmEventGenerator?.processSessionComplete() + reset() + + return true + } + + private func cmdMediaSkip(rule: MediaRule, context: [String: Any]) -> Bool { + xdmEventGenerator?.processSessionEnd() + reset() + + return true + } + + private func cmdAdBreakStart(rule: MediaRule, context: [String: Any]) -> Bool { + guard let adBreakInfo = AdBreakInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + mediaContext?.setAdBreakInfo(adBreakInfo) + xdmEventGenerator?.processAdBreakStart() + return true + } + + private func cmdAdBreakComplete(rule: MediaRule, context: [String: Any]) -> Bool { + mediaContext?.clearAdBreakInfo() + xdmEventGenerator?.processAdBreakComplete() + + return true + } + + private func cmdAdBreakSkip(rule: MediaRule, context: [String: Any]) -> Bool { + // This may be called even when not in adbreak + if mediaContext?.adBreakInfo != nil { + mediaContext?.clearAdBreakInfo() + xdmEventGenerator?.processAdBreakSkip() + } + return true + } + + private func cmdAdStart(rule: MediaRule, context: [String: Any]) -> Bool { + guard let adInfo = AdInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + let metadata = getMetadata(context: context) + + mediaContext?.setAdInfo(adInfo, metadata: metadata) + xdmEventGenerator?.processAdStart() + + return true + } + + private func cmdAdComplete(rule: MediaRule, context: [String: Any]) -> Bool { + mediaContext?.clearAdInfo() + xdmEventGenerator?.processAdComplete() + + return true + } + + private func cmdAdSkip(rule: MediaRule, context: [String: Any]) -> Bool { + // This may be called even when not in ad + if mediaContext?.adInfo != nil { + mediaContext?.clearAdInfo() + xdmEventGenerator?.processAdSkip() + } + + return true + } + + private func cmdChapterStart(rule: MediaRule, context: [String: Any]) -> Bool { + guard let chapterInfo = ChapterInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + let metadata = getMetadata(context: context) + + mediaContext?.setChapterInfo(chapterInfo, metadata: metadata) + xdmEventGenerator?.processChapterStart() + + return true + } + + private func cmdChapterComplete(rule: MediaRule, context: [String: Any]) -> Bool { + mediaContext?.clearChapterInfo() + xdmEventGenerator?.processChapterComplete() + + return true + } + + private func cmdChapterSkip(rule: MediaRule, context: [String: Any]) -> Bool { + // This may be called even when not in chapter + if mediaContext?.chapterInfo != nil { + mediaContext?.clearChapterInfo() + xdmEventGenerator?.processChapterSkip() + } + return true + } + + private func cmdError(rule: MediaRule, context: [String: Any]) -> Bool { + if let errorId = getError(context: context) { + xdmEventGenerator?.processError(errorId: errorId) + } + return true + } + + private func cmdBitrateChange(rule: MediaRule, context: [String: Any]) -> Bool { + xdmEventGenerator?.processBitrateChange() + return true + } + + private func cmdPlay(rule: MediaRule, context: [String: Any]) -> Bool { + mediaContext?.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + return true + } + + private func cmdPause(rule: MediaRule, context: [String: Any]) -> Bool { + mediaContext?.enterPlaybackState(state: MediaContext.MediaPlaybackState.Pause) + return true + } + + private func cmdBufferStart(rule: MediaRule, context: [String: Any]) -> Bool { + mediaContext?.enterPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + return true + } + + private func cmdBufferComplete(rule: MediaRule, context: [String: Any]) -> Bool { + if mediaContext?.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) ?? false { + mediaContext?.exitPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + } + return true + } + + private func cmdSeekStart(rule: MediaRule, context: [String: Any]) -> Bool { + mediaContext?.enterPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + return true + } + + private func cmdSeekComplete(rule: MediaRule, context: [String: Any]) -> Bool { + if mediaContext?.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek) ?? false { + mediaContext?.exitPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + } + return true + } + + private func cmdStateStart(rule: MediaRule, context: [String: Any]) -> Bool { + guard let stateInfo = StateInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + mediaContext?.startState(info: stateInfo) + xdmEventGenerator?.processStateStart(stateInfo: stateInfo) + + return true + } + + private func cmdStateEnd(rule: MediaRule, context: [String: Any]) -> Bool { + guard let stateInfo = StateInfo(info: context[Self.KEY_INFO] as? [String: Any]) else { + return false + } + mediaContext?.endState(info: stateInfo) + xdmEventGenerator?.processStateEnd(stateInfo: stateInfo) + + return true + } + + private func cmdQoEUpdate(rule: MediaRule, context: [String: Any]) -> Bool { + if let qoeInfo = QoEInfo(info: context[Self.KEY_INFO] as? [String: Any]) { + mediaContext?.qoeInfo = qoeInfo + return true + } + return false + } + + private func cmdPlayheadUpdate(rule: MediaRule, context: [String: Any]) -> Bool { + if let playhead = getPlayhead(context: context) { + mediaContext?.playhead = playhead + return true + } + return false + } + + /// Check the duration of current session and abort if active for more than 24 hours. + /// - Parameters: + /// - rule: EventName corresponding to API call. + /// - context: Data passed with the corresponding API call. + @discardableResult + private func cmdSessionTimeoutDetection(rule: MediaRule, context: [String: Any]) -> Bool { + let refTS = getRefTS(context: context) + + if mediaSessionStartTS == Self.INVALID_TS { + mediaSessionStartTS = refTS + } + + if !trackerIdle && ((refTS - mediaSessionStartTS) >= Self.MEDIA_SESSION_TIMEOUT_MS) { + xdmEventGenerator?.processSessionAbort() + xdmEventGenerator?.processSessionRestart() + + mediaSessionStartTS = refTS + contentStarted = false + contentStartRefTS = Self.INVALID_TS + } + + return true + } + + /// Detect if the player is idle (not in play) and abort current session if idle for more than 30 minutes. + /// - Parameters: + /// - rule: EventName corresponding to API call. + /// - context: Data passed with the corresponding API call. + @discardableResult + private func cmdIdleDetection(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext else { + return false + } + + if mediaContext.isIdle() { + let refTS = getRefTS(context: context) + if mediaIdle { + // Media was already idle during previous call + if !trackerIdle && (refTS - mediaIdleStartTS) >= Self.IDLE_TIMEOUT_MS { + // Stop tracking if media has been idle for 30 mins + xdmEventGenerator?.processSessionAbort() + trackerIdle = true + } + } else { + mediaIdle = true + mediaIdleStartTS = refTS + } + } else { + // Media is not currently idle + if trackerIdle { + // Resume tracking if tracking was stopped + xdmEventGenerator?.processSessionRestart() + trackerIdle = false + // Reset content started flag if media is idle + contentStarted = false + contentStartRefTS = Self.INVALID_TS + mediaSessionStartTS = Self.INVALID_TS + } + + mediaIdle = false + } + + return true + } + + /// Handle content start (play) ping. Sends 1 play ping per session after detecting the first second of main content playback. + /// - Parameters: + /// - rule: EventName corresponding to API call. + /// - context: Data passed with the corresponding API call. + @discardableResult + private func cmdContentStartDetection(rule: MediaRule, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext else { + return false + } + + if mediaContext.isIdle() || contentStarted { + return true + } + + if mediaContext.adBreakInfo != nil { + // Reset the timer if in AdBreak and contentStart ping is not sent + contentStartRefTS = Self.INVALID_TS + return true + } + + let refTS = getRefTS(context: context) + if contentStartRefTS == Self.INVALID_TS { + contentStartRefTS = refTS + } + + if (refTS - contentStartRefTS) >= Self.CONTENT_START_DURATION_MS { + xdmEventGenerator?.processPlayback(doFlush: true) + contentStarted = true + } + + return true + } + + // MARK: Preroll Rule Helpers + + // Remove the trackPlay calls before AdBreakStart for preroll ads to avoid incorrect content start on reporting side + func prerollReorderRules(rules: [(name: RuleName, context: [String: Any])]) ->[(name: RuleName, context: [String: Any])] { + var reorderedRules: [(name: RuleName, context: [String: Any])] = [] + var adBreakStart: (name: RuleName, context: [String: Any])? + + for rule in rules where rule.name == RuleName.AdBreakStart { + adBreakStart = rule + break + } + + var dropPlay = adBreakStart != nil + for rule in rules { + if rule.name == RuleName.Play && dropPlay { + continue + } + + if dropPlay && rule.name == RuleName.AdBreakStart { + dropPlay = false + } + + reorderedRules.append(rule) + } + + return reorderedRules + } + + // Check if there is a need to wait for PrerollWaitTime for preroll ads before executing any track calls + func prerollDeferRule(rule: RuleName, context: [String: Any]) -> Bool { + guard let mediaContext = mediaContext, inPrerollInterval else { + return false + } + let prerollWaitingtime = Int64(mediaContext.mediaInfo.prerollWaitingTime) + // Queue the events and stop further downstream processing for preroll_waiting_time ms + prerollQueuedRules.append((name: rule, context: context)) + + let refTS = getRefTS(context: context) + + if (refTS - prerollRefTS) >= prerollWaitingtime || + rule == RuleName.AdBreakStart || + rule == RuleName.MediaComplete || + rule == RuleName.MediaSkip { + + // If preroll_waiting_time has elapsed or any of these rules are triggered, start processing all the queued rules + let reorderedRules = prerollReorderRules(rules: prerollQueuedRules) + + for orderedRule in reorderedRules { + processRule(rule: orderedRule.name, context: orderedRule.context) + } + + prerollQueuedRules.removeAll() + inPrerollInterval = false + } + + return true + } + + // MARK: Event Data Helpers + private func cleanMetadata(data: [String: String]) -> [String: String] { + var cleanData: [String: String] = [:] + let pattern = ("^[a-zA-Z0-9_\\.]+$") + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return data + } + + for (key, value) in data { + let matches = regex.matches(in: key, options: [], range: NSRange(location: 0, length: key.count)) + if matches.isEmpty { + Log.trace(label: Self.LOG_TAG, "CleanMetadata - Dropping metadata entry key:\"\(key)\" value:\"\(value)\". Key should contain only alphabets, digits, '_' and '.'.") + } else { + cleanData[key] = value + } + } + + return cleanData + } + + func getMetadata(context: [String: Any]) -> [String: String] { + guard let metadata = context[Self.KEY_METADATA] as? [String: String] else { + return [:] + } + + return metadata + } + + func getError(context: [String: Any]) -> String? { + guard let errorInfo = context[Self.KEY_INFO] as? [String: Any] else { + return nil + } + + guard let errorId = errorInfo[MediaConstants.ErrorInfo.ID] as? String else { + return nil + } + + return errorId + } + + func getPlayhead(context: [String: Any]) -> Double? { + guard let playheadInfo = context[Self.KEY_INFO] as? [String: Any] else { + return nil + } + + guard let playhead = playheadInfo[MediaConstants.Tracker.PLAYHEAD] as? Double else { + return nil + } + + return playhead + } + + func getRefTS(context: [String: Any]) -> Int64 { + guard let ts = context[Self.KEY_EVENT_TS] as? Int64 else { + return 0 + } + + return ts + } +} diff --git a/Sources/MediaEventTracking.swift b/Sources/MediaEventTracking.swift new file mode 100644 index 0000000..4777e3c --- /dev/null +++ b/Sources/MediaEventTracking.swift @@ -0,0 +1,20 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import Foundation + +protocol MediaEventTracking { + + @discardableResult + func track(event: Event) -> Bool +} diff --git a/Sources/MediaObject/AdBreakInfo.swift b/Sources/MediaObject/AdBreakInfo.swift new file mode 100644 index 0000000..0722fbc --- /dev/null +++ b/Sources/MediaObject/AdBreakInfo.swift @@ -0,0 +1,82 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class AdBreakInfo: Equatable { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "AdBreakInfo" + let name: String + let position: Int + let startTime: Double + + static func == (lhs: AdBreakInfo, rhs: AdBreakInfo) -> Bool { + return lhs.name == rhs.name && + lhs.position == rhs.position && + lhs.startTime.isAlmostEqual(rhs.startTime) + } + + init?(name: String, position: Int, startTime: Double) { + + guard !name.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdBreakInfo, name must not be Empty") + return nil + } + + guard position >= 1 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdBreakInfo, position must be greater than zero") + return nil + } + + guard startTime >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdBreakInfo, start time must not be less than zero") + return nil + } + + self.name = name + self.position = position + self.startTime = startTime + } + + convenience init?(info: [String: Any]?) { + guard info != nil else { + return nil + } + + guard let name = info?[MediaConstants.AdBreakInfo.NAME] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing AdBreakInfo, invalid name") + return nil + } + + guard let position = info?[MediaConstants.AdBreakInfo.POSITION] as? Int else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing AdBreakInfo, invalid position") + return nil + } + + guard let startTime = info?[MediaConstants.AdBreakInfo.START_TIME] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing AdBreakInfo, invalid start time") + return nil + } + + self.init(name: name, position: position, startTime: startTime) + } + + func toMap() -> [String: Any] { + var adBreakInfoMap: [String: Any] = [:] + adBreakInfoMap[MediaConstants.AdBreakInfo.NAME] = self.name + adBreakInfoMap[MediaConstants.AdBreakInfo.POSITION] = self.position + adBreakInfoMap[MediaConstants.AdBreakInfo.START_TIME] = self.startTime + + return adBreakInfoMap + } +} diff --git a/Sources/MediaObject/AdInfo.swift b/Sources/MediaObject/AdInfo.swift new file mode 100644 index 0000000..fbfd3ef --- /dev/null +++ b/Sources/MediaObject/AdInfo.swift @@ -0,0 +1,96 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class AdInfo: Equatable { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "AdInfo" + let id: String + let name: String + let position: Int + let length: Double + + static func == (lhs: AdInfo, rhs: AdInfo) -> Bool { + return lhs.id == rhs.id && + lhs.name == rhs.name && + lhs.position == rhs.position && + lhs.length.isAlmostEqual(rhs.length) + } + + init?(id: String, name: String, position: Int, length: Double) { + + guard !id.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdInfo, id must not be Empty") + return nil + } + + guard !name.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdInfo, name must not be Empty") + return nil + } + + guard position >= 1 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdBreakInfo, position must be greater than zero") + return nil + } + + guard length >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdInfo, start time must not be less than zero") + return nil + } + + self.id = id + self.name = name + self.position = position + self.length = length + } + + convenience init?(info: [String: Any]?) { + guard info != nil else { + return nil + } + + guard let id = info?[MediaConstants.AdInfo.ID] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing AdInfo, invalid id") + return nil + } + + guard let name = info?[MediaConstants.AdInfo.NAME] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing AdInfo, invalid name") + return nil + } + + guard let position = info?[MediaConstants.AdInfo.POSITION] as? Int else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing AdInfo, invalid position") + return nil + } + + guard let length = info?[MediaConstants.AdInfo.LENGTH] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing AdInfo, invalid length") + return nil + } + + self.init(id: id, name: name, position: position, length: length) + } + + func toMap() -> [String: Any] { + var adInfoMap: [String: Any] = [:] + adInfoMap[MediaConstants.AdInfo.ID] = self.id + adInfoMap[MediaConstants.AdInfo.NAME] = self.name + adInfoMap[MediaConstants.AdInfo.POSITION] = self.position + adInfoMap[MediaConstants.AdInfo.LENGTH] = self.length + + return adInfoMap + } +} diff --git a/Sources/MediaObject/ChapterInfo.swift b/Sources/MediaObject/ChapterInfo.swift new file mode 100644 index 0000000..36318e7 --- /dev/null +++ b/Sources/MediaObject/ChapterInfo.swift @@ -0,0 +1,96 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class ChapterInfo: Equatable { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "ChapterInfo" + let name: String + let position: Int + let startTime: Double + let length: Double + + static func == (lhs: ChapterInfo, rhs: ChapterInfo) -> Bool { + return lhs.name == rhs.name && + lhs.position == rhs.position && + lhs.startTime.isAlmostEqual(rhs.startTime) && + lhs.length.isAlmostEqual(rhs.length) + } + + init?(name: String, position: Int, startTime: Double, length: Double) { + + guard !name.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating ChapterInfo, name must not be empty") + return nil + } + + guard position >= 1 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating AdBreakInfo, position must be greater than zero") + return nil + } + + guard startTime >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating ChapterInfo, start time must not be less than zero") + return nil + } + + guard length >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating ChapterInfo, length must not be less than zero") + return nil + } + + self.name = name + self.position = position + self.startTime = startTime + self.length = length + } + + convenience init?(info: [String: Any]?) { + guard info != nil else { + return nil + } + + guard let name = info?[MediaConstants.ChapterInfo.NAME] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing ChapterInfo, invalid name") + return nil + } + + guard let position = info?[MediaConstants.ChapterInfo.POSITION] as? Int else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing ChapterInfo, invalid position") + return nil + } + + guard let startTime = info?[MediaConstants.ChapterInfo.START_TIME] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing ChapterInfo, invalid start time") + return nil + } + + guard let length = info?[MediaConstants.ChapterInfo.LENGTH] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing ChapterInfo, invalid length") + return nil + } + + self.init(name: name, position: position, startTime: startTime, length: length) + } + + func toMap() -> [String: Any] { + var chapterInfoMap: [String: Any] = [:] + chapterInfoMap[MediaConstants.ChapterInfo.NAME] = self.name + chapterInfoMap[MediaConstants.ChapterInfo.POSITION] = self.position + chapterInfoMap[MediaConstants.ChapterInfo.START_TIME] = self.startTime + chapterInfoMap[MediaConstants.ChapterInfo.LENGTH] = self.length + + return chapterInfoMap + } +} diff --git a/Sources/MediaObject/MediaInfo.swift b/Sources/MediaObject/MediaInfo.swift new file mode 100644 index 0000000..bb8f49c --- /dev/null +++ b/Sources/MediaObject/MediaInfo.swift @@ -0,0 +1,129 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class MediaInfo: Equatable { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaInfo" + static let DEFAULT_PREROLL_WAITING_TIME_IN_MS: Int = 250 // 250 milliseconds + let id: String + let name: String + let streamType: String + let mediaType: MediaType + let length: Double + let resumed: Bool + let prerollWaitingTime: Int + let granularAdTracking: Bool + + static func == (lhs: MediaInfo, rhs: MediaInfo) -> Bool { + return lhs.id == rhs.id && + lhs.name == rhs.name && + lhs.streamType == rhs.streamType && + lhs.mediaType == rhs.mediaType && + lhs.length.isAlmostEqual(rhs.length) && + lhs.resumed == rhs.resumed && + lhs.prerollWaitingTime == rhs.prerollWaitingTime && + lhs.granularAdTracking == rhs.granularAdTracking + } + + init?(id: String, name: String, streamType: String, mediaType: MediaType, length: Double, resumed: Bool = false, prerollWaitingTime: Int = DEFAULT_PREROLL_WAITING_TIME_IN_MS, granularAdTracking: Bool = false) { + + guard !id.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating MediaInfo, id must not be Empty") + return nil + } + + guard !name.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating MediaInfo, name must not be Empty") + return nil + } + + guard !streamType.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating MediaInfo, stream type must not be Empty") + return nil + } + + guard length >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating MediaInfo, length must not be less than zero") + return nil + } + + self.id = id + self.name = name + self.streamType = streamType + self.mediaType = mediaType + self.length = length + self.resumed = resumed + self.prerollWaitingTime = prerollWaitingTime + self.granularAdTracking = granularAdTracking + } + + convenience init?(info: [String: Any]?) { + guard info != nil else { + return nil + } + + guard let id = info?[MediaConstants.MediaInfo.ID] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing MediaInfo, invalid id") + return nil + } + + guard let name = info?[MediaConstants.MediaInfo.NAME] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing MediaInfo, invalid name") + return nil + } + + guard let streamType = info?[MediaConstants.MediaInfo.STREAM_TYPE] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing MediaInfo, invalid stream type. Sample values -> {\"VOD\", \"LIVE\" ...}") + return nil + } + + guard let mediaTypeString = info?[MediaConstants.MediaInfo.MEDIA_TYPE] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing MediaInfo, invalid media type. Valid values -> {\"video\", \"audio\"}") + return nil + } + + guard let mediaType: MediaType = MediaType(rawValue: mediaTypeString) else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing MediaInfo, invalid media type") + return nil + } + + guard let length = info?[MediaConstants.MediaInfo.LENGTH] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing MediaInfo, invalid length") + return nil + } + + let resumed = info?[MediaConstants.MediaInfo.RESUMED] as? Bool ?? false + + let prerollWaitingTime: Int = info?[MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME] as? Int ?? Self.DEFAULT_PREROLL_WAITING_TIME_IN_MS + + let granularAdTracking = info?[MediaConstants.MediaInfo.GRANULAR_AD_TRACKING] as? Bool ?? false + + self.init(id: id, name: name, streamType: streamType, mediaType: mediaType, length: length, resumed: resumed, prerollWaitingTime: prerollWaitingTime, granularAdTracking: granularAdTracking) + } + + func toMap() -> [String: Any] { + var mediaInfoMap: [String: Any] = [:] + mediaInfoMap[MediaConstants.MediaInfo.ID] = self.id + mediaInfoMap[MediaConstants.MediaInfo.NAME] = self.name + mediaInfoMap[MediaConstants.MediaInfo.LENGTH] = self.length + mediaInfoMap[MediaConstants.MediaInfo.STREAM_TYPE] = self.streamType + mediaInfoMap[MediaConstants.MediaInfo.MEDIA_TYPE] = self.mediaType.rawValue + mediaInfoMap[MediaConstants.MediaInfo.RESUMED] = self.resumed + mediaInfoMap[MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME] = self.prerollWaitingTime + mediaInfoMap[MediaConstants.MediaInfo.GRANULAR_AD_TRACKING] = self.granularAdTracking + + return mediaInfoMap + } +} diff --git a/Sources/MediaObject/QoEInfo.swift b/Sources/MediaObject/QoEInfo.swift new file mode 100644 index 0000000..573990b --- /dev/null +++ b/Sources/MediaObject/QoEInfo.swift @@ -0,0 +1,95 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class QoEInfo: Equatable { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "QoEInfo" + let bitrate: Double + let droppedFrames: Double + let fps: Double + let startupTime: Double + + static func == (lhs: QoEInfo, rhs: QoEInfo) -> Bool { + return lhs.bitrate.isAlmostEqual(rhs.bitrate) && + lhs.droppedFrames.isAlmostEqual(rhs.droppedFrames) && + lhs.fps.isAlmostEqual(rhs.fps) && + lhs.startupTime.isAlmostEqual(rhs.startupTime) + } + + init?(bitrate: Double, droppedFrames: Double, fps: Double, startupTime: Double) { + guard bitrate >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating QoEInfo, bitrate must not be less than zero") + return nil + } + + guard droppedFrames >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating QoEInfo, dropped frames must not be less than zero") + return nil + } + + guard fps >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating QoEInfo, fps must not be less than zero") + return nil + } + + guard startupTime >= 0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating QoEInfo, startup time must not be less than zero") + return nil + } + + self.bitrate = bitrate + self.droppedFrames = droppedFrames + self.fps = fps + self.startupTime = startupTime + } + + convenience init?(info: [String: Any]?) { + guard info != nil else { + return nil + } + + guard let bitrate = info?[MediaConstants.QoEInfo.BITRATE] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing QoEInfo, invalid bitrate") + return nil + } + + guard let droppedFrames = info?[MediaConstants.QoEInfo.DROPPED_FRAMES] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing QoEInfo, invalid dropped frames") + return nil + } + + guard let fps = info?[MediaConstants.QoEInfo.FPS] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing QoEInfo, invalid fps") + return nil + } + + guard let startupTime = info?[MediaConstants.QoEInfo.STARTUP_TIME] as? Double else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing QoEInfo, invalid start time") + return nil + } + + self.init(bitrate: bitrate, droppedFrames: droppedFrames, fps: fps, startupTime: startupTime) + } + + func toMap() -> [String: Any] { + var qoeInfoMap: [String: Any] = [:] + qoeInfoMap[MediaConstants.QoEInfo.BITRATE] = self.bitrate + qoeInfoMap[MediaConstants.QoEInfo.DROPPED_FRAMES] = self.droppedFrames + qoeInfoMap[MediaConstants.QoEInfo.FPS] = self.fps + qoeInfoMap[MediaConstants.QoEInfo.STARTUP_TIME] = self.startupTime + + return qoeInfoMap + } +} diff --git a/Sources/MediaObject/StateInfo.swift b/Sources/MediaObject/StateInfo.swift new file mode 100644 index 0000000..492e474 --- /dev/null +++ b/Sources/MediaObject/StateInfo.swift @@ -0,0 +1,65 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class StateInfo: Equatable { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "StateInfo" + let stateName: String + + static func == (lhs: StateInfo, rhs: StateInfo) -> Bool { + return lhs.stateName == rhs.stateName + } + + init?(stateName: String) { + guard !stateName.isEmpty else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating StateInfo, state name cannot be empty") + return nil + } + let pattern = "^[a-zA-Z0-9_\\.]{1,64}$" + do { + let regex = try NSRegularExpression(pattern: pattern, options: []) + let matches = regex.matches(in: stateName, options: [], range: NSRange(location: 0, length: stateName.count)) + if matches.isEmpty { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating StateInfo. State name: \(stateName) with length: \(stateName.count)" + + " cannot contain special characters and can only be 64 character long. Only alphabets, digits, '_' and '.' are allowed.") + return nil + } + } catch { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Invalid regex pattern") + } + + self.stateName = stateName + } + + convenience init?(info: [String: Any]?) { + guard info != nil else { + return nil + } + + guard let stateName = info?[MediaConstants.StateInfo.STATE_NAME_KEY] as? String else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error parsing StateInfo, no state name") + return nil + } + + self.init(stateName: stateName) + } + + func toMap() -> [String: Any]? { + var stateInfoMap: [String: Any] = [:] + stateInfoMap[MediaConstants.StateInfo.STATE_NAME_KEY] = self.stateName + + return stateInfoMap + } +} diff --git a/Sources/MediaPublicTracker.swift b/Sources/MediaPublicTracker.swift new file mode 100644 index 0000000..c375718 --- /dev/null +++ b/Sources/MediaPublicTracker.swift @@ -0,0 +1,195 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import Foundation + +class MediaPublicTracker: MediaTracker { + + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaPublicTracker" + + typealias DispatchFn = (Event) -> Void + + let TICK_INTERVAL = TimeInterval(0.75) + let EVENT_TIMEOUT_MS: Int64 = 500 + private let dispatchQueue: DispatchQueue = DispatchQueue(label: LOG_TAG) + + var dispatch: DispatchFn? + let config: [String: Any]? + let trackerId: String + var sessionId: String + var inSession = true + var lastEventTs: Int64 = 0 + var lastPlayheadParams: [String: Any]? + var timer: Timer? + + // MediaTracker Impl + init(dispatch: DispatchFn?, config: [String: Any]?) { + self.dispatch = dispatch + self.config = config + self.trackerId = UUID().uuidString + self.sessionId = UUID().uuidString + + let eventData: [String: Any] = [ + MediaConstants.Tracker.ID: self.trackerId, + MediaConstants.Tracker.EVENT_PARAM: self.config ?? [:] + ] + let event = Event(name: MediaConstants.Media.EVENT_NAME_CREATE_TRACKER, + type: MediaConstants.Media.EVENT_TYPE, + source: MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST, + data: eventData) + + dispatch?(event) + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>]: Tracker request event was sent to event hub.") + } + + deinit { + stopTimer() + } + + public func trackSessionStart(info: [String: Any], metadata: [String: String]? = nil) { + dispatchQueue.async { + self.trackInternal(eventName: MediaConstants.EventName.SESSION_START, params: info, metadata: metadata) + self.startTimer() + } + } + + public func trackPlay() { + dispatchQueue.async { + self.trackInternal(eventName: MediaConstants.EventName.PLAY) + } + } + + public func trackPause() { + dispatchQueue.async { + self.trackInternal(eventName: MediaConstants.EventName.PAUSE) + } + } + + public func trackComplete() { + dispatchQueue.async { + self.trackInternal(eventName: MediaConstants.EventName.COMPLETE) + } + } + + public func trackSessionEnd() { + dispatchQueue.async { + self.trackInternal(eventName: MediaConstants.EventName.SESSION_END) + } + } + + public func trackError(errorId: String) { + dispatchQueue.async { + let params: [String: Any] = [MediaConstants.ErrorInfo.ID: errorId] + self.trackInternal(eventName: MediaConstants.EventName.ERROR, params: params) + } + } + + public func trackEvent(event: MediaEvent, info: [String: Any]? = nil, metadata: [String: String]? = nil) { + dispatchQueue.async { + self.trackInternal(eventName: event.rawValue, params: info, metadata: metadata) + } + } + + public func updateCurrentPlayhead(time: Double) { + dispatchQueue.async { + let params: [String: Any] = [MediaConstants.Tracker.PLAYHEAD: time] + self.trackInternal(eventName: MediaConstants.EventName.PLAYHEAD_UPDATE, params: params) + } + } + + public func updateQoEObject(qoe: [String: Any]) { + dispatchQueue.async { + self.trackInternal(eventName: MediaConstants.EventName.QOE_UPDATE, params: qoe) + } + } + + private func trackInternal(eventName: String, params: [String: Any]? = nil, metadata: [String: String]? = nil, internalEvent: Bool = false) { + if eventName == MediaConstants.EventName.SESSION_START { + // Internal Tracker starts a new session only when we are not in an active session and we follow the same. + if !inSession, MediaInfo(info: params) != nil { + sessionId = UUID().uuidString + inSession = true + } + } else if eventName == MediaConstants.EventName.COMPLETE || eventName == MediaConstants.EventName.SESSION_END { + inSession = false + } + + var eventData: [String: Any] = [:] + eventData[MediaConstants.Tracker.ID] = self.trackerId + eventData[MediaConstants.Tracker.SESSION_ID] = self.sessionId + eventData[MediaConstants.Tracker.EVENT_NAME] = eventName + eventData[MediaConstants.Tracker.EVENT_INTERNAL] = internalEvent + + if params != nil { + eventData[MediaConstants.Tracker.EVENT_PARAM] = params + } + + if metadata != nil { + eventData[MediaConstants.Tracker.EVENT_METADATA] = metadata + } + + let ts = getCurrentTimeStamp() + eventData[MediaConstants.Tracker.EVENT_TIMESTAMP] = ts + + let event = Event(name: MediaConstants.Media.EVENT_NAME_TRACK_MEDIA, type: MediaConstants.Media.EVENT_TYPE, source: MediaConstants.Media.EVENT_SOURCE_TRACK_MEDIA, data: eventData) + + dispatch?(event) + + lastEventTs = ts + if eventName == MediaConstants.EventName.PLAYHEAD_UPDATE && params != nil { + lastPlayheadParams = params + } + } + + private func tick() { + dispatchQueue.async { + guard self.inSession else { + return + } + + let currentTs = self.getCurrentTimeStamp() + if (currentTs - self.lastEventTs) > self.EVENT_TIMEOUT_MS { + // We have not got any public api call for 500 ms. + // We manually send an event to keep our internal processsing alive (idle tracking / ping processing). + self.trackInternal(eventName: MediaConstants.EventName.PLAYHEAD_UPDATE, params: self.lastPlayheadParams, internalEvent: true) + } + } + } + + private func startTimer() { + if timer == nil { + timer = Timer.scheduledTimer(withTimeInterval: TICK_INTERVAL, repeats: true, block: { _ in + self.tick() + }) + timer?.fire() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + func getCurrentTimeStamp() -> Int64 { + return Date().millisecondsSince1970 + } +} + +private extension Date { + var millisecondsSince1970: Int64 { + return Int64((timeIntervalSince1970 * 1000.0).rounded()) + } + +} diff --git a/Sources/MediaRealTimeSession.swift b/Sources/MediaRealTimeSession.swift new file mode 100644 index 0000000..da7eb23 --- /dev/null +++ b/Sources/MediaRealTimeSession.swift @@ -0,0 +1,270 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import Foundation + +class MediaRealTimeSession: MediaSession { + + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaRealTimeSession" + + private var lastHitTS: Int64 = 0 + + #if DEBUG + var mediaBackendSessionId: String = "" + var sessionStartEdgeRequestId: String? + var events: [MediaXDMEvent] = [] + #else + private var mediaBackendSessionId: String = "" + private var sessionStartEdgeRequestId: String? + private var events: [MediaXDMEvent] = [] + #endif + + typealias ErrorData = MediaConstants.Edge.ErrorData + typealias ErrorKeys = MediaConstants.Edge.ErrorKeys + + /// Handles media state update. Triggers the dispatch loop if it was halted waiting for media state properties. + override func handleMediaStateUpdate() { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id))] Handling media state update.") + + // Trigger the event dispatch loop if it was blocked by the required media state properties + tryDispatchExperienceEvent() + } + + /// Add media events to the queue. + override func handleQueueEvent(_ event: MediaXDMEvent) { + if !isSessionActive { + return + } + + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id))] Queuing media event (\(event.eventType)).") + events.append(event) + + // Start processing and dispatching media events + tryDispatchExperienceEvent() + } + + /// handles media session end scenario. + override func handleSessionEnd() { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id))] Ending media session.") + + // Trigger the event dispatch loop and ensure all the events are dispatched before ending the session + tryDispatchExperienceEvent() + } + + /// Handles session abort scenario. + override func handleSessionAbort() { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] -[Session (\(id))] Aborting media.") + events.removeAll() + sessionEndHandler?() + } + + /// Handles media backend session id dispatched by the edge extension. + /// If valid backend session id is found it dispatches the session created event and starts dispatching subsequent media events. In case of invalid session id, the media session is aborted and no events are dispatched. + /// - Parameters: + /// - requestEventId: A `String` UUID for the edge request event. + /// - backendSessionId: A `String` UUID representing the session id returned by the backend. + override func handleSessionUpdate(requestEventId: String, backendSessionId: String?) { + if sessionStartEdgeRequestId != requestEventId { + return + } + + // If valid backendSessionId is received dispatch the sessionCreated event and start processing the queued media events + if updateBackendSessionId(backendSessionId) { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id)] Updating MediaEdge session with backendSessionId:(\(mediaBackendSessionId)).") + dispatchSessionCreatedEvent() + tryDispatchExperienceEvent() + + } else { + // Unable to update backend session id as it is invalid, so abort the session + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id)] Dropping the current tracking media session as invalid session id returned by the backend.") + abort(onSessionEnd: sessionEndHandler) + } + } + + /// Handles error response dispatched by the edge extension. Aborts the media session if the error code is `ErrorData.ERROR_CODE_400` and error type is `ErrorData.ERROR_TYPE_VA_EDGE_400`. + /// - Parameters: + /// - requestEventId: A `String` UUID for the edge request event. + /// - data: A dictionary with error details returned by the backend. + override func handleErrorResponse(requestEventId: String, data: [String: Any?]) { + if sessionStartEdgeRequestId != requestEventId { + // Error is not for the events dispatched in this session + return + } + + guard let statusCode = data[ErrorKeys.STATUS] as? Int64, let errorType = data[ErrorKeys.TYPE] as? String else { + return + } + + if statusCode == ErrorData.ERROR_CODE_400 && errorType.caseInsensitiveCompare(ErrorData.ERROR_TYPE_VA_EDGE_400) == .orderedSame { + // Abort the session as the sessionStart request failed + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id)] Aborting session as error occured while dispatching" + + "\(XDMMediaEventType.sessionStart.rawValue) request. Error payload: (\(data))") + abort() + } + } + + /// Sends the Media Edge `Event` with XDM data to the edge extension + private func tryDispatchExperienceEvent() { + if events.isEmpty { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id)] Exiting as there are no events to be dispatched.") + return + } + + guard let dispatcher = dispatcher else { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id)] Exiting as event dispatcher not found.") + return + } + + guard state.hasRequiredConfiguration() else { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id)] Exiting as the required configuration is missing, verify channel and playerName are configured.") + return + } + + while !events.isEmpty { + var event = events[0] + + if !isReadyToDispatchEvent(eventType: event.eventType) { + break + } + + attachMediaStateInfo(to: &event) + + generateMediaEdgeEventAndDispatch(dispatcher: dispatcher, event: event) + + // Remove the processed event from the list + events.removeFirst() + } + + // Check if session has ended + // Call the sessionEndHandler closure after processing all the events if the session is not active + if events.isEmpty && !isSessionActive { + sessionEndHandler?() + return + } + } + + /// Checks if current event can be dispatched based on type and backend session id. It specifically checks if mediaBackendSessionId is available for all the media events except sessionStart. + /// - Parameter eventType: Current `XDMMediaEventType`. + /// - Returns: `True` for sessionStart event or when `mediaBackendSessionId` is available for other events. + func isReadyToDispatchEvent(eventType: XDMMediaEventType) -> Bool { + if eventType != XDMMediaEventType.sessionStart && mediaBackendSessionId.isEmpty { + // Ensure media backend session id is present for events other than sessionStart + // If not present wait till the session id updates as a response from edge extension + // The session aborts in case the sessionStart event returns error response + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - [Session (\(id)] Exiting as the media session id is unavailable, will retry later.") + return false + } + + return true + } + + /// Creates XDM formatted Media Edge `Event` object from the internal `MediaXDMEvent` object and dispatches the resulting `Event`. + /// - Parameters: + /// - dispatcher: A closure used for dispatching `Event`. + /// - event:A `MediaXDMEvent` object to be converted to `Event` and then dispatched. + private func generateMediaEdgeEventAndDispatch(dispatcher: ((_ event: Event) -> Void), event: MediaXDMEvent) { + let mediaEdgeEvent: Event = getXDMFormattedMediaEdgeEvent(event: event) + + if event.eventType == XDMMediaEventType.sessionStart { + // Store the edge request id media for sessionStart event for handling the success/error reponses from the backend + sessionStartEdgeRequestId = mediaEdgeEvent.id.uuidString + } + + // Dispatch the media event to the eventhub to be sent by the edge extension to the backend + dispatcher(mediaEdgeEvent) + } + + /// Generates XDM formatted Media Edge `Event`. + /// - Parameter event: A `MediaXDMEvent` object. + /// - Returns: An `Event` object representing XDM formatted Media Edge event. + private func getXDMFormattedMediaEdgeEvent(event: MediaXDMEvent) -> Event { + // Generate custom path for the Interact API call for media backend + let eventOverwritePath = generateEventPath(eventType: event.eventType.rawValue) + + var mediaXDMData = event.toXDMData() + mediaXDMData[MediaConstants.Edge.EventData.REQUEST] = [MediaConstants.Edge.EventData.PATH: eventOverwritePath] + + let mediaEdgeEvent = Event(name: "MediaEdge event - \(event.eventType.edgeEventType())", + type: EventType.edge, + source: EventSource.requestContent, + data: mediaXDMData) + + return mediaEdgeEvent + } + + /// Attaches media state fields and backendSessionId based on the eventType + /// - Parameters: + /// - event: A mutable `MediaXDMEvent` object to attach additional details to. + private func attachMediaStateInfo(to event: inout MediaXDMEvent) { + if event.eventType == XDMMediaEventType.sessionStart { + event.mediaCollection.sessionDetails?.playerName = state.playerName + event.mediaCollection.sessionDetails?.appVersion = state.appVersion + + // Channel would exist if the value is overriden using tracker configuration + if event.mediaCollection.sessionDetails?.channel == nil { + event.mediaCollection.sessionDetails?.channel = state.channel + } + } else { + // Append backend session id for hits other than sessionStart + event.mediaCollection.sessionID = self.mediaBackendSessionId + + if event.eventType == XDMMediaEventType.adStart { + event.mediaCollection.advertisingDetails?.playerName = state.playerName + } + + } + } + + /// Generates custom request path to be overwritten by the edge request for Media. + /// - Parameter evenType: A `String` denoting media event type for which the path is generated. + /// - Returns: A `String` path used by the edge to send the edge media requests to. + private func generateEventPath(eventType: String) -> String { + return MediaConstants.Edge.MEDIA_CUSTOM_PATH_PREFIX + eventType + } + + /// Verifies that the `backendSessionId` is a valid, non-empty string, and if so caches it. + /// The backend session id is returned by the Edge as a response to the sessionStart event response. + /// This backend session id is required for all the events after sessionStart event. + /// - Parameter backendSessionId: A UUID `String` returned by the backend. + /// - Returns: `true` if the backend session id is a valid non-empty `String`, otherwise it returns `false`. + private func updateBackendSessionId(_ backendSessionId: String?) -> Bool { + guard let backendSessionId = backendSessionId, !backendSessionId.isEmpty else { + return false + } + + self.mediaBackendSessionId = backendSessionId + + return true + } + + /// Dispatches media session created event after receiving the backend session id. + private func dispatchSessionCreatedEvent() { + // Dispatch session created event + var eventData: [String: Any] = [:] + eventData[MediaConstants.Tracker.BACKEND_SESSION_ID] = mediaBackendSessionId + + // Attach tracker session Id for debug + if trackerSessionId != nil { + eventData[MediaConstants.Tracker.SESSION_ID] = trackerSessionId + } + + let sessionCreatedEvent = Event(name: MediaConstants.Media.EVENT_NAME_SESSION_CREATED, + type: MediaConstants.Media.EVENT_TYPE, + source: MediaConstants.Media.EVENT_SOURCE_SESSION_CREATED, + data: eventData) + + self.dispatcher?(sessionCreatedEvent) + } +} diff --git a/Sources/MediaRule.swift b/Sources/MediaRule.swift new file mode 100644 index 0000000..2c94bf3 --- /dev/null +++ b/Sources/MediaRule.swift @@ -0,0 +1,71 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +class MediaRule { + typealias RuleFunction = (MediaRule, [String: Any]) -> Bool + private(set) var name: Int + private(set) var description: String + // swiftlint:disable large_tuple + private var predicateList: [(fn: RuleFunction, expectedResult: Bool, errorMsg: String)] = [] + private var actionList: [RuleFunction] = [] + + init(name: Int, description: String) { + self.name = name + self.description = description + } + + // Adds the predicates/conditions function for the rule. + /// - Parameter predicateFn: a closure function to be executed for the associated rule. + @discardableResult + func addPredicate(predicateFn: @escaping RuleFunction, expectedValue: Bool, errorMsg: String) -> MediaRule { + let predicateTuple = (predicateFn, expectedValue, errorMsg) + predicateList.append(predicateTuple) + + return self + } + + // Adds the action function for the rule to be executed. + /// - Parameter actionFn: a closure function to be executed for the associated rule. + @discardableResult + func addAction(actionFn: @escaping RuleFunction) -> MediaRule { + actionList.append(actionFn) + + return self + } + + // Run all the predicates associated with the rule. + /// - Parameter context: a dictionary containing data to be verified. + func runPredicates(context: [String: Any]) -> (Bool, String) { + for predicate in predicateList { + let predicateFn = predicate.fn + let expectedValue = predicate.expectedResult + + if predicateFn(self, context) != expectedValue { + return (false, predicate.errorMsg) + } + } + return (true, "") + } + + // Run all the actions associated with the rule + /// - Parameter context: a dictionary containing data to be verified + func runActions(context: [String: Any]) -> Bool { + for action in actionList { + if !action(self, context) { + return false + } + } + return true + } +} diff --git a/Sources/MediaRuleEngine.swift b/Sources/MediaRuleEngine.swift new file mode 100644 index 0000000..ca3cbea --- /dev/null +++ b/Sources/MediaRuleEngine.swift @@ -0,0 +1,80 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class MediaRuleEngine { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaRuleEngine" + private let RULE_NOT_FOUND = "Matching rule not found" + private var rules: [Int: MediaRule] = [:] + private var enterFn: MediaRule.RuleFunction? + private var exitFn: MediaRule.RuleFunction? + + // Add a rule to the be verified by the MediaRuleEngine. + /// - Parameter rule: a `MediaRule` to be validated by the `MediaRulesEngine`. + @discardableResult + func add(rule: MediaRule) -> Bool { + if rules[rule.name] == nil { + rules[rule.name] = rule + return true + } + return false + } + + // Adds the closure/function to be run before processing the rules. + /// - Parameter enterFn: a closure to be executed. + func onEnterRule(enterFn: @escaping MediaRule.RuleFunction) { + self.enterFn = enterFn + } + + // Adds the closure/function to be run after processing the rules. + /// - Parameter exitFn: a closure to be executed. + func onExitRule(exitFn: @escaping MediaRule.RuleFunction) { + self.exitFn = exitFn + } + + // Processes the rule and returns true if success and false if failure along with the error message. + /// - Parameters: + /// - name: an `Int` denoting the name of the rule./ + /// - context: a `dictionary` containing data to be valdiated for the rule. + func processRule(name: Int, context: [String: Any]) -> (success: Bool, errorMsg: String) { + guard let rule = rules[name] else { + return (false, RULE_NOT_FOUND) + } + + let predicateResult = rule.runPredicates(context: context) + + guard predicateResult.0 else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Predicates failed for Rule: (\(rule.description))") + return predicateResult + } + + // pass if no enterFn or if enterFn is a success + if let enterFn = enterFn, !enterFn(rule, context) { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Enter action prevents further processing for Rule: (\(rule.description))") + return predicateResult + } + + guard rule.runActions(context: context) else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Rule action prevents further processing for Rule: (\(rule.description))") + return predicateResult + } + + if let exitFn = exitFn, !exitFn(rule, context) { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Exit action resulted in a failure for Rule: (\(rule.description))") + } + + return predicateResult + } +} diff --git a/Sources/MediaSession.swift b/Sources/MediaSession.swift new file mode 100644 index 0000000..5e0368c --- /dev/null +++ b/Sources/MediaSession.swift @@ -0,0 +1,115 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import Foundation + +class MediaSession { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaSession" + + private(set) var id: String + private(set) var state: MediaState + private(set) var dispatcher: ((_ event: Event) -> Void)? + var isSessionActive: Bool + var sessionEndHandler: (() -> Void)? + var trackerSessionId: String? + + /// Initializer for `MediaSession` + /// - Parameters: + /// - id: Unique `MediaSession id` + /// - trackerSessionId: A `UUID` string representing tracker session ID which can used be for debugging. + /// - mediaState: `MediaState` object + /// - dispatchQueue: `DispatchQueue` used for handling response after processing `MediaHit` + /// - dispather: A closure used for dispatching `Event` + init(id: String, trackerSessionId: String?, state: MediaState, dispatchQueue: DispatchQueue, dispatcher: ((_ event: Event) -> Void)?) { + self.id = id + self.trackerSessionId = trackerSessionId + self.state = state + self.dispatcher = dispatcher + isSessionActive = true + } + + /// Queues the media `Event` + /// - Parameter event: `Event` to be queued. + func queue(event: MediaXDMEvent) { + guard isSessionActive else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Failed to queue event. Media Session (\(id)) is inactive.") + return + } + + handleQueueEvent(event) + } + + /// Ends the session + /// - Parameter onsessionEnd: An optional closure that will be executed after successfully ending the session. + func end(onSessionEnd sessionEndHandler: (() -> Void)? = nil) { + guard isSessionActive else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Failed to end session. Media Session (\(id)) is inactive") + return + } + + self.sessionEndHandler = sessionEndHandler + isSessionActive = false + handleSessionEnd() + } + + /// Aborts the session. + /// - Parameter onSessionEnd: An optional closure that will be executed after successfully aborting the session. + func abort(onSessionEnd sessionEndHandler: (() -> Void)? = nil) { + guard isSessionActive else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Failed to abort session. Media Session (\(id)) is inactive") + return + } + + self.sessionEndHandler = sessionEndHandler + isSessionActive = false + handleSessionAbort() + } + + /// Notifies MediaState updates + func handleMediaStateUpdate() { + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - This function should be handled by the implementing class.") + } + + /// Includes the business logic for ending session. Implemented by more concrete classes of MediaSession: `MedialRealTimeSession` and `MediaOfflineSession`. + func handleSessionEnd() { + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - This function should be handled by the implementing class.") + } + + /// Includes the business logic for aborting session. Implemented by more concrete classes of MediaSession: `MedialRealTimeSession` and `MediaOfflineSession`. + func handleSessionAbort() { + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - This function should be handled by the implementing class.") + } + + /// Includes the business logic for queuing `MediaHit`. Implemented by more concrete classes of MediaSession: `MedialRealTimeSession` and `MediaOfflineSession`. + /// - Parameter hit: `MediaHit` to be queued. + func handleQueueEvent(_ event: MediaXDMEvent) { + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - This function should be handled by the implementing class.") + } + + /// Handles sessionId from the server. Implemented by more concrete classes of MediaSession: `MedialRealTimeSession` + /// - Parameters: + /// - requestEventId: UUID denoting edge request event id. + /// - backendSessionId: UUID returned by the backend for the media session. + func handleSessionUpdate(requestEventId: String, backendSessionId: String?) { + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - This function should be handled by the implementing class.") + } + + /// Handles error responses from the server + /// - requestEventId: UUID denoting edge request event id. + /// - data: dictionary containing errors returned by the backend. + func handleErrorResponse(requestEventId: String, data: [String: Any?]) { + Log.warning(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - This function should be handled by the implementing class.") + } +} diff --git a/Sources/MediaState.swift b/Sources/MediaState.swift new file mode 100644 index 0000000..b0d61df --- /dev/null +++ b/Sources/MediaState.swift @@ -0,0 +1,39 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +class MediaState { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaState" + + private(set) var channel: String? + private(set) var playerName: String? + private(set) var appVersion: String? + + // Updates the configuration shared state data related to media edge. + /// - Parameter data: the configuration shared state data + func updateConfigurationSharedState(_ data: [String: Any]?) { + guard let configurationData = data else { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Failed to extract configuration data (event data was nil).") + return + } + self.channel = configurationData[MediaConstants.Configuration.MEDIA_CHANNEL] as? String + self.playerName = configurationData[MediaConstants.Configuration.MEDIA_PLAYER_NAME] as? String + self.appVersion = configurationData[MediaConstants.Configuration.MEDIA_APP_VERSION] as? String + } + + func hasRequiredConfiguration() -> Bool { + return !(channel ?? "").isEmpty && !(playerName ?? "").isEmpty + } +} diff --git a/Sources/MediaXDMEvent.swift b/Sources/MediaXDMEvent.swift new file mode 100644 index 0000000..d49df90 --- /dev/null +++ b/Sources/MediaXDMEvent.swift @@ -0,0 +1,36 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ +import AEPServices +import Foundation + +struct MediaXDMEvent { + let timestamp: Date + let eventType: XDMMediaEventType + var mediaCollection: XDMMediaCollection + + init(eventType: XDMMediaEventType, timestamp: Date, mediaCollection: XDMMediaCollection) { + self.eventType = eventType + self.timestamp = timestamp + self.mediaCollection = mediaCollection + } + + func toXDMData() -> [String: Any] { + var mediaXDMData = [String: Any]() + mediaXDMData[MediaConstants.XDMKeys.EVENT_TYPE] = self.eventType.edgeEventType() + mediaXDMData[MediaConstants.XDMKeys.TIMESTAMP] = timestamp.getISO8601UTCDateWithMilliseconds() + mediaXDMData[MediaConstants.XDMKeys.MEDIA_COLLECTION] = self.mediaCollection.asDictionary() + + var xdmData = [String: Any]() + xdmData[MediaConstants.XDMKeys.XDM] = mediaXDMData + return xdmData + } +} diff --git a/Sources/MediaXDMEventGenerator.swift b/Sources/MediaXDMEventGenerator.swift new file mode 100644 index 0000000..30e1739 --- /dev/null +++ b/Sources/MediaXDMEventGenerator.swift @@ -0,0 +1,330 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import Foundation + +class MediaXDMEventGenerator { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaExperienceEventGenerator" + private let mediaEventProcessor: MediaEventProcessing + private let trackerConfig: [String: Any] + private let refEvent: Event + private var lastReportedQoe: XDMQoeDataDetails? + private var isTracking: Bool = false + private var refTS: Int64 + private var currentPlaybackState: MediaContext.MediaPlaybackState? + private var currentPlaybackStateStartRefTS: Int64 + private let allowedAdPingIntervalRangeInSeconds = 1...10 + private let allowedMainPingIntervalRangeInSeconds = 10...50 + + #if DEBUG + var mediaContext: MediaContext + var sessionId: String = "" + #else + private var mediaContext: MediaContext + private var sessionId: String = "" + #endif + + /// Initializes the Media XDM Event Generator + public required init(context: MediaContext, eventProcessor: MediaEventProcessing, config: [String: Any], refEvent: Event, refTS: Int64) { + self.mediaContext = context + self.mediaEventProcessor = eventProcessor + self.trackerConfig = config + self.refEvent = refEvent + self.refTS = refTS + self.currentPlaybackState = .Init + self.currentPlaybackStateStartRefTS = refTS + startTrackingSession(trackerSessionId: refEvent.sessionId) + } + + func processSessionStart(forceResume: Bool = false) { + var sessionDetails = MediaXDMEventHelper.generateSessionDetails(mediaInfo: mediaContext.mediaInfo, metadata: mediaContext.mediaMetadata, forceResume: forceResume) + let customMetadata = MediaXDMEventHelper.generateMediaCustomMetadataDetails(metadata: mediaContext.mediaMetadata) + + if let channel = trackerConfig[MediaConstants.TrackerConfig.CHANNEL] as? String, !channel.isEmpty { + sessionDetails.channel = channel + } + + var mediaCollection = XDMMediaCollection() + mediaCollection.sessionDetails = sessionDetails + mediaCollection.customMetadata = customMetadata + + addGenericDataAndProcess(eventType: XDMMediaEventType.sessionStart, mediaCollection: mediaCollection) + } + + func processSessionComplete() { + addGenericDataAndProcess(eventType: XDMMediaEventType.sessionComplete, mediaCollection: nil) + endTrackingSession() + } + + func processSessionEnd() { + addGenericDataAndProcess(eventType: XDMMediaEventType.sessionEnd, mediaCollection: nil) + endTrackingSession() + } + + func processAdBreakStart() { + var mediaCollection = XDMMediaCollection() + mediaCollection.advertisingPodDetails = MediaXDMEventHelper.generateAdvertisingPodDetails(adBreakInfo: mediaContext.adBreakInfo) + + addGenericDataAndProcess(eventType: XDMMediaEventType.adBreakStart, mediaCollection: mediaCollection) + } + + func processAdBreakComplete() { + addGenericDataAndProcess(eventType: XDMMediaEventType.adBreakComplete, mediaCollection: nil) + } + + func processAdBreakSkip() { + addGenericDataAndProcess(eventType: XDMMediaEventType.adBreakComplete, mediaCollection: nil) + } + + func processAdStart() { + var mediaCollection = XDMMediaCollection() + mediaCollection.advertisingDetails = MediaXDMEventHelper.generateAdvertisingDetails(adInfo: mediaContext.adInfo, adMetadata: mediaContext.adMetadata) + mediaCollection.customMetadata = MediaXDMEventHelper.generateAdCustomMetadataDetails(metadata: mediaContext.adMetadata) + + addGenericDataAndProcess(eventType: XDMMediaEventType.adStart, mediaCollection: mediaCollection) + } + + func processAdComplete() { + addGenericDataAndProcess(eventType: XDMMediaEventType.adComplete, mediaCollection: nil) + } + + func processAdSkip() { + addGenericDataAndProcess(eventType: XDMMediaEventType.adSkip, mediaCollection: nil) + } + + func processChapterStart() { + var mediaCollection = XDMMediaCollection() + mediaCollection.chapterDetails = MediaXDMEventHelper.generateChapterDetails(chapterInfo: mediaContext.chapterInfo) + mediaCollection.customMetadata = MediaXDMEventHelper.generateChapterMetadata(metadata: mediaContext.chapterMetadata) + + addGenericDataAndProcess(eventType: XDMMediaEventType.chapterStart, mediaCollection: mediaCollection) + } + + func processChapterComplete() { + addGenericDataAndProcess(eventType: XDMMediaEventType.chapterComplete, mediaCollection: nil) + } + + func processChapterSkip() { + addGenericDataAndProcess(eventType: XDMMediaEventType.chapterSkip, mediaCollection: nil) + } + + /// End media session after 24 hr timeout or idle timeout(30 mins). + func processSessionAbort() { + processSessionEnd() + } + + /// Restart session again after 24 hr timeout or idle timeout recovered. + func processSessionRestart() { + currentPlaybackState = .Init + currentPlaybackStateStartRefTS = refTS + + lastReportedQoe = nil + startTrackingSession(trackerSessionId: refEvent.sessionId) + processSessionStart(forceResume: true) + + if mediaContext.chapterInfo != nil { + processChapterStart() + } + + if mediaContext.adBreakInfo != nil { + processAdBreakStart() + } + + if mediaContext.adInfo != nil { + processAdStart() + } + + for state in mediaContext.getActiveTrackedStates() { + processStateStart(stateInfo: state) + } + + processPlayback(doFlush: true) + } + + func processBitrateChange() { + var mediaCollection = XDMMediaCollection() + mediaCollection.qoeDataDetails = MediaXDMEventHelper.generateQoEDataDetails(qoeInfo: mediaContext.qoeInfo) + + addGenericDataAndProcess(eventType: XDMMediaEventType.bitrateChange, mediaCollection: mediaCollection) + } + + func processError(errorId: String) { + let errorDetails = MediaXDMEventHelper.generateErrorDetails(errorID: errorId) + var mediaCollection = XDMMediaCollection() + mediaCollection.errorDetails = errorDetails + + addGenericDataAndProcess(eventType: XDMMediaEventType.error, mediaCollection: mediaCollection) + } + + func processPlayback(doFlush: Bool = false) { + let reportingInterval = getReportingIntervalFromTrackerConfig(isAdStart: (mediaContext.adInfo != nil)) + + if !isTracking { + return + } + + let newPlaybackState = getPlaybackState() + + if self.currentPlaybackState != newPlaybackState || doFlush { + let eventType = getMediaEventForPlaybackState(newPlaybackState) + + addGenericDataAndProcess(eventType: eventType, mediaCollection: nil) + currentPlaybackState = newPlaybackState + currentPlaybackStateStartRefTS = refTS + } else if (newPlaybackState == currentPlaybackState) && (refTS - currentPlaybackStateStartRefTS >= reportingInterval) { + // If the ts difference is more than interval we need to send it as multiple pings + addGenericDataAndProcess(eventType: XDMMediaEventType.ping, mediaCollection: nil) + currentPlaybackStateStartRefTS = refTS + } + } + + func processStateStart(stateInfo: StateInfo) { + let stateStartDetails = MediaXDMEventHelper.generateStateDetails(states: [stateInfo]) + var mediaCollection = XDMMediaCollection() + mediaCollection.statesStart = stateStartDetails + + addGenericDataAndProcess(eventType: XDMMediaEventType.statesUpdate, mediaCollection: mediaCollection) + } + + func processStateEnd(stateInfo: StateInfo) { + let stateEndDetails = MediaXDMEventHelper.generateStateDetails(states: [stateInfo]) + var mediaCollection = XDMMediaCollection() + mediaCollection.statesEnd = stateEndDetails + + addGenericDataAndProcess(eventType: XDMMediaEventType.statesUpdate, mediaCollection: mediaCollection) + } + + func setRefTS(ts: Int64) { + refTS = ts + } + + /// Signals event processor to start a new media session. + /// - Parameter trackerSessionId: A `UUID` string representing tracker session ID which can used be for debugging. + private func startTrackingSession(trackerSessionId: String?) { + guard let sessionId = mediaEventProcessor.createSession(trackerConfig: trackerConfig, trackerSessionId: trackerSessionId) else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Unable to create a tracking session.") + isTracking = false + return + } + self.sessionId = sessionId + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Started a new session with id (\(self.sessionId)).") + isTracking = true + } + + private func endTrackingSession() { + if isTracking { + Log.trace(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Ending the session with id (\(sessionId)).") + mediaEventProcessor.endSession(sessionId: sessionId) + isTracking = false + } + } + + /// Prepares the XDM formatted data and creates a`MediaXDMEvent`, which is then sent to `MediaEventProcessor` for processing. + /// - Parameters: + /// - eventType: A `XDMMediaEventType` enum representing the XDM formatted name of the media event. + /// - mediaCollection: A `XDMMediaCollection` object which is a XDM formatted object with some fields populated depending on the media event. + private func addGenericDataAndProcess(eventType: XDMMediaEventType, mediaCollection: XDMMediaCollection?) { + guard isTracking else { + Log.debug(label: Self.LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Dropping hit as we have stopped tracking the session") + return + } + + var mediaCollection = mediaCollection ?? XDMMediaCollection() + + // For bitrate change events and error events, use the qoe data in the current event being generated. For other events check MediaContext QoE object for latest QoE data updates. + mediaCollection.qoeDataDetails = getQoEForCurrentEvent(qoe: mediaCollection.qoeDataDetails) + // Add playhead details + mediaCollection.playhead = Int64(mediaContext.playhead) + + // Convert the refTS from milliseconds to seconds + let timestampAsDate = Date(timeIntervalSince1970: Double(refTS / 1000)) + let xdmEvent = MediaXDMEvent(eventType: eventType, timestamp: timestampAsDate, mediaCollection: mediaCollection) + + mediaEventProcessor.processEvent(sessionId: sessionId, event: xdmEvent) + } + + /// Gets the XDM formatted QoE data for the current event. + /// - Parameter qoe: A `XDMQoeDataDetails` object + /// - Returns:XDMFormatted QoE data if the current event has QoE Data or if the MediaContext has QoE data which is not yet reported to the backend. Otherwise it returns nil. + private func getQoEForCurrentEvent(qoe: XDMQoeDataDetails?) -> XDMQoeDataDetails? { + // Cache and return the passed in QoE object if it is not nil + if let qoe = qoe, !qoe.isNullOrEmpty() { + lastReportedQoe = qoe + return qoe + } + + // If the passed QoE data object is nil, get the QoE data cached by the MediaContext class and convert to XDM formatted object. + let mediaContextQoe = MediaXDMEventHelper.generateQoEDataDetails(qoeInfo: mediaContext.qoeInfo) + // If the QoE data cached by the MediaContext class is different than the last reported QoE data, return the MediaContext cached QoE data to be sent to the backend + if lastReportedQoe != mediaContextQoe { + lastReportedQoe = mediaContextQoe + return mediaContextQoe + } + + // Return nil if the current event does not have any QoE data and the latest QoE data has been already reported + return nil + } + + private func getPlaybackState() -> MediaContext.MediaPlaybackState { + if mediaContext.isInMediaPlaybackState(state: .Buffer) { + return .Buffer + } else if mediaContext.isInMediaPlaybackState(state: .Seek) { + return .Seek + } else if mediaContext.isInMediaPlaybackState(state: .Play) { + return .Play + } else if mediaContext.isInMediaPlaybackState(state: .Pause) { + return .Pause + } else { + return .Init + } + } + + private func getMediaEventForPlaybackState(_ state: MediaContext.MediaPlaybackState) -> XDMMediaEventType { + switch state { + case .Buffer: + return XDMMediaEventType.bufferStart + case .Seek: + return XDMMediaEventType.pauseStart + case .Play: + return XDMMediaEventType.play + case .Pause: + return XDMMediaEventType.pauseStart + case .Init: + // We should never hit this condition as there is no event to denote init. + // Ping without any previous playback state denotes init. + return XDMMediaEventType.ping + } + } + + /// Gets the custom reporting interval set in the tracker configuration. Valid custom main ping interval range is (10 seconds - 50 seconds) and valid ad ping interval is (1 second - 10 seconds) + /// - Parameter isAdStart: A Boolean when true denotes reporting interval is needed for Ad content or denotes Main content when false. + /// - Return: the custom interval in `MILLISECONDS` if found in tracker configuration. Returns the default `MediaConstants.PingInterval.REALTIME_TRACKING` if the custom values are invalid or not found + private func getReportingIntervalFromTrackerConfig(isAdStart: Bool = false) -> Int64 { + if isAdStart { + guard let customAdPingInterval = trackerConfig[MediaConstants.TrackerConfig.AD_PING_INTERVAL] as? Int, allowedAdPingIntervalRangeInSeconds.contains(customAdPingInterval) else { + return MediaConstants.PingInterval.REALTIME_TRACKING_MS + } + + return Int64(customAdPingInterval) * 1000 // convert to Milliseconds + + } else { + guard let customMainPingInterval = trackerConfig[MediaConstants.TrackerConfig.MAIN_PING_INTERVAL] as? Int, allowedMainPingIntervalRangeInSeconds.contains(customMainPingInterval) else { + return MediaConstants.PingInterval.REALTIME_TRACKING_MS + } + + return Int64(customMainPingInterval) * 1000 // convert to Milliseconds + } + } +} diff --git a/Sources/MediaXDMEventHelper.swift b/Sources/MediaXDMEventHelper.swift new file mode 100644 index 0000000..9ebac6b --- /dev/null +++ b/Sources/MediaXDMEventHelper.swift @@ -0,0 +1,262 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ +import AEPServices +import Foundation + +enum MediaXDMEventHelper { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "MediaXDMHelper" + private static let standardMediaMetadataSet: Set = [ + MediaConstants.VideoMetadataKeys.AD_LOAD, + MediaConstants.VideoMetadataKeys.ASSET_ID, + MediaConstants.VideoMetadataKeys.AUTHORIZED, + MediaConstants.VideoMetadataKeys.DAY_PART, + MediaConstants.VideoMetadataKeys.EPISODE, + MediaConstants.VideoMetadataKeys.FEED, + MediaConstants.VideoMetadataKeys.FIRST_AIR_DATE, + MediaConstants.VideoMetadataKeys.FIRST_DIGITAL_DATE, + MediaConstants.VideoMetadataKeys.GENRE, + MediaConstants.VideoMetadataKeys.MVPD, + MediaConstants.VideoMetadataKeys.NETWORK, + MediaConstants.VideoMetadataKeys.ORIGINATOR, + MediaConstants.VideoMetadataKeys.RATING, + MediaConstants.VideoMetadataKeys.SEASON, + MediaConstants.VideoMetadataKeys.SHOW, + MediaConstants.VideoMetadataKeys.SHOW_TYPE, + MediaConstants.VideoMetadataKeys.STREAM_FORMAT, + MediaConstants.AudioMetadataKeys.ALBUM, + MediaConstants.AudioMetadataKeys.ARTIST, + MediaConstants.AudioMetadataKeys.AUTHOR, + MediaConstants.AudioMetadataKeys.LABEL, + MediaConstants.AudioMetadataKeys.PUBLISHER, + MediaConstants.AudioMetadataKeys.STATION + ] + + private static let standardAdMetadataSet: Set = [ + MediaConstants.AdMetadataKeys.ADVERTISER, + MediaConstants.AdMetadataKeys.CAMPAIGN_ID, + MediaConstants.AdMetadataKeys.CREATIVE_ID, + MediaConstants.AdMetadataKeys.CREATIVE_URL, + MediaConstants.AdMetadataKeys.PLACEMENT_ID, + MediaConstants.AdMetadataKeys.SITE_ID + ] + + // swiftlint:disable function_body_length + static func generateSessionDetails(mediaInfo: MediaInfo, metadata: [String: String], forceResume: Bool = false) -> XDMSessionDetails { + var streamType = XDMStreamType.video + if mediaInfo.mediaType == MediaType.Audio { + streamType = XDMStreamType.audio + } + + // To also handle the internally triggered resume by the SDK for long running sessions >= 24 hours + let hasResume = forceResume || mediaInfo.resumed + + var sessionDetailsXDM = XDMSessionDetails(name: mediaInfo.id, + friendlyName: mediaInfo.name, + length: Int64(mediaInfo.length), + streamType: streamType, + contentType: mediaInfo.streamType, + hasResume: hasResume) + + // Append standard metadata to sessionDetails + for (key, value) in metadata { + if !standardMediaMetadataSet.contains(key) { + continue + } + + switch key { + // Video standard metadata cases + case MediaConstants.VideoMetadataKeys.AD_LOAD: + sessionDetailsXDM.adLoad = value + case MediaConstants.VideoMetadataKeys.ASSET_ID: + sessionDetailsXDM.assetID = value + case MediaConstants.VideoMetadataKeys.AUTHORIZED: + sessionDetailsXDM.authorized = value + case MediaConstants.VideoMetadataKeys.DAY_PART: + sessionDetailsXDM.dayPart = value + case MediaConstants.VideoMetadataKeys.EPISODE: + sessionDetailsXDM.episode = value + case MediaConstants.VideoMetadataKeys.FEED: + sessionDetailsXDM.feed = value + case MediaConstants.VideoMetadataKeys.FIRST_AIR_DATE: + sessionDetailsXDM.firstAirDate = value + case MediaConstants.VideoMetadataKeys.FIRST_DIGITAL_DATE: + sessionDetailsXDM.firstDigitalDate = value + case MediaConstants.VideoMetadataKeys.GENRE: + sessionDetailsXDM.genre = value + case MediaConstants.VideoMetadataKeys.MVPD: + sessionDetailsXDM.mvpd = value + case MediaConstants.VideoMetadataKeys.NETWORK: + sessionDetailsXDM.network = value + case MediaConstants.VideoMetadataKeys.ORIGINATOR: + sessionDetailsXDM.originator = value + case MediaConstants.VideoMetadataKeys.RATING: + sessionDetailsXDM.rating = value + case MediaConstants.VideoMetadataKeys.SEASON: + sessionDetailsXDM.season = value + case MediaConstants.VideoMetadataKeys.SHOW: + sessionDetailsXDM.show = value + case MediaConstants.VideoMetadataKeys.SHOW_TYPE: + sessionDetailsXDM.showType = value + case MediaConstants.VideoMetadataKeys.STREAM_FORMAT: + sessionDetailsXDM.streamFormat = value + + // Audio standard metadata cases + case MediaConstants.AudioMetadataKeys.ALBUM: + sessionDetailsXDM.album = value + case MediaConstants.AudioMetadataKeys.ARTIST: + sessionDetailsXDM.artist = value + case MediaConstants.AudioMetadataKeys.AUTHOR: + sessionDetailsXDM.author = value + case MediaConstants.AudioMetadataKeys.LABEL: + sessionDetailsXDM.label = value + case MediaConstants.AudioMetadataKeys.PUBLISHER: + sessionDetailsXDM.publisher = value + case MediaConstants.AudioMetadataKeys.STATION: + sessionDetailsXDM.station = value + default: + break + + } + } + + return sessionDetailsXDM + } + + static func generateMediaCustomMetadataDetails(metadata: [String: String]) -> [XDMCustomMetadata] { + var customMetadataList = [XDMCustomMetadata]() + for (key, value) in metadata { + if !standardMediaMetadataSet.contains(key) { + customMetadataList.append(XDMCustomMetadata(name: key, value: value)) + } + } + + customMetadataList.sort { $0.name < $1.name } + + return customMetadataList + } + + static func generateAdvertisingPodDetails(adBreakInfo: AdBreakInfo?) -> XDMAdvertisingPodDetails? { + guard let adBreakInfo = adBreakInfo else { + Log.trace(label: LOG_TAG, "[\(CLASS_NAME)<\(#function)>] - found empty ad break info.") + return nil + } + + let advertisingPodDetailsXDM = XDMAdvertisingPodDetails(friendlyName: adBreakInfo.name, index: Int64(adBreakInfo.position), offset: Int64(adBreakInfo.startTime)) + + return advertisingPodDetailsXDM + } + + static func generateAdvertisingDetails(adInfo: AdInfo?, adMetadata: [String: String]) -> XDMAdvertisingDetails? { + guard let adInfo = adInfo else { + Log.trace(label: LOG_TAG, "[\(CLASS_NAME)<\(#function)>] - found empty ad info.") + return nil + } + + var advertisingDetailsXDM = XDMAdvertisingDetails(name: adInfo.id, friendlyName: adInfo.name, length: Int64(adInfo.length), podPosition: Int64(adInfo.position)) + + // Append standard metadata to advertisingDetails + for (key, value) in adMetadata { + if !standardAdMetadataSet.contains(key) { + continue + } + + switch key { + case MediaConstants.AdMetadataKeys.ADVERTISER: + advertisingDetailsXDM.advertiser = value + case MediaConstants.AdMetadataKeys.CAMPAIGN_ID: + advertisingDetailsXDM.campaignID = value + case MediaConstants.AdMetadataKeys.CREATIVE_ID: + advertisingDetailsXDM.creativeID = value + case MediaConstants.AdMetadataKeys.CREATIVE_URL: + advertisingDetailsXDM.creativeURL = value + case MediaConstants.AdMetadataKeys.PLACEMENT_ID: + advertisingDetailsXDM.placementID = value + case MediaConstants.AdMetadataKeys.SITE_ID: + advertisingDetailsXDM.siteID = value + default: + break + } + } + + return advertisingDetailsXDM + } + + static func generateAdCustomMetadataDetails(metadata: [String: String]) -> [XDMCustomMetadata] { + var customMetadataList = [XDMCustomMetadata]() + for (key, value) in metadata { + if !standardAdMetadataSet.contains(key) { + let customMetadata = XDMCustomMetadata(name: key, value: value) + customMetadataList.append(customMetadata) + } + } + + customMetadataList.sort { $0.name < $1.name } + + return customMetadataList + } + + static func generateChapterDetails(chapterInfo: ChapterInfo?) -> XDMChapterDetails? { + guard let chapterInfo = chapterInfo else { + Log.trace(label: LOG_TAG, "[\(CLASS_NAME)<\(#function)>] - found empty chapter info.") + return nil + } + + let chapterDetailsXDM = XDMChapterDetails(friendlyName: chapterInfo.name, index: Int64(chapterInfo.position), length: Int64(chapterInfo.length), offset: Int64(chapterInfo.startTime)) + return chapterDetailsXDM + } + + static func generateChapterMetadata(metadata: [String: String]) -> [XDMCustomMetadata] { + var metadataList = [XDMCustomMetadata]() + for (key, value) in metadata { + metadataList.append(XDMCustomMetadata(name: key, value: value)) + } + + metadataList.sort { metadata1, metadata2 in + metadata1.name < metadata2.name + } + + return metadataList + } + + static func generateQoEDataDetails(qoeInfo: QoEInfo?, errorId: String? = nil) -> XDMQoeDataDetails? { + guard let qoeInfo = qoeInfo else { + Log.trace(label: LOG_TAG, "[\(CLASS_NAME)<\(#function)>] - found empty chapter info.") + return nil + } + let qoeDetailsXDM = XDMQoeDataDetails(bitrate: Int64(qoeInfo.bitrate), + droppedFrames: Int64(qoeInfo.droppedFrames), + framesPerSecond: Int64(qoeInfo.fps), + timeToStart: Int64(qoeInfo.startupTime)) + + return qoeDetailsXDM + } + + static func generateErrorDetails(errorID: String) -> XDMErrorDetails { + let errorDetailsXDM = XDMErrorDetails(name: errorID, source: MediaConstants.ErrorSource.PLAYER) + + return errorDetailsXDM + } + + static func generateStateDetails(states: [StateInfo]?) -> [XDMPlayerStateData]? { + guard let states = states, !states.isEmpty else { + return nil + } + + var playerStateXDMList = [XDMPlayerStateData]() + for state in states { + playerStateXDMList.append(XDMPlayerStateData(name: state.stateName)) + } + + return playerStateXDMList + } +} diff --git a/Sources/Public/Media+PublicAPI.swift b/Sources/Public/Media+PublicAPI.swift new file mode 100644 index 0000000..4cda70b --- /dev/null +++ b/Sources/Public/Media+PublicAPI.swift @@ -0,0 +1,206 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import Foundation + +/// Defines the public interface for the Media extension +@objc public extension Media { + private static let LOG_TAG = MediaConstants.LOG_TAG + private static let CLASS_NAME = "Media" + + /// Creates an instance of `MediaTracker` used for calling track APIs. + @objc(createTracker) + static func createTracker() -> MediaTracker { + return createTrackerWith(config: nil) + } + + /// Creates an instance of `MediaTracker` used for calling track APIs. + /// - Parameter: + /// - config: The configuration for `MediaTracker` instance. + @objc(createTrackerWithConfig:) + static func createTrackerWith(config: [String: Any]?) -> MediaTracker { + return MediaPublicTracker(dispatch: MobileCore.dispatch(event:), config: config) + } + + /// Creates an instance of `MediaInfo` to be used with `trackSessionStart` API. + /// - Parameter: + /// - name: The name of the media. + /// - id: The unqiue identifier for the media. + /// - length: The length of the media in seconds. + /// - streamType: The stream type as a string. Use the pre-defined constants for vod, live, and linear content. + /// - mediaType: The media type of the stream. Use `MediaType` enum which can be either `MediaType.Video` or `MediaType.Audio`. + @objc(createMediaObjectWith:id:length:streamType:mediaType:) + static func createMediaObjectWith(name: String, id: String, length: Double, streamType: String, mediaType: MediaType) -> [String: Any]? { + guard let mediaInfo = MediaInfo(id: id, name: name, streamType: streamType, mediaType: mediaType, length: length) else { + Log.error(label: LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating media Object") + return nil + } + + return mediaInfo.toMap() + } + + /// Creates an instance of `AdBreakInfo` to be used with `trackEvent(AdBreakStart)` API. + /// - Parameter: + /// - name: The name of the ad break. + /// - position: The position of the ad break in the content starting with `1`. + /// - startTime: The start time of the ad break relative to the main media in seconds. + @objc(createAdBreakObjectWith:position:startTime:) + static func createAdBreakObjectWith(name: String, position: Int, startTime: Double) -> [String: Any]? { + guard let adBreakInfo = AdBreakInfo(name: name, position: position, startTime: startTime) else { + Log.error(label: LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating adBreak Object") + return nil + } + return adBreakInfo.toMap() + } + + /// Creates an instance of `AdInfo` to be used with `trackEvent(AdStart)` API. + /// - Parameter: + /// - name: The name of the ad. + /// - id: The unqiue identifier for the ad. + /// - position: The position of the ad in the ad break starting with `1`. + /// - length: The length of the ad in seconds. + @objc(createAdObjectWith:id:position:length:) + static func createAdObjectWith(name: String, id: String, position: Int, length: Double) -> [String: Any]? { + guard let adInfo = AdInfo(id: id, name: name, position: position, length: length) else { + Log.error(label: LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating ad Object") + return nil + } + return adInfo.toMap() + } + + /// Creates an instance of `ChapterInfo` to be used with `trackEvent(ChapterStart)` API. + /// - Parameter: + /// - name: The name of the chapter. + /// - position: The position of the chapter in the content starting with `1`. + /// - length: The length of the chapter in seconds. + /// - startTime: The start time of the chapter relative to the main media in seconds. + @objc(createChapterObjectWith:position:length:startTime:) + static func createChapterObjectWith(name: String, position: Int, length: Double, startTime: Double) -> [String: Any]? { + guard let chapterInfo = ChapterInfo(name: name, position: position, startTime: startTime, length: length) else { + Log.error(label: LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating chapter Object") + return nil + } + return chapterInfo.toMap() + } + + /// Creates an instance of `QoEInfo` to be used with `updateQoEObject` API. + /// - Parameter: + /// - bitrate: The average bitrate of media in kbps. + /// - startupTime: The duration (in seconds) between video load and start. + /// - fps: The current frame rate (in frames per second) information. + /// - droppedFrames: The number of frames dropped during playback of the main content. + @objc(createQoEObjectWith:startTime:fps:droppedFrames:) + static func createQoEObjectWith(bitrate: Double, startupTime: Double, fps: Double, droppedFrames: Double) -> [String: Any]? { + guard let qoeInfo = QoEInfo(bitrate: bitrate, droppedFrames: droppedFrames, fps: fps, startupTime: startupTime) else { + Log.error(label: LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating qoe Object") + return nil + } + return qoeInfo.toMap() + } + + /// Creates an instance of `StateInfo` to be used with trackEvent(StateStart) and trackEvent(StateEnd) API. + /// - Parameter: + /// - stateName: The name of the custom state to track. Use the pre-defined constants for fullscreen, pictureInPicture, closedCaptioning, inFocus and mute. + @objc(createStateObjectWith:) + static func createStateObjectWith(stateName: String) -> [String: Any]? { + guard let stateInfo = StateInfo(stateName: stateName) else { + Log.error(label: LOG_TAG, "[\(Self.CLASS_NAME)<\(#function)>] - Error creating state Object") + return nil + } + return stateInfo.toMap() + } +} + +// swiftlint:disable identifier_name + +/// These enumeration values define the type of a tracking event. +/// These enumeration are to be used in *trackEvent(event:info:metadata:)* +@objc(AEPMediaEvent) +public enum MediaEvent: Int, RawRepresentable { + case AdBreakStart + case AdBreakComplete + case AdStart + case AdComplete + case AdSkip + case ChapterStart + case ChapterComplete + case ChapterSkip + case SeekStart + case SeekComplete + case BufferStart + case BufferComplete + case BitrateChange + case StateStart + case StateEnd + + public typealias RawValue = String + + public var rawValue: RawValue { + switch self { + case .AdBreakStart: return MediaConstants.EventName.ADBREAK_START + case .AdBreakComplete: return MediaConstants.EventName.ADBREAK_COMPLETE + case .AdStart: return MediaConstants.EventName.AD_START + case .AdComplete: return MediaConstants.EventName.AD_COMPLETE + case .AdSkip: return MediaConstants.EventName.AD_SKIP + case .ChapterStart: return MediaConstants.EventName.CHAPTER_START + case .ChapterComplete: return MediaConstants.EventName.CHAPTER_COMPLETE + case .ChapterSkip: return MediaConstants.EventName.CHAPTER_SKIP + case .SeekStart: return MediaConstants.EventName.SEEK_START + case .SeekComplete: return MediaConstants.EventName.SEEK_COMPLETE + case .BufferStart: return MediaConstants.EventName.BUFFER_START + case .BufferComplete: return MediaConstants.EventName.BUFFER_COMPLETE + case .BitrateChange: return MediaConstants.EventName.BITRATE_CHANGE + case .StateStart: return MediaConstants.EventName.STATE_START + case .StateEnd: return MediaConstants.EventName.STATE_END + } + } + + public init?(rawValue: RawValue) { + switch rawValue { + case MediaConstants.EventName.ADBREAK_START: + self = .AdBreakStart + case MediaConstants.EventName.ADBREAK_COMPLETE: + self = .AdBreakComplete + case MediaConstants.EventName.AD_START: + self = .AdStart + case MediaConstants.EventName.AD_COMPLETE: + self = .AdComplete + case MediaConstants.EventName.AD_SKIP: + self = .AdSkip + case MediaConstants.EventName.CHAPTER_START: + self = .ChapterStart + case MediaConstants.EventName.CHAPTER_COMPLETE: + self = .ChapterComplete + case MediaConstants.EventName.CHAPTER_SKIP: + self = .ChapterSkip + case MediaConstants.EventName.SEEK_START: + self = .SeekStart + case MediaConstants.EventName.SEEK_COMPLETE: + self = .SeekComplete + case MediaConstants.EventName.BUFFER_START: + self = .BufferStart + case MediaConstants.EventName.BUFFER_COMPLETE: + self = .BufferComplete + case MediaConstants.EventName.BITRATE_CHANGE: + self = .BitrateChange + case MediaConstants.EventName.STATE_START: + self = .StateStart + case MediaConstants.EventName.STATE_END: + self = .StateEnd + + default: + return nil + } + } +} diff --git a/Sources/Public/MediaConstants+Public.swift b/Sources/Public/MediaConstants+Public.swift new file mode 100644 index 0000000..5358a3d --- /dev/null +++ b/Sources/Public/MediaConstants+Public.swift @@ -0,0 +1,110 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +public class MediaConstants: NSObject { + + /// These constant strings define the stream type of the main content that is currently tracked. + @objc(AEPMediaStreamType) + @objcMembers + public class StreamType: NSObject { + /// Constant defining stream type for VOD streams. + public static let VOD = "vod" + /// Constant defining stream type for Live streams. + public static let LIVE = "live" + /// Constant defining stream type for Linear streams. + public static let LINEAR = "linear" + /// Constant defining stream type for Podcast streams. + public static let PODCAST = "podcast" + /// Constant defining stream type for Audiobook streams. + public static let AUDIOBOOK = "audiobook" + /// Constant defining stream type for AOD streams. + public static let AOD = "aod" + } + + /// These constant strings define standard metadata keys for video content. + @objc(AEPVideoMetadataKeys) + @objcMembers + public class VideoMetadataKeys: NSObject { + public static let AD_LOAD = "adLoad" + public static let ASSET_ID = "assetID" + public static let AUTHORIZED = "isAuthenticated" + public static let DAY_PART = "dayPart" + public static let EPISODE = "episode" + public static let FEED = "feed" + public static let FIRST_AIR_DATE = "firstAirDate" + public static let FIRST_DIGITAL_DATE = "firstDigitalDate" + public static let GENRE = "genre" + public static let MVPD = "mvpd" + public static let NETWORK = "network" + public static let ORIGINATOR = "originator" + public static let RATING = "rating" + public static let SEASON = "season" + public static let SHOW = "show" + public static let SHOW_TYPE = "showType" + public static let STREAM_FORMAT = "streamFormat" + } + + /// These constant strings define standard metadata keys for audio content. + @objc(AEPAudioMetadataKeys) + @objcMembers + public class AudioMetadataKeys: NSObject { + public static let ALBUM = "album" + public static let ARTIST = "artist" + public static let AUTHOR = "author" + public static let LABEL = "label" + public static let PUBLISHER = "publisher" + public static let STATION = "station" + } + + /// These constant strings define standard metadata keys for ads. + @objc(AEPAdMetadataKeys) + @objcMembers + public class AdMetadataKeys: NSObject { + public static let ADVERTISER = "advertiser" + public static let CAMPAIGN_ID = "campaignID" + public static let CREATIVE_ID = "creativeID" + public static let CREATIVE_URL = "creativeURL" + public static let PLACEMENT_ID = "placementID" + public static let SITE_ID = "siteID" + } + + /// These constant strings define standard player states. + @objc(AEPMediaPlayerState) + @objcMembers + public class PlayerState: NSObject { + public static let FULLSCREEN = "fullScreen" + public static let PICTURE_IN_PICTURE = "pictureInPicture" + public static let CLOSED_CAPTION = "closeCaption" + public static let IN_FOCUS = "inFocus" + public static let MUTE = "mute" + } + + /// These constant strings define additional event keys that can be attached to media object. + @objc(AEPMediaObjectKey) + @objcMembers + public class MediaObjectKey: NSObject { + public static let RESUMED = "media.resumed" + public static let PREROLL_TRACKING_WAITING_TIME = "media.prerollwaitingtime" + } + + /// These constant strings define keys that can be attached to config object. + @objc(AEPMediaTrackerConfig) + @objcMembers + public class TrackerConfig: NSObject { + public static let CHANNEL = "config.channel" + public static let AD_PING_INTERVAL = "config.adpinginterval" + public static let MAIN_PING_INTERVAL = "config.mainpinginterval" + + } +} diff --git a/Sources/Public/MediaTracker.swift b/Sources/Public/MediaTracker.swift new file mode 100644 index 0000000..7a08e0b --- /dev/null +++ b/Sources/Public/MediaTracker.swift @@ -0,0 +1,73 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +/// Interface to call track APIs +@objc(AEPMediaTracker) +public protocol MediaTracker { + + /// API to track the start of a viewing session. + /// - Parameter: + /// - info: Dictionary created using `createMediaObject` API. + /// - metadata: Dictionary containing the context data associated with the media session. + @objc(trackSessionStart:metadata:) + func trackSessionStart(info: [String: Any], metadata: [String: String]?) + + /// API to track media play or resume after a previous pause. + @objc(trackPlay) + func trackPlay() + + /// API to track media pause. + @objc(trackPause) + func trackPause() + + /// API to track media complete. + @objc(trackComplete) + func trackComplete() + + /// API to track the end of a viewing session. + /// This API must be called even if the user does not view the media to completion. + @objc(trackSessionEnd) + func trackSessionEnd() + + /// API to track an error in media playback. + /// - Parameter: + /// - errorId: `String` Identifier describing the error. + @objc(trackError:) + func trackError(errorId: String) + + /// API to track media event. + /// - Parameter: + /// - event: `MediaEvent` describing the event to track `ChapterStart`, `ChapterComplete`, `AdBreakStart`, `AdBreakComplete`, `AdStart`, + /// `AdComplete`, `SeekStart`, `SeekComplete`, `BufferStart`, `BufferComplete`, `BitrateChange`, `StateStart` and `StateEnd` + /// - info: `Dictionary` created using `createChapterObject`, `createAdBreakObject`, `createAdObject`, `createStateObject` API + /// for `ChapterStart`, `AdBreakStart`, `AdStart`, `StateStart` and `StateEnd` events respectively. Pass nil for other events. + /// - metadata: `Dictionary` containing context data for `AdStart` and `ChapterStart` events. Pass nil for other events. + @objc(trackEvent:info:metadata:) + func trackEvent(event: MediaEvent, info: [String: Any]?, metadata: [String: String]?) + + /// API to update playhead value for the content playback. + /// This API should be called when media playhead changes for accurate tracking. + /// - Parameter: + /// - time: Current position of the playhead. For VOD, value is specified in seconds from the beginning of the media item. + /// For live streaming, return playhead position if available or the current UTC time in seconds otherwise. + @objc(updateCurrentPlayhead:) + func updateCurrentPlayhead(time: Double) + + /// API to update the QoE data from the player to track. + /// This API should be called during a playback session with recently available QoE data. + /// - Parameter: + /// - qoe: `Dictionary` containing current QoE information + @objc(updateQoEObject:) + func updateQoEObject(qoe: [String: Any]) +} diff --git a/Sources/Public/MediaType.swift b/Sources/Public/MediaType.swift new file mode 100644 index 0000000..29d150e --- /dev/null +++ b/Sources/Public/MediaType.swift @@ -0,0 +1,45 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPServices +import Foundation + +// swiftlint:disable identifier_name +/// These enumeration values define the type of a media. +/// These enumeration are to be used in *createMediaObjectWith(name:id:length:streamType:mediaType: )* +@objc(AEPMediaType) +public enum MediaType: Int, RawRepresentable { + case Audio + case Video + + public typealias RawValue = String + + public var rawValue: RawValue { + switch self { + case .Audio: + return "audio" + case .Video: + return "video" + } + } + + public init?(rawValue: RawValue) { + switch rawValue { + case "audio": + self = .Audio + case "video": + self = .Video + default: + return nil + } + } +} diff --git a/Sources/xdm/XDMAdvertisingDetails.swift b/Sources/xdm/XDMAdvertisingDetails.swift new file mode 100644 index 0000000..d3a0c24 --- /dev/null +++ b/Sources/xdm/XDMAdvertisingDetails.swift @@ -0,0 +1,40 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMAdvertisingDetails: Encodable { + // Required fields sourced from public APIs + let friendlyName: String + let length: Int64 + let name: String + let podPosition: Int64 + + // Required field sourced from media configuration + // It is marked optional here to allow lazy initalization of the field + var playerName: String? + + // Optional fields + var advertiser: String? + var campaignID: String? + var creativeID: String? + var creativeURL: String? + var placementID: String? + var siteID: String? + + init(name: String, friendlyName: String, length: Int64, podPosition: Int64) { + self.name = name + self.friendlyName = friendlyName + self.length = length + self.podPosition = podPosition + } +} diff --git a/Sources/xdm/XDMAdvertisingPodDetails.swift b/Sources/xdm/XDMAdvertisingPodDetails.swift new file mode 100644 index 0000000..c1eaffc --- /dev/null +++ b/Sources/xdm/XDMAdvertisingPodDetails.swift @@ -0,0 +1,24 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation +struct XDMAdvertisingPodDetails: Encodable { + let friendlyName: String + let index: Int64 + let offset: Int64 + + init(friendlyName: String, index: Int64, offset: Int64) { + self.friendlyName = friendlyName + self.index = index + self.offset = offset + } +} diff --git a/Sources/xdm/XDMChapterDetails.swift b/Sources/xdm/XDMChapterDetails.swift new file mode 100644 index 0000000..41f52d9 --- /dev/null +++ b/Sources/xdm/XDMChapterDetails.swift @@ -0,0 +1,27 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMChapterDetails: Encodable { + let friendlyName: String + let index: Int64 + let length: Int64 + let offset: Int64 + + init(friendlyName: String, index: Int64, length: Int64, offset: Int64) { + self.friendlyName = friendlyName + self.index = index + self.length = length + self.offset = offset + } +} diff --git a/Sources/xdm/XDMCustomMetadata.swift b/Sources/xdm/XDMCustomMetadata.swift new file mode 100644 index 0000000..238bad7 --- /dev/null +++ b/Sources/xdm/XDMCustomMetadata.swift @@ -0,0 +1,23 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMCustomMetadata: Encodable { + let name: String + let value: String + + init(name: String, value: String) { + self.name = name + self.value = value + } +} diff --git a/Sources/xdm/XDMErrorDetails.swift b/Sources/xdm/XDMErrorDetails.swift new file mode 100644 index 0000000..9a53c79 --- /dev/null +++ b/Sources/xdm/XDMErrorDetails.swift @@ -0,0 +1,23 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMErrorDetails: Encodable { + let name: String + let source: String + + init(name: String, source: String) { + self.name = name + self.source = source + } +} diff --git a/Sources/xdm/XDMMediaCollection.swift b/Sources/xdm/XDMMediaCollection.swift new file mode 100644 index 0000000..89f56d3 --- /dev/null +++ b/Sources/xdm/XDMMediaCollection.swift @@ -0,0 +1,30 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMMediaCollection: Encodable { + + var advertisingDetails: XDMAdvertisingDetails? + var advertisingPodDetails: XDMAdvertisingPodDetails? + var chapterDetails: XDMChapterDetails? + var customMetadata: [XDMCustomMetadata]? + var errorDetails: XDMErrorDetails? + var playhead: Int64? + var qoeDataDetails: XDMQoeDataDetails? + var sessionDetails: XDMSessionDetails? + var sessionID: String? + var statesEnd: [XDMPlayerStateData?]? + var statesStart: [XDMPlayerStateData?]? + + init() {} +} diff --git a/Sources/xdm/XDMMediaEventType.swift b/Sources/xdm/XDMMediaEventType.swift new file mode 100644 index 0000000..7dd1c8e --- /dev/null +++ b/Sources/xdm/XDMMediaEventType.swift @@ -0,0 +1,38 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +enum XDMMediaEventType: String, Encodable, Equatable { + case sessionStart + case sessionComplete + case sessionEnd + case play + case pauseStart + case ping + case error + case bufferStart + case bitrateChange + case adBreakStart + case adBreakComplete + case adStart + case adSkip + case adComplete + case chapterSkip + case chapterStart + case chapterComplete + case statesUpdate + + func edgeEventType() -> String { + return "media.\(self.rawValue)" + } +} diff --git a/Sources/xdm/XDMPlayerStateData.swift b/Sources/xdm/XDMPlayerStateData.swift new file mode 100644 index 0000000..4fd325b --- /dev/null +++ b/Sources/xdm/XDMPlayerStateData.swift @@ -0,0 +1,21 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMPlayerStateData: Encodable { + let name: String + + init(name: String) { + self.name = name + } +} diff --git a/Sources/xdm/XDMQoeDataDetails.swift b/Sources/xdm/XDMQoeDataDetails.swift new file mode 100644 index 0000000..f0c8721 --- /dev/null +++ b/Sources/xdm/XDMQoeDataDetails.swift @@ -0,0 +1,33 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMQoeDataDetails: Equatable, Encodable { + let bitrate: Int64? + let droppedFrames: Int64? + let framesPerSecond: Int64? + let timeToStart: Int64? + + init(bitrate: Int64, droppedFrames: Int64, framesPerSecond: Int64, timeToStart: Int64) { + self.bitrate = bitrate + self.droppedFrames = droppedFrames + self.framesPerSecond = framesPerSecond + self.timeToStart = timeToStart + } +} + +extension XDMQoeDataDetails { + func isNullOrEmpty() -> Bool { + return bitrate == nil && droppedFrames == nil && framesPerSecond == nil && timeToStart == nil + } +} diff --git a/Sources/xdm/XDMSessionDetails.swift b/Sources/xdm/XDMSessionDetails.swift new file mode 100644 index 0000000..47580b7 --- /dev/null +++ b/Sources/xdm/XDMSessionDetails.swift @@ -0,0 +1,67 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +struct XDMSessionDetails: Encodable { + // Required fields sourced from APIs + let contentType: String + let friendlyName: String + let hasResume: Bool + let length: Int64 + let name: String + let streamType: XDMStreamType + // Required fields sourced from media configuration + var channel: String? + var playerName: String? + + // Optional field sourced from media configuration + var appVersion: String? + + // Optional metadata fields + // Audio Standard Metadata + var album: String? + var artist: String? + var author: String? + var label: String? + var publisher: String? + var station: String? + + // Video Standard Metadata + var adLoad: String? + var authorized: String? + var assetID: String? + var dayPart: String? + var episode: String? + var feed: String? + var firstAirDate: String? + var firstDigitalDate: String? + var genre: String? + var mvpd: String? + var network: String? + var originator: String? + var rating: String? + var season: String? + var segment: String? + var show: String? + var showType: String? + var streamFormat: String? + + init(name: String, friendlyName: String, length: Int64, streamType: XDMStreamType, contentType: String, hasResume: Bool) { + self.name = name + self.friendlyName = friendlyName + self.length = length + self.streamType = streamType + self.contentType = contentType + self.hasResume = hasResume + } +} diff --git a/Sources/xdm/XDMStreamType.swift b/Sources/xdm/XDMStreamType.swift new file mode 100644 index 0000000..1c29bd1 --- /dev/null +++ b/Sources/xdm/XDMStreamType.swift @@ -0,0 +1,18 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +enum XDMStreamType: String, Encodable, Equatable { + case audio + case video +} diff --git a/TestApps/TestApp/Analytics/MediaAnalyticsProvider.swift b/TestApps/TestApp/Analytics/MediaAnalyticsProvider.swift new file mode 100644 index 0000000..990712f --- /dev/null +++ b/TestApps/TestApp/Analytics/MediaAnalyticsProvider.swift @@ -0,0 +1,241 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPEdgeMedia +import AVKit +import Foundation + +class MediaAnalyticsProvider: NSObject { + + let logTag = "MediaAnalyticsProvider" + var _player: VideoPlayer? + var _tracker: MediaTracker? + + @objc func initWithPlayer(player: VideoPlayer) { + + _player = player + + // Pass optional configuration when creating tracker + var config: [String: Any] = [:] + config[MediaConstants.TrackerConfig.AD_PING_INTERVAL] = 1 // Overwrites ad content ping interval to 1 second + // config[MediaConstants.TrackerConfig.CHANNEL] = "e2e-swift-channel" // Overwrites channel configured from remote configuration + // config[MediaConstants.TrackerConfig.MAIN_PING_INTERVAL] = 30 // Overwrites main content ping interval to 30 seconds. + + _tracker = Media.createTrackerWith(config: config) + + setupPlayerNotifications() + } + + deinit { + NotificationCenter.default.removeObserver(self) + _tracker = nil + } + + @objc func updateQoE(notification: NSNotification) { + NSLog("\(logTag) onUpdateQoE()") + + let qoeData = notification.userInfo + + let qoeBitrate = qoeData?["bitrate"] as? Double ?? 0 + let qoeStartup = qoeData?["startupTime"] as? Double ?? 0 + let qoeFPS = qoeData?["fps"] as? Double ?? 0 + let qoeDroppedFrame = qoeData?["droppedFrames"] as? Double ?? 0 + + guard let qoeObject = Media.createQoEObjectWith(bitrate: qoeBitrate, startupTime: qoeStartup, fps: qoeFPS, droppedFrames: qoeDroppedFrame) else { return } + + _tracker?.updateQoEObject(qoe: qoeObject) + } + + @objc func updateCurrentPlaybackTime(notification: NSNotification) { + guard let playhead = _player?.getCurrentPlaybackTime() else { + return + } + + NSLog("\(logTag) updatePlayhead() - updated playhead value to %f", playhead) + _tracker?.updateCurrentPlayhead(time: playhead) + } + + @objc func onMainVideoLoaded(notification: NSNotification) { + NSLog("\(logTag) onMainVideoLoaded()") + + let videoData = notification.userInfo + + let videoName = videoData?["name"] as? String ?? "" + let videoId = videoData?["id"] as? String ?? "" + let vidLength = videoData?["length"] as? Double ?? 0 + + guard let mediaObject = Media.createMediaObjectWith(name: videoName, id: videoId, length: vidLength, streamType: MediaConstants.StreamType.VOD, mediaType: MediaType.Video) else { + return + } + + var videoMetadata: [String: String] = [:] + // Standard Video Metadata + videoMetadata[MediaConstants.VideoMetadataKeys.SHOW] = "Sample show" + videoMetadata[MediaConstants.VideoMetadataKeys.SEASON] = "Sample season" + + // Custom Metadata + videoMetadata["isUserLoggedIn"] = "false" + videoMetadata["tvStation"] = "Sample TV station" + + _tracker?.trackSessionStart(info: mediaObject, metadata: videoMetadata) + } + + @objc func onMainVideoUnloaded(notification: NSNotification) { + NSLog("\(logTag) onMainVideoUnloaded()") + _tracker?.trackSessionEnd() + } + + @objc func onPlay(notification: NSNotification) { + NSLog("\(logTag) onPlay()") + _tracker?.trackPlay() + } + + @objc func onStop(notification: NSNotification) { + NSLog("\(logTag) onStop()") + _tracker?.trackPause() + } + + @objc func onComplete(notification: NSNotification) { + NSLog("\(logTag) onComplete()") + _tracker?.trackComplete() + } + + @objc func onSeekStart(notification: NSNotification) { + NSLog("\(logTag) onSeekStart()") + _tracker?.trackEvent(event: MediaEvent.SeekStart, info: nil, metadata: nil) + } + + @objc func onSeekComplete(notification: NSNotification) { + NSLog("\(logTag) onSeekComplete()") + _tracker?.trackEvent(event: MediaEvent.SeekComplete, info: nil, metadata: nil) + } + + @objc func onChapterStart(notification: NSNotification) { + NSLog("\(logTag) onChapterStart()") + + let chapterDictionary = ["segmentType": "Sample segment type"] + + let chapterData = notification.userInfo + + let chapterName = chapterData?["name"] as? String ?? "" + let chapterPosition = chapterData?["position"] as? Int ?? 0 + let chapterLength = chapterData?["length"] as? Double ?? 0 + let chapterTime = chapterData?["time"] as? Double ?? 0 + + let chapterObject = Media.createChapterObjectWith(name: chapterName, position: chapterPosition, length: chapterLength, startTime: chapterTime) + + _tracker?.trackEvent(event: MediaEvent.ChapterStart, info: chapterObject, metadata: chapterDictionary) + } + + @objc func onChapterComplete(notification: NSNotification) { + NSLog("\(logTag) onChapterComplete()") + _tracker?.trackEvent(event: MediaEvent.ChapterComplete, info: nil, metadata: nil) + } + + @objc func onAdStart(notification: NSNotification) { + NSLog("\(logTag) onAdStart()") + + let adBreakData = notification.userInfo?["adbreak"] as? [String: Any] ?? [:] + let adData = notification.userInfo?["ad"] as? [String: Any] ?? [:] + + let adBreakName = adBreakData["name"] as? String ?? "" + let adBreakPosition = adBreakData["position"] as? Int ?? 0 + let adBreakStartTime = adBreakData["time"] as? Double ?? 0 + + let adBreakObject = Media.createAdBreakObjectWith(name: adBreakName, position: adBreakPosition, startTime: adBreakStartTime) + + let adName = adData["name"] as? String ?? "" + let adId = adData["id"] as? String ?? "" + let adPosition = adData["position"] as? Int ?? 0 + let adLength = adData["length"] as? Double ?? 0 + + let adObject = Media.createAdObjectWith(name: adName, id: adId, position: adPosition, length: adLength) + + var adMetadata: [String: String] = [:] + // Standard Ad Metadata + adMetadata[MediaConstants.AdMetadataKeys.ADVERTISER] = "Sample Advertiser" + adMetadata[MediaConstants.AdMetadataKeys.CAMPAIGN_ID] = "Sample Campaign" + + // Custom Ad Metadata + adMetadata["affiliate"] = "Sample affiliate" + + // AdBreak Start + _tracker?.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakObject, metadata: nil) + + // Ad Start + _tracker?.trackEvent(event: MediaEvent.AdStart, info: adObject, metadata: adMetadata) + } + + @objc func onAdComplete(notification: NSNotification) { + NSLog("\(logTag) onAdComplete()") + // Ad Complete + _tracker?.trackEvent(event: MediaEvent.AdComplete, info: nil, metadata: nil) + + // AdBreak Complete + _tracker?.trackEvent(event: MediaEvent.AdBreakComplete, info: nil, metadata: nil) + } + + @objc func onMuteUpdate(notification: NSNotification) { + let muted: Bool = (notification.userInfo?["muted"]) as? Bool ?? false + NSLog("\(logTag) onMuteUpdate(): Player muted: \(muted)") + + let muteState = Media.createStateObjectWith(stateName: MediaConstants.PlayerState.MUTE) + let event = muted ? MediaEvent.StateStart : MediaEvent.StateEnd + + _tracker?.trackEvent(event: event, info: muteState, metadata: nil) + } + + @objc func onCCUpdate(notification: NSNotification) { + let ccActive: Bool = (notification.userInfo?["ccActive"]) as? Bool ?? false + NSLog("\(logTag) onCCUpdate(): Closed caption active: \(ccActive)") + + let ccState = Media.createStateObjectWith(stateName: MediaConstants.PlayerState.CLOSED_CAPTION) + let event = ccActive ? MediaEvent.StateStart : MediaEvent.StateEnd + + _tracker?.trackEvent(event: event, info: ccState, metadata: nil) + } + + func setupPlayerNotifications() { + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onMainVideoLoaded), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_VIDEO_LOAD), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onMainVideoUnloaded), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_VIDEO_UNLOAD), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onPlay), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_PLAY), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onStop), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_PAUSE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onSeekStart), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_SEEK_START), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onSeekComplete), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_SEEK_COMPLETE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onComplete), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_COMPLETE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onChapterStart), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_CHAPTER_START), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onChapterComplete), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_CHAPTER_COMPLETE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onAdStart), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_AD_START), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onAdComplete), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_AD_COMPLETE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.updateQoE(notification:)), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_QOE_UPDATE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.updateCurrentPlaybackTime), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_PLAYHEAD_UPDATE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onCCUpdate), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_CC_CHANGE), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(Self.onMuteUpdate), name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_MUTE_CHANGE), object: nil) + + } + +} diff --git a/TestApps/TestApp/AppDelegate.swift b/TestApps/TestApp/AppDelegate.swift new file mode 100644 index 0000000..33feea0 --- /dev/null +++ b/TestApps/TestApp/AppDelegate.swift @@ -0,0 +1,62 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPEdge +import AEPEdgeIdentity +import AEPEdgeMedia +import AEPServices +import UIKit + +// MARK: TODO remove this once Assurance has tvOS support. +#if os(iOS) +import AEPAssurance +#endif + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + // TODO: Set up the preferred Environment File ID from your mobile property configured in Data Collection UI + private let ENVIRONMENT_FILE_ID = "" + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + MobileCore.setLogLevel(.trace) + var extensions: [NSObject.Type] = [Media.self, Edge.self, Identity.self] + + // MARK: TODO remove this once Assurance has tvOS support. + #if os(iOS) + extensions.append(Assurance.self) + #endif + + MobileCore.registerExtensions(extensions, { + MobileCore.configureWith(appId: self.ENVIRONMENT_FILE_ID) + }) + + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + // To handle deeplink on iOS versions 12 and below + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + #if os(iOS) + Assurance.startSession(url: url) + #endif + return true + } +} diff --git a/TestApps/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json b/TestApps/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/TestApps/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestApps/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/TestApps/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..c136eaf --- /dev/null +++ b/TestApps/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,148 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestApps/TestApp/Assets.xcassets/Contents.json b/TestApps/TestApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TestApps/TestApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestApps/TestApp/AssuranceView.swift b/TestApps/TestApp/AssuranceView.swift new file mode 100644 index 0000000..06721fe --- /dev/null +++ b/TestApps/TestApp/AssuranceView.swift @@ -0,0 +1,48 @@ +// +// Copyright 2022 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +#if os(iOS) +import AEPAssurance +import SwiftUI + +struct AssuranceView: View { + @State private var assuranceSessionUrl: String = "" + + var body: some View { + VStack(alignment: HorizontalAlignment.leading, spacing: 12) { + TextField("Copy Assurance Session URL here", text: $assuranceSessionUrl) + .background(Color.black) + .foregroundColor(.white) + .padding(5) + .border(.white) + + HStack { + Button(action: { + // step-assurance-start + // replace the url with the valid one generated on Assurance UI + if let url = URL(string: self.assuranceSessionUrl) { + Assurance.startSession(url: url) + } + // step-assurance-end + }, label: { + Text("Connect Assurance") + .frame(minWidth: 0, maxWidth: .infinity) + .padding() + .background(Color.white) + .foregroundColor(.black) + .font(.caption) + }).cornerRadius(5) + } + }.padding() + } +} +#endif diff --git a/TestApps/TestApp/ContentView.swift b/TestApps/TestApp/ContentView.swift new file mode 100644 index 0000000..10148f4 --- /dev/null +++ b/TestApps/TestApp/ContentView.swift @@ -0,0 +1,57 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import SwiftUI + +struct ContentView: View { + @State var videoPlayer = VideoPlayer(url: Bundle.main.url(forResource: "video", withExtension: "mp4")!) + @State var isPlayingAd = false + @State var videoSegment: String = "Main Content" // main, ad + @State var mediaAnalyticsProvider: MediaAnalyticsProvider? + + var body: some View { + VStack { + #if os(iOS) + AssuranceView() + Divider() + .frame(height: 2) + .background(Color.white) + #endif + + Text(self.isPlayingAd ? "Playing AD" : "Playing Main Video") + .foregroundColor(.white) + .padding() + + VideoPlayerView(player: videoPlayer.player) + .onAppear { + mediaAnalyticsProvider = MediaAnalyticsProvider() + mediaAnalyticsProvider?.initWithPlayer(player: videoPlayer) + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_AD_START))) { _ in + self.isPlayingAd = true + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_AD_COMPLETE))) { _ in + self.isPlayingAd = false + } + + } + .preferredColorScheme(.dark) + + } + +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/TestApps/TestApp/Info.plist b/TestApps/TestApp/Info.plist new file mode 100644 index 0000000..9742bf0 --- /dev/null +++ b/TestApps/TestApp/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/TestApps/TestApp/Player/VideoPlayer.swift b/TestApps/TestApp/Player/VideoPlayer.swift new file mode 100644 index 0000000..38b4914 --- /dev/null +++ b/TestApps/TestApp/Player/VideoPlayer.swift @@ -0,0 +1,420 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AVFoundation +import AVKit +import Foundation + +enum PlayerEvent { + static let PLAYER_EVENT_VIDEO_LOAD = "player_video_load" + static let PLAYER_EVENT_VIDEO_UNLOAD = "player_video_unload" + static let PLAYER_EVENT_PLAY = "player_play" + static let PLAYER_EVENT_PAUSE = "player_pause" + static let PLAYER_EVENT_COMPLETE = "player_complete" + static let PLAYER_EVENT_SEEK_START = "player_seek_start" + static let PLAYER_EVENT_SEEK_COMPLETE = "player_seek_complete" + static let PLAYER_EVENT_AD_START = "player_ad_start" + static let PLAYER_EVENT_AD_COMPLETE = "player_ad_complete" + static let PLAYER_EVENT_CHAPTER_START = "player_chapter_start" + static let PLAYER_EVENT_CHAPTER_COMPLETE = "player_chapter_complete" + static let PLAYER_EVENT_PLAYHEAD_UPDATE = "player_playhead_updates" + static let PLAYER_EVENT_QOE_UPDATE = "player_qoe_update" + static let PLAYER_EVENT_CC_CHANGE = "player_cc_change" + static let PLAYER_EVENT_MUTE_CHANGE = "player_mute_change" +} + +class VideoPlayer: AVPlayer { + var _videoLoaded: Bool = false + var _seeking: Bool = false + var _paused: Bool = false + + var _isMuted: Bool = false + var _isCCActive: Bool = false + + var _isInChapter: Bool = false + var _isInAd: Bool = false + var _chapterPosition: Int? + + let AD_START_POS: Double = 15 + let AD_END_POS: Double = 30 + let AD_LENGTH: Double = 15 + + let CHAPTER1_START_POS: Double = 0 + let CHAPTER1_END_POS: Double = 15 + let CHAPTER1_LENGTH: Double = 15 + + let CHAPTER2_START_POS: Double = 30 + let CHAPTER2_LENGTH: Double = 30 + + let QOEINFO_BITRATRE: Double = 500000 + let QOEINFO_STARTUPTIME: Double = 2 + let QOEINFO_FPS: Double = 24 + let QOEINFO_DROPPEDFRAMES: Double = 10 + let VIDEO_NAME: String = "Adobe Analytics marketing video" + let VIDEO_ID: String = "adobeanalytics" + + let MONITOR_TIMER_INTERVAL = 0.5 // 500 milliseconds + + let kStatusKey = "status" + let kRateKey = "rate" + let kMuteKey = "muted" + let kDurationKey = "duration" + let kPlaybackBufferEmpty = "playbackBufferEmpty" + let kPlaybackBufferFull = "playbackBufferFull" + let kPlaybackLikelyToKeepUp = "playbackLikelyToKeepUp" + + var player: AVPlayer = AVPlayer() + private var mediaPlayerKVOContext = 0 + + var timer: Timer? + + override init(url: URL) { + super.init() + _videoLoaded = false + _seeking = false + _paused = true + _isInAd = false + _isInChapter = false + _isMuted = false + _isCCActive = false + + player = AVPlayer(url: url) + player.addObserver(self, forKeyPath: kRateKey, options: [], context: &mediaPlayerKVOContext) + player.addObserver(self, forKeyPath: kStatusKey, options: [], context: &mediaPlayerKVOContext) + player.addObserver(self, forKeyPath: kMuteKey, options: [], context: &mediaPlayerKVOContext) + + NotificationCenter.default.addObserver(self, selector: #selector(onMediaFinishedPlaying), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: nil) + } + + func playVideo() { + player.play() + } + + func getCurrentPlaybackTime() -> TimeInterval { + let time = player.currentTime().seconds + + return time + } + + func duration() -> Double { + guard let currentItem = player.currentItem else { + return 0 + } + + return currentItem.duration.seconds + } + + deinit { + if let timer = timer { + timer.invalidate() + } + NotificationCenter.default.removeObserver(self) + } + + @objc func onMediaFinishedPlaying(notification: NSNotification) { + NSLog("MediaFinishedPlaying") + completeVideo() + } + + // getting events from player + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + + if context != &mediaPlayerKVOContext { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + } + + let avplayer = self.player + + if keyPath == kStatusKey { + if avplayer.status == AVPlayer.Status.failed { + pausePlayback() + } + } else if keyPath == kRateKey { + if avplayer.rate == 0.0 { + pausePlayback() + } else { + if _seeking { + NSLog("Stop seeking.") + _seeking = false + doPostSeekComputations() + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_SEEK_COMPLETE), object: self) + } else { + NSLog("Resume playback.") + openVideoIfNecessary() + _paused = false + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_PLAY), object: self) + } + } + } else if keyPath == kMuteKey { + if _isMuted != self.player.isMuted { + _isMuted = self.player.isMuted + var info: [String: Any] = [:] + info["muted"] = _isMuted + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_MUTE_CHANGE), object: self, userInfo: info) + } + } + + } + + func detectCCChange() { + guard let currentItem = self.player.currentItem else { return } + let asset = currentItem.asset + guard let group = asset.mediaSelectionGroup(forMediaCharacteristic: AVMediaCharacteristic.legible) else { return } + let option = currentItem.currentMediaSelection.selectedMediaOption(in: group) + let ccActive = (option != nil) + if _isCCActive != ccActive { + _isCCActive = ccActive + var info: [String: Any] = [:] + info["ccActive"] = _isCCActive + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_CC_CHANGE), object: self, userInfo: info) + } + } + + // player helper methods + func openVideoIfNecessary() { + + if !(_videoLoaded ) { + resetInternalState() + startVideo() + + // Start the monitor timer. + timer = Timer.scheduledTimer(timeInterval: MONITOR_TIMER_INTERVAL, target: self, selector: #selector(VideoPlayer.onTimerTick), userInfo: nil, repeats: true) + + } + } + + func pauseIfSeekHasNotStarted() { + if !(_seeking) { + pausePlayback() + } else { + NSLog("This pause is caused by a seek operation. Skipping.") + } + } + + // Call APIs + func pausePlayback() { + NSLog("Video paused") + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_PAUSE), object: self) + } + + func startVideo() { + let videoLength = player.currentItem?.duration.seconds ?? 0 + // Prepare the video info. + let videoInfo = ["id": VIDEO_ID, + "length": videoLength, + "name": VIDEO_NAME] as [String: Any] + + _videoLoaded = true + NSLog("Video started") + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_VIDEO_LOAD), object: self, userInfo: videoInfo) + } + + func completeVideo() { + // Complete the second chapter. + completeChapter() + NSLog("Video complete") + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_COMPLETE), object: self) + + unloadVideo() + } + + func unloadVideo() { + NSLog("Video end") + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_VIDEO_UNLOAD), object: self) + + if timer != nil { + timer?.invalidate() + } + resetInternalState() + } + + func resetInternalState() { + NSLog("reset") + _videoLoaded = false + _seeking = false + _paused = true + timer = nil + } + + func startChapter1() { + NSLog("start chapter 1") + _isInChapter = true + _chapterPosition = 1 + + let chapterInfo = ["name": "First Chapter", + "length": CHAPTER1_LENGTH, + "position": _chapterPosition as Any, + "time": CHAPTER1_START_POS] as [String: Any] + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_CHAPTER_START), object: self, userInfo: chapterInfo) + + // qoe update + qoeUpdate() + } + + func startChapter2() { + NSLog("start chapter 2") + _isInChapter = true + _chapterPosition = 2 + + let chapterInfo = ["name": "Second Chapter", + "length": CHAPTER2_LENGTH, + "position": _chapterPosition as Any, + "time": CHAPTER2_START_POS] as [String: Any] + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_CHAPTER_START), object: self, userInfo: chapterInfo) + } + + func completeChapter() { + NSLog("complete chapter") + _isInChapter = false + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_CHAPTER_COMPLETE), object: self) + } + + func startAd() { + NSLog("start Ad") + _isInAd = true + + let adBreakInfo = ["name": "First AD-Break", + "time": AD_START_POS, + "position": 1 as Int] as [String: Any] + + let adInfo = ["name": "Sample AD", + "id": "001", + "position": 1 as Int, + "length": AD_LENGTH] as [String: Any] + + let userInfo = [ "adbreak": adBreakInfo, + "ad": adInfo] + + // Start the ad. + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_AD_START), object: self, userInfo: userInfo) + } + + func completeAd() { + NSLog("complete Ad") + // Complete the ad. + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_AD_COMPLETE), object: self) + + // Clear the ad and ad-break info. + _isInAd = false + } + + func qoeUpdate() { + NSLog("update QoE") + // Update QoE + let qoeInfo = ["bitrate": QOEINFO_BITRATRE, + "startupTime": QOEINFO_STARTUPTIME, + "fps": QOEINFO_FPS, + "droppedFrames": QOEINFO_DROPPEDFRAMES] as [String: Any] + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_QOE_UPDATE), object: self, userInfo: qoeInfo) + } + + // Timeline helper methods + func doPostSeekComputations() { + let vTime = getCurrentPlaybackTime() + + // Seek inside the first chapter. + if vTime < CHAPTER1_END_POS { + // If we were not inside the first chapter before, trigger a chapter start + if !(_isInChapter) || _chapterPosition != 1 { + startChapter1() + + // If we were in the ad, clear the ad and ad-break info, but don't send the AD_COMPLETE event. + if _isInAd { + _isInAd = false + } + } + } + + // Seek inside ad. + else if vTime >= AD_START_POS && vTime < AD_END_POS { + // If we were not inside the ad before, trigger an ad-start. + if !(_isInAd) { + startAd() + + // Also, clear the chapter info, without sending the CHAPTER_COMPLETE event. + _isInChapter = false + } + } else // Seek inside the second chapter. + { + // If we were not inside the 2nd chapter before, trigger a chapter start + if !(_isInChapter) || _chapterPosition != 2 { + startChapter2() + + // If we were in the ad, clear the ad and ad-break info, but don't send the AD_COMPLETE event. + if _isInAd { + _isInAd = false + } + } + } + } + + @objc func onTimerTick() { + // NSLog("Timer Ticked") + if _seeking || (_paused) { + return + } + + let vTime = getCurrentPlaybackTime() + NotificationCenter.default.post(name: NSNotification.Name(rawValue: PlayerEvent.PLAYER_EVENT_PLAYHEAD_UPDATE), object: self) + + // If we are inside the ad content: + if vTime >= AD_START_POS && vTime < AD_END_POS { + if _isInChapter { + // If for some reason we were inside a chapter, close it. + completeChapter() + } + + if !(_isInAd) { + // Start the ad (if not already started). + startAd() + } + } + + // Otherwise, we are outside the ad content: + else { + if _isInAd { + // Complete the ad (if needed). + completeAd() + } + + if vTime < CHAPTER1_END_POS { + if _isInChapter && _chapterPosition != 1 { + // If we were inside another chapter, complete it. + completeChapter() + } + + if !(_isInChapter) { + // Start the first chapter. + startChapter1() + } + } else { + if _isInChapter && _chapterPosition != 2 { + // If we were inside another chapter, complete it. + completeChapter() + } + + if !(_isInChapter) { + // Start the second chapter. + startChapter2() + } + } + } + + self.detectCCChange() + } +} diff --git a/TestApps/TestApp/SceneDelegate.swift b/TestApps/TestApp/SceneDelegate.swift new file mode 100644 index 0000000..bf1ed96 --- /dev/null +++ b/TestApps/TestApp/SceneDelegate.swift @@ -0,0 +1,76 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import SwiftUI +import UIKit + +#if os(iOS) +import AEPAssurance +#endif + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + let contentView = ContentView() + + // Use a UIHostingController as window root view controller. + if let windowScene = scene as? UIWindowScene { + let window = UIWindow(windowScene: windowScene) + window.rootViewController = UIHostingController(rootView: contentView) + self.window = window + window.makeKeyAndVisible() + } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + @available(iOS 13.0, *) + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + // to note : this method is not called when an app not in memory (forceclosed) is opened with deeplink + #if os(iOS) + if let url = URLContexts.first?.url { + Assurance.startSession(url: url) + } + #endif + } +} diff --git a/TestApps/TestApp/VideoPlayerView.swift b/TestApps/TestApp/VideoPlayerView.swift new file mode 100644 index 0000000..860e65a --- /dev/null +++ b/TestApps/TestApp/VideoPlayerView.swift @@ -0,0 +1,28 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AVKit +import SwiftUI + +struct VideoPlayerView: UIViewControllerRepresentable { + let player: AVPlayer? + + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + uiViewController.player = player + } + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.player = player + return controller + } +} diff --git a/TestApps/TestApp/video.mp4 b/TestApps/TestApp/video.mp4 new file mode 100644 index 0000000..099828c Binary files /dev/null and b/TestApps/TestApp/video.mp4 differ diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml new file mode 100644 index 0000000..564691f --- /dev/null +++ b/Tests/.swiftlint.yml @@ -0,0 +1,38 @@ +disabled_rules: +- force_unwrapping +- force_cast +- force_try +line_length: + warning: 300 + error: 350 + ignores_function_declarations: true + ignores_comments: true + ignores_urls: true + ignores_interpolated_strings: true +function_parameter_count: + warning: 10 +function_body_length: + warning: 100 + error: 150 +type_body_length: + warning: 800 + error: 1000 +file_length: + warning: 1200 + error: 1500 + ignore_comment_only_lines: true +identifier_name: + max_length: + warning: 50 + error: 60 + allowed_symbols: "_" + excluded: + - id + - no + - ok + min_length: + warning: 1 +cyclomatic_complexity: + ignores_case_statements: true + warning: 15 + error: 30 diff --git a/Tests/FunctionalTests/Scenarios/AdChapterPlayback.swift b/Tests/FunctionalTests/Scenarios/AdChapterPlayback.swift new file mode 100644 index 0000000..4aeab70 --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/AdChapterPlayback.swift @@ -0,0 +1,110 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia + +class AdChapterPlayback: BaseScenarioTest { + + let mediaInfoWithDefaultPreroll = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0)! + let mediaMetadata = ["media.show": "sampleshow", "key1": "value1", "key2": "мểŧẳđαţả"] + + let adBreakInfo = AdBreakInfo(name: "adBreakName", position: 1, startTime: 1.1)! + let adBreakInfo2 = AdBreakInfo(name: "adBreakName2", position: 2, startTime: 2.2)! + + let adInfo = AdInfo(id: "adID", name: "adName", position: 1, length: 15.0)! + let adMetadata = ["media.ad.advertiser": "sampleAdvertiser", "key1": "value1", "key2": "мểŧẳđαţả"] + + let adInfo2 = AdInfo(id: "adID2", name: "adName2", position: 2, length: 20.0)! + let adMetadata2 = ["media.ad.advertiser": "sampleAdvertiser2", "key2": "value2", "key3": "мểŧẳđαţả"] + + let chapterInfo = ChapterInfo(name: "chapterName", position: 1, startTime: 1.1, length: 30)! + let chapterMetadata = ["media.artist": "sampleArtist", "key1": "value1", "key2": "мểŧẳđαţả"] + + let chapterInfo2 = ChapterInfo(name: "chapterName2", position: 2, startTime: 2.2, length: 40)! + let chapterMetadata2 = ["media.artist": "sampleArtist2", "key2": "value2", "key3": "мểŧẳđαţả"] + + // Expected Values + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // tests + func testMultipleAdChapter_usingRealTimeTracker_shouldDispatchAdBreakAdAndChapterEventsProperly() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) // will send play since adStart triggers trackPlay internally + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + // should switch to play state + mediaTracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo.toMap(), metadata: chapterMetadata) + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.ChapterComplete) + mediaTracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo2.toMap(), metadata: chapterMetadata2) + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.ChapterComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo2.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo2.toMap(), metadata: adMetadata2) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 10, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 15, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterStart, playhead: 0, ts: 15, backendSessionId: backendSessionId, info: chapterInfo.toMap(), metadata: chapterMetadata), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 16, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 26, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterComplete, playhead: 15, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterStart, playhead: 15, ts: 30, backendSessionId: backendSessionId, info: chapterInfo2.toMap(), metadata: chapterMetadata2), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 21, ts: 36, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterComplete, playhead: 30, ts: 45, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 30, ts: 45, backendSessionId: backendSessionId, info: adBreakInfo2.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 30, ts: 45, backendSessionId: backendSessionId, info: adInfo2.toMap(), metadata: adMetadata2, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 30, ts: 45, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 30, ts: 55, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 30, ts: 60, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 30, ts: 60, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 30, ts: 60, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 30, ts: 60, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } +} diff --git a/Tests/FunctionalTests/Scenarios/AdPlayback.swift b/Tests/FunctionalTests/Scenarios/AdPlayback.swift new file mode 100644 index 0000000..9eb03df --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/AdPlayback.swift @@ -0,0 +1,153 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia + +class AdPlayback: BaseScenarioTest { + + let mediaInfo = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0, prerollWaitingTime: 0)! + let mediaInfoWithDefaultPreroll = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0)! + let mediaMetadata = ["media.show": "sampleshow", "key1": "value1", "key2": "мểŧẳđαţả"] + + let adBreakInfo = AdBreakInfo(name: "adBreakName", position: 1, startTime: 1.1)! + let adBreakInfo2 = AdBreakInfo(name: "adBreakName2", position: 2, startTime: 2.2)! + + let adInfo = AdInfo(id: "adID", name: "adName", position: 1, length: 15.0)! + let adMetadata = ["media.ad.advertiser": "sampleAdvertiser", "key1": "value1", "key2": "мểŧẳđαţả"] + + let adInfo2 = AdInfo(id: "adID2", name: "adName2", position: 2, length: 20.0)! + let adMetadata2 = ["media.ad.advertiser": "sampleAdvertiser2", "key2": "value2", "key3": "мểŧẳđαţả"] + + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // tests + func testPrerollAd_usingRealTimeTracker_shouldSendAdBreakAndAdEventsInProperOrder() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) // will send play since adStart triggers trackPlay internally + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + // should switch to play state + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 10, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 16, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 26, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 15, ts: 30, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testMultipleAdBreakMultipleAds_usingRealTimeTracker_shouldSendMultipleAdBreakAndAdEventsInProperOrder() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) // will send play since adStart triggers trackPlay internally + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo2.toMap(), metadata: adMetadata2) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + // explicitly switch to play state + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo2.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo2.toMap(), metadata: adMetadata2) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 10, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 15, backendSessionId: backendSessionId, info: adInfo2.toMap(), metadata: adMetadata2, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 25, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 0, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 31, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 41, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 15, ts: 45, backendSessionId: backendSessionId, info: adBreakInfo2.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 15, ts: 45, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 15, ts: 45, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 55, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 15, ts: 60, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 15, ts: 60, backendSessionId: backendSessionId, info: adInfo2.toMap(), metadata: adMetadata2, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 15, ts: 60, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 70, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 15, ts: 75, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 15, ts: 75, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 15, ts: 75, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 15, ts: 75, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } +} diff --git a/Tests/FunctionalTests/Scenarios/BaseScenarioTest.swift b/Tests/FunctionalTests/Scenarios/BaseScenarioTest.swift new file mode 100644 index 0000000..9385b23 --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/BaseScenarioTest.swift @@ -0,0 +1,93 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class BaseScenarioTest: XCTestCase { + var mediaTracker: MediaEventGenerator! + var mediaEventProcessorSpy: MediaEventProcessorSpy! + var mediaEventTracker: MediaEventTracking! + var dispatchedEvents: [Event] = [] + var mediaState: MediaState! + + static let DEFAULT_WAIT_TIMEOUT = TimeInterval(1) + + func getMediaSessions() -> [String: MediaSession] { + return mediaEventProcessorSpy.mediaSessions + } + + func fakeDispatcher(_ event: Event) { + dispatchedEvents.append(event) + } + + func setup() { + self.dispatchedEvents = [] + self.mediaEventProcessorSpy = MediaEventProcessorSpy(dispatcher: fakeDispatcher(_:)) + self.mediaState = MediaState() + createTracker() + } + + func mockSharedStateUpdate(sessionId: String, sharedStateData: [String: Any]) { + mediaState.updateConfigurationSharedState(sharedStateData) + mediaEventProcessorSpy.updateMediaState(configurationSharedStateData: sharedStateData) + if let session = mediaEventProcessorSpy.mediaSessions[sessionId] { + session.handleMediaStateUpdate() + } + wait() + } + + func createTracker(trackerConfig: [String: Any] = [:]) { + mediaEventTracker = MediaEventTracker(eventProcessor: mediaEventProcessorSpy, config: trackerConfig) + mediaTracker = MediaEventGenerator(config: trackerConfig) + mediaTracker.connectCoreTracker(tracker: mediaEventTracker) + mediaTracker.setTimeStamp(value: 0) + } + + func incrementTrackerTime(seconds: Int, updatePlayhead: Bool) { + for _ in 1...seconds { + mediaTracker.incrementTimeStamp(value: 1000) + mediaTracker.incrementCurrentPlayhead(time: updatePlayhead ? 1 : 0) + } + } + + func assertEqualsEvents(expectedEvents: [Event], actualEvents: [Event]) { + if expectedEvents.count != actualEvents.count { + XCTFail("Expected number of dispatched events (\(expectedEvents.count)) != actual number of dispatched events (\(actualEvents.count))") + return + } + + for i in 0...expectedEvents.count - 1 { + let expectedEvent = expectedEvents[i] + let actualEvent = actualEvents[i] + XCTAssertEqual(expectedEvent.name, actualEvent.name) + XCTAssertEqual(expectedEvent.type, actualEvent.type) + XCTAssertEqual(expectedEvent.source, actualEvent.source) + + guard let expectedData = expectedEvent.data, let actualData = actualEvent.data else { + XCTFail("Event data cannot be null") + return + } + + XCTAssertTrue( NSDictionary(dictionary: expectedData).isEqual(to: actualData), "Expected event data \n(\(expectedData)\n) does not match the actual event data \n(\(actualData))\n") + } + } + + func wait(_ interval: TimeInterval = DEFAULT_WAIT_TIMEOUT) { + let expectation = XCTestExpectation() + DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + interval - 0.1) { + expectation.fulfill() + } + wait(for: [expectation], timeout: interval) + } +} diff --git a/Tests/FunctionalTests/Scenarios/ChapterPlayback.swift b/Tests/FunctionalTests/Scenarios/ChapterPlayback.swift new file mode 100644 index 0000000..c3dc58a --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/ChapterPlayback.swift @@ -0,0 +1,116 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia + +class ChapterPlayback: BaseScenarioTest { + + let mediaInfo = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0, prerollWaitingTime: 0)! + let mediaInfoWithDefaultPreroll = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0)! + let mediaMetadata = ["media.show": "sampleshow", "key1": "value1", "key2": "мểŧẳđαţả"] + + let chapterInfo = ChapterInfo(name: "chapterName", position: 1, startTime: 1.1, length: 30)! + let chapterMetadata = ["media.artist": "sampleArtist", "key1": "value1", "key2": "мểŧẳđαţả"] + + let chapterInfo2 = ChapterInfo(name: "chapterName2", position: 2, startTime: 2.2, length: 40)! + let chapterMetadata2 = ["media.artist": "sampleArtist2", "key2": "value2", "key3": "мểŧẳđαţả"] + + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // tests + func testChapter_usingRealTimeTracker_shouldSendChapterEvents() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + mediaTracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo.toMap(), metadata: chapterMetadata) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.ChapterComplete) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: chapterInfo.toMap(), metadata: chapterMetadata), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 11, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterComplete, playhead: 15, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 15, ts: 15, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testMultipleChapter_usingRealTimeTracker_shouldSendMultipleChapterEventsInProperOrder() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo.toMap(), metadata: chapterMetadata) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.ChapterComplete) + mediaTracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo2.toMap(), metadata: chapterMetadata2) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.ChapterComplete) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: chapterInfo.toMap(), metadata: chapterMetadata), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 11, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterComplete, playhead: 15, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterStart, playhead: 15, ts: 15, backendSessionId: backendSessionId, info: chapterInfo2.toMap(), metadata: chapterMetadata2), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 21, ts: 21, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterComplete, playhead: 30, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 30, ts: 30, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + +} diff --git a/Tests/FunctionalTests/Scenarios/CustomError.swift b/Tests/FunctionalTests/Scenarios/CustomError.swift new file mode 100644 index 0000000..0e2f0ab --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/CustomError.swift @@ -0,0 +1,69 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http:www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia + +class CustomError: BaseScenarioTest { + + let mediaInfoWithDefaultPreroll = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0)! + let mediaMetadata = ["media.show": "sampleshow", "key1": "value1", "key2": "мểŧẳđαţả"] + + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // tests + func testCustomError_usingRealTimeTracker_dispatchesErrorEventWithSetErrorId() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackError(errorId: "1000.2000.3000") + incrementTrackerTime(seconds: 15, updatePlayhead: true) + mediaTracker.trackError(errorId: "custom.error.code") + mediaTracker.trackError(errorId: "") // ignored + mediaTracker.trackComplete() + + wait() + + let errorInfo1 = ["error.id": "1000.2000.3000", "error.source": "player"] + let errorInfo2 = ["error.id": "custom.error.code", "error.source": "player"] + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.error, playhead: 5, ts: 5, backendSessionId: backendSessionId, info: errorInfo1), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 11, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.error, playhead: 20, ts: 20, backendSessionId: backendSessionId, info: errorInfo2), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 20, ts: 20, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + + } +} diff --git a/Tests/FunctionalTests/Scenarios/CustomPingDuration.swift b/Tests/FunctionalTests/Scenarios/CustomPingDuration.swift new file mode 100644 index 0000000..f2001e1 --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/CustomPingDuration.swift @@ -0,0 +1,127 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import Foundation + +class CustomPingDuration: BaseScenarioTest { + + var mediaInfo = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0, prerollWaitingTime: 0)! + var mediaMetadata = ["media.show": "sampleshow", "key1": "value1"] + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + let adBreakInfo = AdBreakInfo(name: "adBreakName", position: 1, startTime: 1.1)! + let adInfo = AdInfo(id: "adID", name: "adName", position: 1, length: 15.0)! + let adMetadata = ["media.ad.advertiser": "sampleAdvertiser", "key1": "value1", "key2": "мểŧẳđαţả"] + + override func setUp() { + super.setup() + } + + // tests + func testTrackSimplePlayBackWithAd_usingRealTimeTracker_withValidCustomPingInterval_dispatchesPingAfterCustomInterval() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + // Custom ping duration + let trackerConfig = [MediaConstants.TrackerConfig.MAIN_PING_INTERVAL: 15, MediaConstants.TrackerConfig.AD_PING_INTERVAL: 1] + createTracker(trackerConfig: trackerConfig) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) // will send play since adStart triggers trackPlay internally + incrementTrackerTime(seconds: 5, updatePlayhead: false) // will send ping since interval > custom ad interval (1) seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + incrementTrackerTime(seconds: 31, updatePlayhead: true) // will send ping since interval > custom main interval (15) seconds + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 2, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 3, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 4, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 6, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 16, ts: 21, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 31, ts: 36, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 31, ts: 36, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testTrackSimplePlayBackWithAd_usingRealTimeTracker_withInvalidValidCustomPingDuration_dispatchesPingAfterDefaultInterval() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + // Custom ping duration + let trackerConfig = [MediaConstants.TrackerConfig.MAIN_PING_INTERVAL: 1, MediaConstants.TrackerConfig.AD_PING_INTERVAL: 11] + createTracker(trackerConfig: trackerConfig) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) // will send play since adStart triggers trackPlay internally + incrementTrackerTime(seconds: 5, updatePlayhead: false) // will not send ping since interval < default ad interval (10) seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + incrementTrackerTime(seconds: 31, updatePlayhead: true) // will send ping since interval > custom main interval (10) seconds + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 6, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 16, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 21, ts: 26, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 31, ts: 36, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 31, ts: 36, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } +} diff --git a/Tests/FunctionalTests/Scenarios/CustomStatePlayback.swift b/Tests/FunctionalTests/Scenarios/CustomStatePlayback.swift new file mode 100644 index 0000000..855a5cc --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/CustomStatePlayback.swift @@ -0,0 +1,162 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http:www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia + +class CustomStatePlayback: BaseScenarioTest { + + let mediaInfo = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0, prerollWaitingTime: 0)! + let mediaMetadata = ["media.show": "sampleshow", "key1": "value1", "key2": "мểŧẳđαţả"] + + let customStateInfo = StateInfo(stateName: "customStateName")! + let standardStateMute = StateInfo(stateName: MediaConstants.PlayerState.MUTE)! + let standardStateFullScreen = StateInfo(stateName: MediaConstants.PlayerState.FULLSCREEN)! + + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // tests + func testCustomState_usingRealTimeTracker_dispatchesStateStartAndEndEvents() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: customStateInfo.toMap()) + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackEvent(event: MediaEvent.StateEnd, info: customStateInfo.toMap()) + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: standardStateMute.toMap()) + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: standardStateFullScreen.toMap()) + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackEvent(event: MediaEvent.StateEnd, info: standardStateMute.toMap()) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: customStateInfo.toMap(), stateStart: true), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 5, ts: 5, backendSessionId: backendSessionId, info: customStateInfo.toMap(), stateStart: false), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 10, ts: 10, backendSessionId: backendSessionId, info: standardStateMute.toMap(), stateStart: true), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 10, ts: 10, backendSessionId: backendSessionId, info: standardStateFullScreen.toMap(), stateStart: true), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 11, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 15, ts: 15, backendSessionId: backendSessionId, info: standardStateMute.toMap(), stateStart: false), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 15, ts: 15, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + + } + + func testCustomState_withoutStateEnd_usingRealTimeTracker_dispatchesStateStartEvents() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: customStateInfo.toMap()) + incrementTrackerTime(seconds: 5, updatePlayhead: true) + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: standardStateMute.toMap()) + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: standardStateFullScreen.toMap()) + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: customStateInfo.toMap(), stateStart: true), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 10, ts: 10, backendSessionId: backendSessionId, info: standardStateMute.toMap(), stateStart: true), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 10, ts: 10, backendSessionId: backendSessionId, info: standardStateFullScreen.toMap(), stateStart: true), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 11, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 15, ts: 15, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + + } + + func testCustomState_moreThanTenUniqueStates_usingRealTimeTracker_dispatchesFirstTenStates() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + for i in 1...15 { + let info = StateInfo(stateName: "state_\(i)")! + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: info.toMap()) + } + + mediaTracker.trackComplete() + + wait() + + var expectedStateStartEvents = [Event]() + // We will have states only till state_10 + for i in 1...10 { + let info = StateInfo(stateName: "state_\(i)")! + expectedStateStartEvents.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: info.toMap(), stateStart: true)) + } + + var expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId) + ] + + expectedEvents.insert(contentsOf: expectedStateStartEvents, at: expectedEvents.endIndex) + expectedEvents.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 0, ts: 0, backendSessionId: backendSessionId)) + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + + } + +} diff --git a/Tests/FunctionalTests/Scenarios/SimplePlayback.swift b/Tests/FunctionalTests/Scenarios/SimplePlayback.swift new file mode 100644 index 0000000..d5557af --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/SimplePlayback.swift @@ -0,0 +1,191 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import Foundation + +class SimplePlayback: BaseScenarioTest { + + var mediaInfo = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0, prerollWaitingTime: 0)! + var mediaMetadata = ["media.show": "sampleshow", "key1": "value1"] + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // tests + func testTrackSimplePlayBack_usingRealTimeTracker_dispatchesAllEventsInOrderWithCorrectPlayheadAndTS() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 5, updatePlayhead: true) // content start play ping at 1 second + mediaTracker.trackPause() + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 5, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 5, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 5, ts: 20, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 20, ts: 35, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testTrackSimplePlayBack_withSessionEnd_usingRealTimeTracker_dispatchesAllEventsInOrderWithCorrectPlayheadAndTS() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 5, updatePlayhead: true) // content start play ping at 1 second + mediaTracker.trackPause() + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackSessionEnd() // sends sessionEnd event + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 5, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 5, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 5, ts: 20, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionEnd, playhead: 20, ts: 35, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testTrackSimplePlayBack_withBuffer_usingRealTimeTracker_dispatchesAllEventsInOrderWithCorrectPlayheadAndTS() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.BufferStart) + incrementTrackerTime(seconds: 5, updatePlayhead: false) // content start play ping at 1 second + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackEvent(event: MediaEvent.BufferStart) + incrementTrackerTime(seconds: 15, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.BufferComplete) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.bufferStart, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 6, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.bufferStart, playhead: 5, ts: 10, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 5, ts: 20, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 5, ts: 25, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 35, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 20, ts: 40, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testTrackSimplePlayBack_withSeek_usingRealTimeTracker_dispatchesAllEventsInOrderWithCorrectPlayheadAndTS() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfo.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.SeekStart) + incrementTrackerTime(seconds: 5, updatePlayhead: false) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackEvent(event: MediaEvent.SeekStart) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.SeekComplete) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfo.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 6, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 5, ts: 10, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 5, ts: 20, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 5, ts: 25, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 35, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 20, ts: 40, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } +} diff --git a/Tests/FunctionalTests/Scenarios/SpecialAdPlayback.swift b/Tests/FunctionalTests/Scenarios/SpecialAdPlayback.swift new file mode 100644 index 0000000..05f8771 --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/SpecialAdPlayback.swift @@ -0,0 +1,246 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http:www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia + +class SpecialAdPlayback: BaseScenarioTest { + + let mediaInfoWithDefaultPreroll = MediaInfo(id: "mediaID", name: "mediaName", streamType: "aod", mediaType: MediaType.Audio, length: 30.0)! + let mediaMetadata = ["media.show": "sampleshow", "key1": "value1", "key2": "мểŧẳđαţả"] + + let adBreakInfo = AdBreakInfo(name: "adBreakName", position: 1, startTime: 1.1)! + let adBreakInfo2 = AdBreakInfo(name: "adBreakName2", position: 2, startTime: 2.2)! + + let adInfo = AdInfo(id: "adID", name: "adName", position: 1, length: 15.0)! + let adMetadata = ["media.ad.advertiser": "sampleAdvertiser", "key1": "value1", "key2": "мểŧẳđαţả"] + + let adInfo2 = AdInfo(id: "adID2", name: "adName2", position: 2, length: 20.0)! + let adMetadata2 = ["media.ad.advertiser": "sampleAdvertiser2", "key2": "value2", "key3": "мểŧẳđαţả"] + + let chapterInfo = ChapterInfo(name: "chapterName", position: 1, startTime: 1.1, length: 30)! + let chapterMetadata = ["media.artist": "sampleArtist", "key1": "value1", "key2": "мểŧẳđαţả"] + + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // tests + func testDelayedAds_usingRealTimeTracker_willSendPingEventsBeforeDelayedAdStartEvents() { + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + // should switch to play state + mediaTracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo.toMap(), metadata: chapterMetadata) + incrementTrackerTime(seconds: 15, updatePlayhead: true) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.ChapterComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo2.toMap()) + incrementTrackerTime(seconds: 25, updatePlayhead: false) // will send 2 pings since interval > 20 seconds + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo2.toMap(), metadata: adMetadata2) + incrementTrackerTime(seconds: 15, updatePlayhead: false) // will send ping since interval > 10 seconds + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 10, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 15, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 15, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 25, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 0, ts: 30, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 30, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterStart, playhead: 0, ts: 30, backendSessionId: backendSessionId, info: chapterInfo.toMap(), metadata: chapterMetadata), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 31, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 11, ts: 41, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterComplete, playhead: 15, ts: 45, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 15, ts: 45, backendSessionId: backendSessionId, info: adBreakInfo2.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 51, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 61, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 15, ts: 70, backendSessionId: backendSessionId, info: adInfo2.toMap(), metadata: adMetadata2, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 15, ts: 70, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 15, ts: 80, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 15, ts: 85, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 15, ts: 85, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 15, ts: 85, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 15, ts: 85, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testAdWithSeek_usingRealTimeTracker_shouldSendPauseStartEventForAdSection() { + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) + incrementTrackerTime(seconds: 5, updatePlayhead: false) + // seek out of ad into main content chapter + mediaTracker.trackEvent(event: MediaEvent.SeekStart) + mediaTracker.incrementTimeStamp(value: 1000) + mediaTracker.incrementCurrentPlayhead(time: 5) + mediaTracker.trackEvent(event: MediaEvent.SeekComplete) + mediaTracker.trackEvent(event: MediaEvent.AdSkip) // seeking from ad to main section + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + // should switch to play state + mediaTracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo.toMap(), metadata: chapterMetadata) + incrementTrackerTime(seconds: 15, updatePlayhead: true) + // seek out of chapter into Ad + mediaTracker.trackEvent(event: MediaEvent.SeekStart) + mediaTracker.incrementTimeStamp(value: 1000) + mediaTracker.incrementCurrentPlayhead(time: 5) + mediaTracker.trackEvent(event: MediaEvent.ChapterSkip) // Seeking from chapter to ad section + mediaTracker.trackEvent(event: MediaEvent.SeekComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo2.toMap()) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo2.toMap(), metadata: adMetadata2) + incrementTrackerTime(seconds: 15, updatePlayhead: false) + mediaTracker.trackSessionEnd() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 5, ts: 6, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adSkip, playhead: 5, ts: 6, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 5, ts: 6, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 5, ts: 6, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterStart, playhead: 5, ts: 6, backendSessionId: backendSessionId, info: chapterInfo.toMap(), metadata: chapterMetadata), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 6, ts: 7, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 16, ts: 17, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 20, ts: 21, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.chapterSkip, playhead: 25, ts: 22, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 25, ts: 22, backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 25, ts: 22, backendSessionId: backendSessionId, info: adBreakInfo2.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 25, ts: 22, backendSessionId: backendSessionId, info: adInfo2.toMap(), metadata: adMetadata2, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 25, ts: 22, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 25, ts: 32, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adSkip, playhead: 25, ts: 37, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 25, ts: 37, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionEnd, playhead: 25, ts: 37, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + func testAdWithBuffer_usingRealtimeTracker_shouldSendBufferEventsForAdSection() { + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait(4) + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackEvent(event: MediaEvent.BufferStart) + incrementTrackerTime(seconds: 5, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.BufferComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo.toMap()) + mediaTracker.trackEvent(event: MediaEvent.BufferStart) + incrementTrackerTime(seconds: 5, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.BufferComplete) + mediaTracker.trackEvent(event: MediaEvent.AdStart, info: adInfo.toMap(), metadata: adMetadata) + incrementTrackerTime(seconds: 15, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.BufferStart) + incrementTrackerTime(seconds: 5, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.BufferComplete) + incrementTrackerTime(seconds: 5, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.AdComplete) + mediaTracker.trackEvent(event: MediaEvent.AdBreakComplete) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 5, updatePlayhead: true) + mediaTracker.trackComplete() + + wait() + + let expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.bufferStart, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 0, ts: 5, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakStart, playhead: 0, ts: 5, backendSessionId: backendSessionId, info: adBreakInfo.toMap()), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adStart, playhead: 0, ts: 10, backendSessionId: backendSessionId, info: adInfo.toMap(), metadata: adMetadata, mediaState: mediaState), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 0, ts: 10, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 0, ts: 20, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.bufferStart, playhead: 0, ts: 25, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 0, ts: 30, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adComplete, playhead: 0, ts: 35, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.adBreakComplete, playhead: 0, ts: 35, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 0, ts: 35, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 35, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 36, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 5, ts: 40, backendSessionId: backendSessionId) + ] + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + +} diff --git a/Tests/FunctionalTests/Scenarios/Timeout.swift b/Tests/FunctionalTests/Scenarios/Timeout.swift new file mode 100644 index 0000000..85ed212 --- /dev/null +++ b/Tests/FunctionalTests/Scenarios/Timeout.swift @@ -0,0 +1,213 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http:www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia + +class Timeout: BaseScenarioTest { + + let standardStateCC = StateInfo(stateName: MediaConstants.PlayerState.CLOSED_CAPTION)! + let mediaInfoWithDefaultPreroll = MediaInfo(id: "mediaID", name: "mediaName", streamType: "vod", mediaType: MediaType.Video, length: 30.0)! + let mediaMetadata = ["media.show": "sampleshow", "key1": "value1", "key2": "мểŧẳđαţả"] + var mediaSharedState: [String: Any] = ["edgemedia.channel": "test_channel", "edgemedia.playerName": "test_playerName", "edgemedia.appVersion": "test_appVersion"] + + override func setUp() { + super.setup() + } + + // SDK automatically restarts the long running session >= 24 hours + func testSessionActiveForMoreThan24Hours_usingRealTimeTracker_shouldEndAndResumeSessionAutomatically() { + // setup + let sessionId1 = "1" + let sessionId2 = "2" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: sessionId1, sharedStateData: mediaSharedState) + + // test + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: sessionId1, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackPlay() + // wait for 24 hours + incrementTrackerTime(seconds: 86400, updatePlayhead: true) + wait() + + // mock sessionIDUpdate for restart sceario session2 + mediaEventProcessorSpy.mockBackendSessionId(sessionId: sessionId2, sessionStartEvent: dispatchedEvents[8644], fakeBackendId: backendSessionId) + + // wait for 20 seconds + incrementTrackerTime(seconds: 20, updatePlayhead: true) + mediaTracker.trackComplete() + + wait() + + var resumedMediaInfo = mediaInfoWithDefaultPreroll.toMap() + resumedMediaInfo["media.resumed"] = true + + var expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: sessionId1), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId) + ] + + var pingList = [Event]() + for i in stride(from: 11, to: 86400, by: 10) { + pingList.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: Int64(i), ts: TimeInterval(i), backendSessionId: backendSessionId)) + } + + expectedEvents.insert(contentsOf: pingList, at: expectedEvents.endIndex) + expectedEvents.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionEnd, playhead: 86400, ts: 86400, backendSessionId: backendSessionId)) + // Session2 + + let expectedEventsSession2: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 86400, ts: 86400, backendSessionId: backendSessionId, info: resumedMediaInfo, metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: sessionId2), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 86400, ts: 86400, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 86401, ts: 86401, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 86411, ts: 86411, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 86420, ts: 86420, backendSessionId: backendSessionId) + ] + expectedEvents.insert(contentsOf: expectedEventsSession2, at: expectedEvents.endIndex) + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + + } + + func testIdleTimeOut_RealTimeTrackershouldSendSessionEndAutomaticallyAfterIdleTimeout() { + // setup + let curSessionId = "1" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: curSessionId, sharedStateData: mediaSharedState) + + // test idle timeout after 30 mins + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: curSessionId, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 3, updatePlayhead: true) + mediaTracker.trackPause() + // wait for 30 mins + incrementTrackerTime(seconds: 1800, updatePlayhead: false) + + wait() + + var expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: curSessionId), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 3, ts: 3, backendSessionId: backendSessionId) + ] + var pingList = [Event]() + for i in stride(from: 3, to: 1793, by: 10) { + pingList.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 3, ts: TimeInterval(i + 10), backendSessionId: backendSessionId)) + } + + expectedEvents.insert(contentsOf: pingList, at: expectedEvents.endIndex) + expectedEvents.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionEnd, playhead: 3, ts: 1803, backendSessionId: backendSessionId)) + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + + // trackPlay after sessionEnd because of idleTimeout will resume the session. + // trackSessionStart with resume flag set to true is sent by the SDK sutomatically on receiving play on idle session + func testPlay_afterIdleTimeOut_usingRealTimeTracker_shouldAutomaticallyStartNewSessionWithResumeFlagSet() { + // setup + let sessionId1 = "1" + let sessionId2 = "2" + let backendSessionId = "FakeBackendID" + mockSharedStateUpdate(sessionId: sessionId1, sharedStateData: mediaSharedState) + + // test idle timeout after 30 mins and issue a play event, new session start + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + wait() + + // mock sessionIDUpdate + mediaEventProcessorSpy.mockBackendSessionId(sessionId: sessionId1, sessionStartEvent: dispatchedEvents[0], fakeBackendId: backendSessionId) + + mediaTracker.trackSessionStart(info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata) + mediaTracker.trackPlay() + incrementTrackerTime(seconds: 3, updatePlayhead: true) + mediaTracker.trackPause() + // wait for 30 mins + incrementTrackerTime(seconds: 600, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.StateStart, info: standardStateCC.toMap()) + incrementTrackerTime(seconds: 600, updatePlayhead: false) + mediaTracker.trackEvent(event: MediaEvent.StateEnd, info: standardStateCC.toMap()) + incrementTrackerTime(seconds: 600, updatePlayhead: false) + mediaTracker.trackPlay() + + wait() + // mock sessionIDUpdate for restart sceario session2 + mediaEventProcessorSpy.mockBackendSessionId(sessionId: sessionId2, sessionStartEvent: dispatchedEvents[187], fakeBackendId: backendSessionId) + incrementTrackerTime(seconds: 3, updatePlayhead: true) + mediaTracker.trackComplete() + + wait() + + var expectedEvents: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 0, ts: 0, backendSessionId: backendSessionId, info: mediaInfoWithDefaultPreroll.toMap(), metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: sessionId1), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 0, ts: 0, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 1, ts: 1, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.pauseStart, playhead: 3, ts: 3, backendSessionId: backendSessionId) + ] + + var pingList1 = [Event]() + for i in stride(from: 3, to: 603, by: 10) { + pingList1.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 3, ts: TimeInterval(i + 10), backendSessionId: backendSessionId)) + } + + expectedEvents.insert(contentsOf: pingList1, at: expectedEvents.endIndex) + expectedEvents.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 3, ts: 603, backendSessionId: backendSessionId, info: standardStateCC.toMap(), stateStart: true)) + + var pingList2 = [Event]() + for i in stride(from: 603, to: 1203, by: 10) { + pingList2.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 3, ts: TimeInterval(i + 10), backendSessionId: backendSessionId)) + } + expectedEvents.insert(contentsOf: pingList2, at: expectedEvents.endIndex) + expectedEvents.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.statesUpdate, playhead: 3, ts: 1203, backendSessionId: backendSessionId, info: standardStateCC.toMap(), stateStart: false)) + + var pingList3 = [Event]() + for i in stride(from: 1203, to: 1793, by: 10) { + pingList3.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.ping, playhead: 3, ts: TimeInterval(i + 10), backendSessionId: backendSessionId)) + } + expectedEvents.insert(contentsOf: pingList3, at: expectedEvents.endIndex) + expectedEvents.append(EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionEnd, playhead: 3, ts: 1803, backendSessionId: backendSessionId)) + + var resumedMediaInfo = mediaInfoWithDefaultPreroll.toMap() + resumedMediaInfo["media.resumed"] = true + + let expectedEventsSession2: [Event] = [ + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionStart, playhead: 3, ts: 1803, backendSessionId: backendSessionId, info: resumedMediaInfo, metadata: mediaMetadata, mediaState: mediaState), + EdgeEventHelper.generateSessionCreatedEvent(trackerSessionId: mediaEventProcessorSpy.getTrackerSessionId(sessionId: sessionId2), backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 3, ts: 1803, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.play, playhead: 4, ts: 1804, backendSessionId: backendSessionId), + EdgeEventHelper.generateEdgeEvent(eventType: XDMMediaEventType.sessionComplete, playhead: 6, ts: 1806, backendSessionId: backendSessionId) + ] + expectedEvents.insert(contentsOf: expectedEventsSession2, at: expectedEvents.endIndex) + + // verify + assertEqualsEvents(expectedEvents: expectedEvents, actualEvents: dispatchedEvents) + } + +} diff --git a/Tests/FunctionalTests/Utils/MediaEventProcessorSpy.swift b/Tests/FunctionalTests/Utils/MediaEventProcessorSpy.swift new file mode 100644 index 0000000..99b567f --- /dev/null +++ b/Tests/FunctionalTests/Utils/MediaEventProcessorSpy.swift @@ -0,0 +1,34 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import AEPServices +import Foundation + +class MediaEventProcessorSpy: MediaEventProcessor { + + private var atomicSessionId: AtomicCounter = AtomicCounter() + override var uuid: String { + return "\(atomicSessionId.incrementAndGet())" + } + + func mockBackendSessionId(sessionId: String, sessionStartEvent: Event, fakeBackendId: String) { + if let session = mediaSessions[sessionId] { + session.handleSessionUpdate(requestEventId: sessionStartEvent.id.uuidString, backendSessionId: fakeBackendId) + } + } + + func getTrackerSessionId(sessionId: String) -> String { + return mediaSessions[sessionId]?.trackerSessionId ?? "" + } +} diff --git a/Tests/IntegrationTests/Media+Edge+EdgeIdentityFunctionalTests.swift b/Tests/IntegrationTests/Media+Edge+EdgeIdentityFunctionalTests.swift new file mode 100644 index 0000000..e031aef --- /dev/null +++ b/Tests/IntegrationTests/Media+Edge+EdgeIdentityFunctionalTests.swift @@ -0,0 +1,408 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPEdge +import AEPEdgeIdentity +@testable import AEPEdgeMedia +import AEPServices +import Foundation +import XCTest + +class EdgeMediaIntegrationTests: FunctionalTestBase { + private let sessionStartEdgeEndpoint = "https://edge.adobedc.net/ee/va/v1/sessionStart" + private let configuration = ["edge.configId": "12345-example", + "edgemedia.channel": "testChannel", + "edgemedia.playerName": "testPlayerName" + ] + + let mediaInfo = Media.createMediaObjectWith(name: "testName", id: "testId", length: 30.0, streamType: "VOD", mediaType: MediaType.Video)! + let adBreakInfo = Media.createAdBreakObjectWith(name: "testName", position: 1, startTime: 1)! + let adInfo = Media.createAdObjectWith(name: "testName", id: "testId", position: 1, length: 15)! + let chapterInfo = Media.createChapterObjectWith(name: "testName", position: 1, length: 30, startTime: 2)! + let qoeInfo = Media.createQoEObjectWith(bitrate: 1, startupTime: 2, fps: 3, droppedFrames: 4)! + let muteStateInfo = Media.createStateObjectWith(stateName: MediaConstants.PlayerState.MUTE)! + let customStateInfo = Media.createStateObjectWith(stateName: "testStateName")! + + let metadata = ["testKey": "testValue"] + + let testBackendSessionId = "99cf4e3e7145d8e2b8f4f1e9e1a08cd52518a74091c0b0c611ca97b259e03a4d" + let successResponseBody = "\u{0000}{\"handle\":[{\"payload\":[{\"sessionId\":\"99cf4e3e7145d8e2b8f4f1e9e1a08cd52518a74091c0b0c611ca97b259e03a4d\"}],\"type\":\"media-analytics:new-session\",\"eventIndex\":0}]}" + + let errorResponseBody = "{\"errors\" : [{\"type\" : \"https://ns.adobe.com/aep/errors/va-edge-0404-404\", \"status\" : 404,\"title\" : \"Not Found\", \"detail\" : \"The requested resource could not be found but may be available again in the future.\",\"report\" : {\"details\" : \"Error processing request. If the session is longer than 24h, please start a new one. Returning Not Found\"}}]}" + + public class override func setUp() { + super.setUp() + FunctionalTestBase.debugEnabled = true + } + + override func setUp() { + super.setUp() + continueAfterFailure = false + + // hub shared state update for 1 extension versions Edge, Identity, Configuration, EventHub shared state updates + setExpectationEvent(type: EventType.hub, source: EventSource.sharedState, expectedCount: 4) + + // expectations for update config request&response events + setExpectationEvent(type: EventType.configuration, source: EventSource.requestContent, expectedCount: 1) + setExpectationEvent(type: EventType.configuration, source: EventSource.responseContent, expectedCount: 1) + + // wait for async registration because the EventHub is already started in FunctionalTestBase + let waitForRegistration = CountDownLatch(1) + MobileCore.registerExtensions([Identity.self, Edge.self, Media.self], { + print("Extensions registration is complete") + waitForRegistration.countDown() + }) + XCTAssertEqual(DispatchTimeoutResult.success, waitForRegistration.await(timeout: 2)) + + MobileCore.updateConfigurationWith(configDict: configuration) + + assertExpectedEvents(ignoreUnexpectedEvents: false) + resetTestExpectations() + } + + // Test Cases + func testPlayback_singleSession_play_pause_complete() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.trackPlay() + tracker.updateCurrentPlayhead(time: 7) + tracker.trackPause() + tracker.trackComplete() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(4, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[2], eventType: "pauseStart", backendSessionId: testBackendSessionId, playhead: 7) + assertXDMData(networkRequest: networkRequests[3], eventType: "sessionComplete", backendSessionId: testBackendSessionId, playhead: 7) + } + + func testSessionStartErrorResponse_shouldNotSendAnyOtherNetworkRequests() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: errorResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.trackPlay() + tracker.updateCurrentPlayhead(time: 7) + tracker.trackPause() + tracker.trackComplete() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(1, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + } + + func testPlayback_withPrerollAdBreak() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo, metadata: nil) + tracker.updateQoEObject(qoe: qoeInfo) + tracker.trackEvent(event: MediaEvent.AdStart, info: adInfo, metadata: metadata) + tracker.trackPlay() + tracker.trackEvent(event: MediaEvent.AdComplete, info: nil, metadata: nil) + tracker.trackEvent(event: MediaEvent.AdBreakComplete, info: nil, metadata: nil) + tracker.trackComplete() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(8, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "adBreakStart", info: adBreakInfo, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[2], eventType: "adStart", info: adInfo, metadata: metadata, configuration: configuration, backendSessionId: testBackendSessionId, qoeInfo: qoeInfo) + assertXDMData(networkRequest: networkRequests[3], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[4], eventType: "adComplete", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[5], eventType: "adBreakComplete", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[6], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[7], eventType: "sessionComplete", backendSessionId: testBackendSessionId) + } + + func testPlayback_withSingleChapter() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo, metadata: metadata) + tracker.trackPlay() + tracker.trackEvent(event: MediaEvent.ChapterComplete, info: nil, metadata: nil) + tracker.trackComplete() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(5, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "chapterStart", info: chapterInfo, metadata: metadata, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[2], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[3], eventType: "chapterComplete", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[4], eventType: "sessionComplete", backendSessionId: testBackendSessionId) + } + + func testPlayback_withBuffer_withSeek_withBitrate_withQoeUpdate_withError() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.trackPlay() + tracker.updateCurrentPlayhead(time: 5) + tracker.trackEvent(event: MediaEvent.BufferStart, info: nil, metadata: nil) + tracker.trackEvent(event: MediaEvent.BufferComplete, info: nil, metadata: nil) + tracker.updateQoEObject(qoe: qoeInfo) + tracker.updateCurrentPlayhead(time: 10) + tracker.trackEvent(event: MediaEvent.BitrateChange, info: nil, metadata: nil) + tracker.updateCurrentPlayhead(time: 15) + tracker.trackEvent(event: MediaEvent.SeekStart, info: nil, metadata: nil) + tracker.trackEvent(event: MediaEvent.SeekComplete, info: nil, metadata: nil) + tracker.trackError(errorId: "testError") + tracker.updateCurrentPlayhead(time: 20) + tracker.trackComplete() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(9, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "play", backendSessionId: testBackendSessionId, playhead: 0) + assertXDMData(networkRequest: networkRequests[2], eventType: "bufferStart", backendSessionId: testBackendSessionId, playhead: 5) + assertXDMData(networkRequest: networkRequests[3], eventType: "play", backendSessionId: testBackendSessionId, playhead: 5) + assertXDMData(networkRequest: networkRequests[4], eventType: "bitrateChange", info: qoeInfo, backendSessionId: testBackendSessionId, playhead: 10) + assertXDMData(networkRequest: networkRequests[5], eventType: "pauseStart", backendSessionId: testBackendSessionId, playhead: 15) + assertXDMData(networkRequest: networkRequests[6], eventType: "play", backendSessionId: testBackendSessionId, playhead: 15) + assertXDMData(networkRequest: networkRequests[7], eventType: "error", info: ["error.id": "testError", "error.source": "player"], backendSessionId: testBackendSessionId, playhead: 15) + assertXDMData(networkRequest: networkRequests[8], eventType: "sessionComplete", backendSessionId: testBackendSessionId, playhead: 20) + } + + func testPlayback_withPrerollAdBreak_noAdComplete_noAdbreakComplete_withSessionEnd() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.updateQoEObject(qoe: qoeInfo) + tracker.trackEvent(event: MediaEvent.AdBreakStart, info: adBreakInfo, metadata: nil) + tracker.trackEvent(event: MediaEvent.AdStart, info: adInfo, metadata: metadata) + tracker.trackPlay() + tracker.trackSessionEnd() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(7, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "adBreakStart", info: adBreakInfo, backendSessionId: testBackendSessionId, qoeInfo: qoeInfo) + assertXDMData(networkRequest: networkRequests[2], eventType: "adStart", info: adInfo, metadata: metadata, configuration: configuration, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[3], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[4], eventType: "adSkip", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[5], eventType: "adBreakComplete", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[6], eventType: "sessionEnd", backendSessionId: testBackendSessionId) + } + + func testPlayback_withChapterStart_noChapterComplete_withSessionEnd() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo, metadata: metadata) + tracker.trackPlay() + tracker.updateCurrentPlayhead(time: 12) + tracker.trackSessionEnd() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(5, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "chapterStart", info: chapterInfo, metadata: metadata, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[2], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[3], eventType: "chapterSkip", backendSessionId: testBackendSessionId, playhead: 12) + assertXDMData(networkRequest: networkRequests[4], eventType: "sessionEnd", backendSessionId: testBackendSessionId, playhead: 12) + } + + func testPlayback_withSingleChapter_withMuteState_withCustomState() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo, metadata: metadata) + tracker.trackPlay() + tracker.trackEvent(event: MediaEvent.StateStart, info: muteStateInfo, metadata: nil) + tracker.trackEvent(event: MediaEvent.StateStart, info: customStateInfo, metadata: nil) + tracker.updateCurrentPlayhead(time: 12) + tracker.trackEvent(event: MediaEvent.StateEnd, info: customStateInfo, metadata: nil) + tracker.trackEvent(event: MediaEvent.StateEnd, info: muteStateInfo, metadata: nil) + tracker.trackEvent(event: MediaEvent.ChapterComplete, info: nil, metadata: nil) + tracker.trackComplete() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(9, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "chapterStart", info: chapterInfo, metadata: metadata, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[2], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[3], eventType: "statesUpdate", info: muteStateInfo, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[4], eventType: "statesUpdate", info: customStateInfo, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[5], eventType: "statesUpdate", info: customStateInfo, backendSessionId: testBackendSessionId, playhead: 12, stateStart: false) + assertXDMData(networkRequest: networkRequests[6], eventType: "statesUpdate", info: muteStateInfo, backendSessionId: testBackendSessionId, playhead: 12, stateStart: false) + assertXDMData(networkRequest: networkRequests[7], eventType: "chapterComplete", backendSessionId: testBackendSessionId, playhead: 12) + assertXDMData(networkRequest: networkRequests[8], eventType: "sessionComplete", backendSessionId: testBackendSessionId, playhead: 12) + } + + func testPlayback_withChapterStart_noChapterComplete_withMuteStateStart_withCustomStateStart_noMuteStateEnd_noCustomStateEnd_withSessionEnd() { + // setup + let responseConnection: HttpConnection = HttpConnection(data: successResponseBody.data(using: .utf8), + response: HTTPURLResponse(url: URL(string: sessionStartEdgeEndpoint)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil) + setNetworkResponseFor(url: sessionStartEdgeEndpoint, httpMethod: .post, responseHttpConnection: responseConnection) + + // test + let tracker = Media.createTracker() + tracker.trackSessionStart(info: mediaInfo, metadata: metadata) + tracker.updateQoEObject(qoe: qoeInfo) + tracker.trackEvent(event: MediaEvent.ChapterStart, info: chapterInfo, metadata: metadata) + tracker.trackPlay() + tracker.trackEvent(event: MediaEvent.StateStart, info: muteStateInfo, metadata: nil) + tracker.trackEvent(event: MediaEvent.StateStart, info: customStateInfo, metadata: nil) + tracker.updateCurrentPlayhead(time: 12) + tracker.trackSessionEnd() + + // verify + let networkRequests = getAllNetworkRequests() + XCTAssertEqual(7, networkRequests.count) + + assertXDMData(networkRequest: networkRequests[0], eventType: "sessionStart", info: mediaInfo, metadata: metadata, configuration: configuration) + assertXDMData(networkRequest: networkRequests[1], eventType: "chapterStart", info: chapterInfo, metadata: metadata, backendSessionId: testBackendSessionId, qoeInfo: qoeInfo) + assertXDMData(networkRequest: networkRequests[2], eventType: "play", backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[3], eventType: "statesUpdate", info: muteStateInfo, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[4], eventType: "statesUpdate", info: customStateInfo, backendSessionId: testBackendSessionId) + assertXDMData(networkRequest: networkRequests[5], eventType: "chapterSkip", backendSessionId: testBackendSessionId, playhead: 12) + assertXDMData(networkRequest: networkRequests[6], eventType: "sessionEnd", backendSessionId: testBackendSessionId, playhead: 12) + } + + // Test Assert Utils + + func assertXDMData(networkRequest: NetworkRequest, eventType: String, info: [String: Any] = [:], metadata: [String: String] = [:], configuration: [String: Any] = [:], backendSessionId: String? = nil, qoeInfo: [String: Any]? = nil, playhead: Int64? = nil, stateStart: Bool = true) { + let expectedMediaCollectionData = EdgeEventHelper.generateMediaCollection(eventType: XDMMediaEventType(rawValue: eventType) ?? XDMMediaEventType.sessionEnd, + playhead: playhead ?? 0, + backendSessionId: testBackendSessionId, + info: info, + metadata: metadata, + mediaState: getMediaStateFrom(configuration), + qoeInfo: qoeInfo, + stateStart: stateStart) + + let actualXDMData = getXDMDataFromNetworkRequest(networkRequest) + + XCTAssertEqual("media." + eventType, actualXDMData["eventType"] as? String) + XCTAssertNotNil(actualXDMData["timestamp"] as? String) + XCTAssertNotNil(actualXDMData["_id"] as? String) + + let actualMediaCollectionData = actualXDMData["mediaCollection"] as? [String: Any] ?? [:] + + XCTAssertTrue( NSDictionary(dictionary: expectedMediaCollectionData).isEqual(to: actualMediaCollectionData), "For media event (\(String(describing: actualXDMData["eventType"]))) expected mediaCollection data \n(\(expectedMediaCollectionData)\n) does not match the actual mediaCollection data \n(\(actualMediaCollectionData))\n") + } + + // Test Helpers + + func getXDMDataFromNetworkRequest(_ networkRequest: NetworkRequest, eventNumber: Int = 0) -> [String: Any] { + let data = getNetworkRequestBodyAsDictionary(networkRequest) + + guard let eventDataList = data["events"] as? [[String: Any]] else { + return [:] + } + + let eventData = eventDataList[0] + + return eventData["xdm"] as? [String: Any] ?? [:] + } + + func getMediaStateFrom(_ config: [String: Any]) -> MediaState { + let mediaState = MediaState() + mediaState.updateConfigurationSharedState(config) + return mediaState + } +} diff --git a/Tests/IntegrationTests/Utils/CountDownLatch.swift b/Tests/IntegrationTests/Utils/CountDownLatch.swift new file mode 100644 index 0000000..50e2c42 --- /dev/null +++ b/Tests/IntegrationTests/Utils/CountDownLatch.swift @@ -0,0 +1,57 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation +import XCTest + +/// CountDown latch to be used for asserts and expectations +class CountDownLatch { + private let initialCount: Int32 + private var currentCount: Int32 + private let waitSemaphore = DispatchSemaphore(value: 0) + + init(_ expectedCount: Int32) { + guard expectedCount > 0 else { + assertionFailure("CountDownLatch requires a count greater than 0") + self.currentCount = 0 + self.initialCount = 0 + return + } + + self.currentCount = expectedCount + self.initialCount = expectedCount + } + + func getCurrentCount() -> Int32 { + return currentCount + } + + func getInitialCount() -> Int32 { + return initialCount + } + + func await(timeout: TimeInterval = 1) -> DispatchTimeoutResult { + return currentCount > 0 ? waitSemaphore.wait(timeout: (DispatchTime.now() + timeout)) : DispatchTimeoutResult.success + } + + func countDown() { + OSAtomicDecrement32(¤tCount) + if currentCount == 0 { + waitSemaphore.signal() + } + + if currentCount < 0 { + print("Count Down decreased more times than expected.") + } + + } +} diff --git a/Tests/IntegrationTests/Utils/EventHub+Test.swift b/Tests/IntegrationTests/Utils/EventHub+Test.swift new file mode 100644 index 0000000..65e7950 --- /dev/null +++ b/Tests/IntegrationTests/Utils/EventHub+Test.swift @@ -0,0 +1,20 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +import Foundation + +extension EventHub { + static func reset() { + shared = EventHub() + } +} diff --git a/Tests/IntegrationTests/Utils/FileManager+Testable.swift b/Tests/IntegrationTests/Utils/FileManager+Testable.swift new file mode 100644 index 0000000..28bd3a2 --- /dev/null +++ b/Tests/IntegrationTests/Utils/FileManager+Testable.swift @@ -0,0 +1,35 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPServices +import Foundation + +extension FileManager { + + func clearCache() { + let knownCacheItems: [String] = ["com.adobe.edge", "com.adobe.edge.identity", "com.adobe.edge.consent"] + guard let url = self.urls(for: .cachesDirectory, in: .userDomainMask).first else { + return + } + + for cacheItem in knownCacheItems { + do { + try self.removeItem(at: URL(fileURLWithPath: "\(url.relativePath)/\(cacheItem)")) + if let dqService = ServiceProvider.shared.dataQueueService as? DataQueueService { + _ = dqService.threadSafeDictionary.removeValue(forKey: cacheItem) + } + } catch { + print("ERROR DESCRIPTION: \(error)") + } + } + } +} diff --git a/Tests/IntegrationTests/Utils/FunctionalTestBase.swift b/Tests/IntegrationTests/Utils/FunctionalTestBase.swift new file mode 100644 index 0000000..ddbaa47 --- /dev/null +++ b/Tests/IntegrationTests/Utils/FunctionalTestBase.swift @@ -0,0 +1,361 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdge +@testable import AEPServices +import Foundation +import XCTest + +/// Struct defining the event specifications - contains the event type and source +struct EventSpec { + let type: String + let source: String +} + +/// Hashable `EventSpec`, to be used as key in Dictionaries +extension EventSpec: Hashable & Equatable { + + static func == (lhs: EventSpec, rhs: EventSpec) -> Bool { + return lhs.source.lowercased() == rhs.source.lowercased() && lhs.type.lowercased() == rhs.type.lowercased() + } + + func hash(into hasher: inout Hasher) { + hasher.combine(type) + hasher.combine(source) + } +} + +class FunctionalTestBase: XCTestCase { + /// Use this property to execute code logic in the first run in this test class; this value changes to False after the parent tearDown is executed + private(set) static var isFirstRun: Bool = true + private static var networkService: FunctionalTestNetworkService = FunctionalTestNetworkService() + /// Use this setting to enable debug mode logging in the `FunctionalTestBase` + static var debugEnabled = false + + public class override func setUp() { + super.setUp() + UserDefaults.clearAll() + FileManager.default.clearCache() + MobileCore.setLogLevel(LogLevel.trace) + networkService = FunctionalTestNetworkService() + ServiceProvider.shared.networkService = networkService + } + + public override func setUp() { + super.setUp() + continueAfterFailure = false + MobileCore.registerExtension(InstrumentedExtension.self) + } + + public override func tearDown() { + super.tearDown() + + // to revisit when AMSDK-10169 is available + // wait .2 seconds in case there are unexpected events that were in the dispatch process during cleanup + usleep(200000) + resetTestExpectations() + FunctionalTestBase.isFirstRun = false + EventHub.reset() + UserDefaults.clearAll() + FileManager.default.clearCache() + } + + /// Reset event and network request expectations and drop the items received until this point + func resetTestExpectations() { + log("Resetting functional test expectations for events and network requests") + InstrumentedExtension.reset() + FunctionalTestBase.networkService.reset() + } + + /// Unregisters the `InstrumentedExtension` from the Event Hub. This method executes asynchronous. + func unregisterInstrumentedExtension() { + let event = Event(name: "Unregister Instrumented Extension", + type: FunctionalTestConstant.EventType.INSTRUMENTED_EXTENSION, + source: FunctionalTestConstant.EventSource.UNREGISTER_EXTENSION, + data: nil) + + MobileCore.dispatch(event: event) + } + + // MARK: Expected/Unexpected events assertions + + /// Sets an expectation for a specific event type and source and how many times the event should be dispatched + /// - Parameters: + /// - type: the event type as a `String`, should not be empty + /// - source: the event source as a `String`, should not be empty + /// - count: the number of times this event should be dispatched, but default it is set to 1 + /// - See also: + /// - assertExpectedEvents(ignoreUnexpectedEvents:) + func setExpectationEvent(type: String, source: String, expectedCount: Int32 = 1) { + guard expectedCount > 0 else { + assertionFailure("Expected event count should be greater than 0") + return + } + guard !type.isEmpty, !source.isEmpty else { + assertionFailure("Expected event type and source should be non-empty trings") + return + } + + InstrumentedExtension.expectedEvents[EventSpec(type: type, source: source)] = CountDownLatch(expectedCount) + } + + /// Asserts if all the expected events were received and fails if an unexpected event was seen + /// - Parameters: + /// - ignoreUnexpectedEvents: if set on false, an assertion is made on unexpected events, otherwise the unexpected events are ignored + /// - See also: + /// - setExpectationEvent(type: source: count:) + /// - assertUnexpectedEvents() + func assertExpectedEvents(ignoreUnexpectedEvents: Bool = false, file: StaticString = #file, line: UInt = #line) { + guard InstrumentedExtension.expectedEvents.count > 0 else { // swiftlint:disable:this empty_count + assertionFailure("There are no event expectations set, use this API after calling setExpectationEvent", file: file, line: line) + return + } + + let currentExpectedEvents = InstrumentedExtension.expectedEvents.shallowCopy + for expectedEvent in currentExpectedEvents { + let waitResult = expectedEvent.value.await(timeout: FunctionalTestConstant.Defaults.WAIT_EVENT_TIMEOUT) + let expectedCount: Int32 = expectedEvent.value.getInitialCount() + let receivedCount: Int32 = expectedEvent.value.getInitialCount() - expectedEvent.value.getCurrentCount() + XCTAssertFalse(waitResult == DispatchTimeoutResult.timedOut, "Timed out waiting for event type \(expectedEvent.key.type) and source \(expectedEvent.key.source), expected \(expectedCount), but received \(receivedCount)", file: (file), line: line) + XCTAssertEqual(expectedCount, receivedCount, "Expected \(expectedCount) event(s) of type \(expectedEvent.key.type) and source \(expectedEvent.key.source), but received \(receivedCount)", file: (file), line: line) + } + + guard ignoreUnexpectedEvents == false else { return } + assertUnexpectedEvents(file: file, line: line) + } + + /// Asserts if any unexpected event was received. Use this method to verify the received events are correct when setting event expectations. + /// - See also: setExpectationEvent(type: source: count:) + func assertUnexpectedEvents(file: StaticString = #file, line: UInt = #line) { + wait() + var unexpectedEventsReceivedCount = 0 + var unexpectedEventsAsString = "" + + let currentReceivedEvents = InstrumentedExtension.receivedEvents.shallowCopy + for receivedEvent in currentReceivedEvents { + + // check if event is expected and it is over the expected count + if let expectedEvent = InstrumentedExtension.expectedEvents[EventSpec(type: receivedEvent.key.type, source: receivedEvent.key.source)] { + _ = expectedEvent.await(timeout: FunctionalTestConstant.Defaults.WAIT_EVENT_TIMEOUT) + let expectedCount: Int32 = expectedEvent.getInitialCount() + let receivedCount: Int32 = expectedEvent.getInitialCount() - expectedEvent.getCurrentCount() + XCTAssertEqual(expectedCount, receivedCount, "Expected \(expectedCount) events of type \(receivedEvent.key.type) and source \(receivedEvent.key.source), but received \(receivedCount)", file: (file), line: line) + } + // check for events that don't have expectations set + else { + unexpectedEventsReceivedCount += receivedEvent.value.count + unexpectedEventsAsString.append("(\(receivedEvent.key.type), \(receivedEvent.key.source), \(receivedEvent.value.count)),") + log("Received unexpected event with type: \(receivedEvent.key.type) source: \(receivedEvent.key.source)") + } + } + + XCTAssertEqual(0, unexpectedEventsReceivedCount, "Received \(unexpectedEventsReceivedCount) unexpected event(s): \(unexpectedEventsAsString)", file: (file), line: line) + } + + /// - Parameters: + /// - timeout:how long should this method wait, in seconds. + func wait(_ timeout: UInt32? = FunctionalTestConstant.Defaults.WAIT_TIMEOUT) { + if let timeout = timeout { + sleep(timeout) + } + } + + /// Returns the `ACPExtensionEvent`(s) dispatched through the Event Hub, or empty if none was found. + /// Use this API after calling `setExpectationEvent(type:source:count:)` to wait for the right amount of time + /// - Parameters: + /// - type: the event type as in the expectation + /// - source: the event source as in the expectation + /// - timeout: how long should this method wait for the expected event, in seconds; by default it waits up to 1 second + /// - Returns: list of events with the provided `type` and `source`, or empty if none was dispatched + func getDispatchedEventsWith(type: String, source: String, timeout: TimeInterval = FunctionalTestConstant.Defaults.WAIT_EVENT_TIMEOUT, file: StaticString = #file, line: UInt = #line) -> [Event] { + if InstrumentedExtension.expectedEvents[EventSpec(type: type, source: source)] != nil { + let waitResult = InstrumentedExtension.expectedEvents[EventSpec(type: type, source: source)]?.await(timeout: timeout) + XCTAssertFalse(waitResult == DispatchTimeoutResult.timedOut, "Timed out waiting for event type \(type) and source \(source)", file: file, line: line) + } else { + wait(FunctionalTestConstant.Defaults.WAIT_TIMEOUT) + } + return InstrumentedExtension.receivedEvents[EventSpec(type: type, source: source)] ?? [] + } + + /// Synchronous call to get the shared state for the specified `stateOwner`. This API throws an assertion failure in case of timeout. + /// - Parameter ownerExtension: the owner extension of the shared state (typically the name of the extension) + /// - Parameter timeout: how long should this method wait for the requested shared state, in seconds; by default it waits up to 3 second + /// - Returns: latest shared state of the given `stateOwner` or nil if no shared state was found + func getSharedStateFor(_ ownerExtension: String, timeout: TimeInterval = FunctionalTestConstant.Defaults.WAIT_SHARED_STATE_TIMEOUT) -> [AnyHashable: Any]? { + log("GetSharedState for \(ownerExtension)") + let event = Event(name: "Get Shared State", + type: FunctionalTestConstant.EventType.INSTRUMENTED_EXTENSION, + source: FunctionalTestConstant.EventSource.SHARED_STATE_REQUEST, + data: ["stateowner": ownerExtension]) + + var returnedState: [AnyHashable: Any]? + + let expectation = XCTestExpectation(description: "Shared state data returned") + MobileCore.dispatch(event: event, responseCallback: { event in + + if let eventData = event?.data { + returnedState = eventData["state"] as? [AnyHashable: Any] + } + expectation.fulfill() + }) + + wait(for: [expectation], timeout: timeout) + return returnedState + } + + // MARK: Network Service helpers + + /// Set a custom network response to a network request + /// - Parameters: + /// - url: The URL for which to return the response + /// - httpMethod: The `HttpMethod` for which to return the response, along with the `url` + /// - responseHttpConnection: `HttpConnection` to be returned when a `NetworkRequest` with the specified `url` and `httpMethod` is seen; when nil is provided the default + /// `HttpConnection` is returned + func setNetworkResponseFor(url: String, httpMethod: HttpMethod, responseHttpConnection: HttpConnection?) { + guard let requestUrl = URL(string: url) else { + assertionFailure("Unable to convert the provided string \(url) to URL") + return + } + + _ = FunctionalTestBase.networkService.setResponseConnectionFor(networkRequest: NetworkRequest(url: requestUrl, httpMethod: httpMethod), responseConnection: responseHttpConnection) + } + + /// Set a network request expectation. + /// + /// - Parameters: + /// - url: The URL for which to set the expectation + /// - httpMethod: the `HttpMethod` for which to set the expectation, along with the `url` + /// - count: how many times a request with this url and httpMethod is expected to be sent, by default it is set to 1 + /// - See also: + /// - assertNetworkRequestsCount() + /// - getNetworkRequestsWith(url:httpMethod:) + func setExpectationNetworkRequest(url: String, httpMethod: HttpMethod, expectedCount: Int32 = 1, file: StaticString = #file, line: UInt = #line) { + guard expectedCount > 0 else { + assertionFailure("Expected event count should be greater than 0") + return + } + + guard let requestUrl = URL(string: url) else { + assertionFailure("Unable to convert the provided string \(url) to URL") + return + } + + FunctionalTestBase.networkService.setExpectedNetworkRequest(networkRequest: NetworkRequest(url: requestUrl, httpMethod: httpMethod), count: expectedCount) + } + + /// Asserts that the correct number of network requests were being sent, based on the previously set expectations. + /// - See also: + /// - setExpectationNetworkRequest(url:httpMethod:) + func assertNetworkRequestsCount(file: StaticString = #file, line: UInt = #line) { + let expectedNetworkRequests = FunctionalTestBase.networkService.getExpectedNetworkRequests() + guard !expectedNetworkRequests.isEmpty else { + assertionFailure("There are no network request expectations set, use this API after calling setExpectationNetworkRequest") + return + } + + for expectedRequest in expectedNetworkRequests { + let waitResult = expectedRequest.value.await(timeout: 10) + let expectedCount: Int32 = expectedRequest.value.getInitialCount() + let receivedCount: Int32 = expectedRequest.value.getInitialCount() - expectedRequest.value.getCurrentCount() + XCTAssertFalse(waitResult == DispatchTimeoutResult.timedOut, "Timed out waiting for network request(s) with URL \(expectedRequest.key.url.absoluteString) and HTTPMethod \(expectedRequest.key.httpMethod.toString()), expected \(expectedCount) but received \(receivedCount)", file: file, line: line) + XCTAssertEqual(expectedCount, receivedCount, "Expected \(expectedCount) network request(s) for URL \(expectedRequest.key.url.absoluteString) and HTTPMethod \(expectedRequest.key.httpMethod.toString()), but received \(receivedCount)", file: file, line: line) + } + } + + /// Returns the `NetworkRequest`(s) sent through the Core NetworkService, or empty if none was found. + /// Use this API after calling `setExpectationNetworkRequest(url:httpMethod:count:)` to wait for the right amount of time + /// - Parameters: + /// - url: The URL for which to retrieved the network requests sent, should be a valid URL + /// - httpMethod: the `HttpMethod` for which to retrieve the network requests, along with the `url` + /// - timeout: how long should this method wait for the expected network requests, in seconds; by default it waits up to 1 second + /// - Returns: list of network requests with the provided `url` and `httpMethod`, or empty if none was dispatched + /// - See also: + /// - setExpectationNetworkRequest(url:httpMethod:) + func getNetworkRequestsWith(url: String, httpMethod: HttpMethod, timeout: TimeInterval = FunctionalTestConstant.Defaults.WAIT_NETWORK_REQUEST_TIMEOUT, file: StaticString = #file, line: UInt = #line) -> [NetworkRequest] { + guard let requestUrl = URL(string: url) else { + assertionFailure("Unable to convert the provided string \(url) to URL") + return [] + } + + let networkRequest = NetworkRequest(url: requestUrl, httpMethod: httpMethod) + + if let waitResult = FunctionalTestBase.networkService.awaitFor(networkRequest: networkRequest, timeout: timeout) { + XCTAssertFalse(waitResult == DispatchTimeoutResult.timedOut, "Timed out waiting for network request(s) with URL \(url) and HTTPMethod \(httpMethod.toString())", file: file, line: line) + } else { + wait(FunctionalTestConstant.Defaults.WAIT_TIMEOUT) + } + + return FunctionalTestBase.networkService.getReceivedNetworkRequestsMatching(networkRequest: networkRequest) + } + + /// Returns all the `NetworkRequest`(s) sent through the Core NetworkService, or empty if none was found. + /// - Returns: list of all the dispatched network requests or empty if none was dispatched + func getAllNetworkRequests() -> [NetworkRequest] { + // Wait for network requests to be dispatched + wait(FunctionalTestConstant.Defaults.WAIT_TIMEOUT) + + return FunctionalTestBase.networkService.getAllReceivedNetworkRequests() + } + + /// Use this API for JSON formatted `NetworkRequest` body in order to retrieve a flattened dictionary containing its data. + /// This API fails the assertion if the request body cannot be parsed as JSON. + /// - Parameters: + /// - networkRequest: the NetworkRequest to parse + /// - Returns: The JSON request body represented as a flatten dictionary + func getFlattenNetworkRequestBody(_ networkRequest: NetworkRequest, file: StaticString = #file, line: UInt = #line) -> [String: Any] { + + if !networkRequest.connectPayload.isEmpty { + if let payloadAsDictionary = try? JSONSerialization.jsonObject(with: networkRequest.connectPayload, options: []) as? [String: Any] { + return flattenDictionary(dict: payloadAsDictionary) + } else { + XCTFail("Failed to parse networkRequest.connectionPayload to JSON", file: file, line: line) + } + } + + log("Connection payload is empty for network request with URL \(networkRequest.url.absoluteString), HTTPMethod \(networkRequest.httpMethod.toString())") + return [:] + } + + func getNetworkRequestBodyAsDictionary(_ networkRequest: NetworkRequest) -> [String: Any] { + if !networkRequest.connectPayload.isEmpty { + if let payloadAsDictionary = try? JSONSerialization.jsonObject(with: networkRequest.connectPayload, options: []) as? [String: Any] { + return payloadAsDictionary + } else { + XCTFail("Failed to parse networkRequest.connectionPayload to JSON") + } + } + + log("Connection payload is empty for network request with URL \(networkRequest.url.absoluteString), HTTPMethod \(networkRequest.httpMethod.toString())") + return [:] + } + + /// Sets the provided delay for all network responses, until reset + /// - Parameter delaySec: delay in seconds + func enableNetworkResponseDelay(delaySec: UInt32) { + FunctionalTestBase.networkService.enableDelayedResponse(delaySec: delaySec) + } + + /// Print message to console if `FunctionalTestBase.debug` is true + /// - Parameter message: message to log to console + func log(_ message: String) { + FunctionalTestBase.log(message) + + } + + /// Print message to console if `FunctionalTestBase.debug` is true + /// - Parameter message: message to log to console + static func log(_ message: String) { + guard !message.isEmpty && FunctionalTestBase.debugEnabled else { return } + print("FunctionalTestBase - \(message)") + } +} diff --git a/Tests/IntegrationTests/Utils/FunctionalTestConstant.swift b/Tests/IntegrationTests/Utils/FunctionalTestConstant.swift new file mode 100644 index 0000000..a7125bf --- /dev/null +++ b/Tests/IntegrationTests/Utils/FunctionalTestConstant.swift @@ -0,0 +1,50 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +enum FunctionalTestConstant { + + enum EventType { + static let EDGE = "com.adobe.eventType.edge" + static let INSTRUMENTED_EXTENSION = "com.adobe.eventType.instrumentedExtension" + static let HUB = "com.adobe.eventType.hub" + static let CONFIGURATION = "com.adobe.eventType.configuration" + static let IDENTITY = "com.adobe.eventType.identity" + static let CONSENT = "com.adobe.eventType.edgeConsent" + } + + enum EventSource { + static let REQUEST_CONTENT = "com.adobe.eventSource.requestContent" + static let RESPONSE_CONTENT = "com.adobe.eventSource.responseContent" + static let ERROR_RESPONSE_CONTENT = "com.adobe.eventSource.errorResponseContent" + static let SHARED_STATE_REQUEST = "com.adobe.eventSource.requestState" + static let SHARED_STATE_RESPONSE = "com.adobe.eventSource.responseState" + static let UNREGISTER_EXTENSION = "com.adobe.eventSource.unregisterExtension" + static let SHARED_STATE = "com.adobe.eventSource.sharedState" + static let RESPONSE_IDENTITY = "com.adobe.eventSource.responseIdentity" + static let REQUEST_IDENTITY = "com.adobe.eventSource.requestIdentity" + static let BOOTED = "com.adobe.eventSource.booted" + } + + enum EventDataKey { + static let STATE_OWNER = "stateowner" + static let STATE = "state" + } + + enum Defaults { + static let WAIT_EVENT_TIMEOUT: TimeInterval = 2 + static let WAIT_SHARED_STATE_TIMEOUT: TimeInterval = 3 + static let WAIT_NETWORK_REQUEST_TIMEOUT: TimeInterval = 2 + static let WAIT_TIMEOUT: UInt32 = 3 // used when no expectation is set + } +} diff --git a/Tests/IntegrationTests/Utils/FunctionalTestNetworkService.swift b/Tests/IntegrationTests/Utils/FunctionalTestNetworkService.swift new file mode 100644 index 0000000..a950bf4 --- /dev/null +++ b/Tests/IntegrationTests/Utils/FunctionalTestNetworkService.swift @@ -0,0 +1,144 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPServices +import Foundation + +/// Overriding NetworkService used for functional tests when extending the FunctionalTestBase +class FunctionalTestNetworkService: NetworkService { + private var allReceivedNetworkRequests: [NetworkRequest] = [] + private var receivedNetworkRequests: [NetworkRequest: [NetworkRequest]] = [NetworkRequest: [NetworkRequest]]() + private var responseMatchers: [NetworkRequest: HttpConnection] = [NetworkRequest: HttpConnection]() + private var expectedNetworkRequests: [NetworkRequest: CountDownLatch] = [NetworkRequest: CountDownLatch]() + private var delayedResponse: UInt32 = 0 + + override func connectAsync(networkRequest: NetworkRequest, completionHandler: ((HttpConnection) -> Void)? = nil) { + FunctionalTestBase.log("Received connectAsync to URL \(networkRequest.url.absoluteString) and HTTPMethod \(networkRequest.httpMethod.toString())") + if var requests = receivedNetworkRequests[networkRequest] { + requests.append(networkRequest) + } else { + receivedNetworkRequests[networkRequest] = [networkRequest] + } + // Add to the single list of all network requests + allReceivedNetworkRequests.append(networkRequest) + + countDownExpected(networkRequest: networkRequest) + guard let unwrappedCompletionHandler = completionHandler else { return } + + if delayedResponse > 0 { + sleep(delayedResponse) + } + + if let response = getMarchedResponseForUrlAndHttpMethod(networkRequest: networkRequest) { + unwrappedCompletionHandler(response) + } else { + // default response + unwrappedCompletionHandler(HttpConnection(data: "".data(using: .utf8), + response: HTTPURLResponse(url: networkRequest.url, + statusCode: 200, + httpVersion: nil, + headerFields: nil), + error: nil)) + } + } + + func enableDelayedResponse(delaySec: UInt32) { + delayedResponse = delaySec + } + + func reset() { + allReceivedNetworkRequests.removeAll() + expectedNetworkRequests.removeAll() + receivedNetworkRequests.removeAll() + responseMatchers.removeAll() + delayedResponse = 0 + } + + func awaitFor(networkRequest: NetworkRequest, timeout: TimeInterval) -> DispatchTimeoutResult? { + for expectedNetworkRequest in expectedNetworkRequests { + if areNetworkRequestsEqual(lhs: expectedNetworkRequest.key, rhs: networkRequest) { + return expectedNetworkRequest.value.await(timeout: timeout) + } + } + + return nil + } + + func getAllReceivedNetworkRequests() -> [NetworkRequest] { + return allReceivedNetworkRequests + } + + func getReceivedNetworkRequestsMatching(networkRequest: NetworkRequest) -> [NetworkRequest] { + var matchingRequests: [NetworkRequest] = [] + for receivedRequest in receivedNetworkRequests { + if areNetworkRequestsEqual(lhs: receivedRequest.key, rhs: networkRequest) { + matchingRequests.append(receivedRequest.key) + } + } + + return matchingRequests + } + + func setExpectedNetworkRequest(networkRequest: NetworkRequest, count: Int32) { + expectedNetworkRequests[networkRequest] = CountDownLatch(count) + } + + func getExpectedNetworkRequests() -> [NetworkRequest: CountDownLatch] { + return expectedNetworkRequests + } + + func setResponseConnectionFor(networkRequest: NetworkRequest, responseConnection: HttpConnection?) -> Bool { + for responseMatcher in responseMatchers { + if areNetworkRequestsEqual(lhs: responseMatcher.key, rhs: networkRequest) { + // unable to override response matcher + return false + } + } + + // add new entry if not present already + responseMatchers[networkRequest] = responseConnection + return true + } + + private func countDownExpected(networkRequest: NetworkRequest) { + for expectedNetworkRequest in expectedNetworkRequests { + if areNetworkRequestsEqual(lhs: expectedNetworkRequest.key, rhs: networkRequest) { + expectedNetworkRequest.value.countDown() + } + } + } + + private func getMarchedResponseForUrlAndHttpMethod(networkRequest: NetworkRequest) -> HttpConnection? { + for responseMatcher in responseMatchers { + if areNetworkRequestsEqual(lhs: responseMatcher.key, rhs: networkRequest) { + return responseMatcher.value + } + } + + return nil + } + + /// Equals compare based on host, scheme and URL path. Query params are not taken into consideration + private func areNetworkRequestsEqual(lhs: NetworkRequest, rhs: NetworkRequest) -> Bool { + return lhs.url.host?.lowercased() == rhs.url.host?.lowercased() + && lhs.url.scheme?.lowercased() == rhs.url.scheme?.lowercased() + && lhs.url.path.lowercased() == rhs.url.path.lowercased() + && lhs.httpMethod.rawValue == rhs.httpMethod.rawValue + } +} + +extension URL { + func queryParam(_ param: String) -> String? { + guard let url = URLComponents(string: self.absoluteString) else { return nil } + return url.queryItems?.first(where: { $0.name == param })?.value + } +} diff --git a/Tests/IntegrationTests/Utils/InstrumentedExtension.swift b/Tests/IntegrationTests/Utils/InstrumentedExtension.swift new file mode 100644 index 0000000..13b8b07 --- /dev/null +++ b/Tests/IntegrationTests/Utils/InstrumentedExtension.swift @@ -0,0 +1,111 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +import AEPServices +import XCTest + +/// Instrumented extension that registers a wildcard listener for intercepting events in current session. Use it along with `FunctionalTestBase` +class InstrumentedExtension: NSObject, Extension { + private static let logTag = "InstrumentedExtension" + var name = "com.adobe.InstrumentedExtension" + var friendlyName = "InstrumentedExtension" + static var extensionVersion = "1.0.0" + var metadata: [String: String]? + var runtime: ExtensionRuntime + + // Expected events Dictionary - key: EventSpec, value: the expected count + static var expectedEvents = ThreadSafeDictionary() + + // All the events seen by this listener that are not of type instrumentedExtension - key: EventSpec, value: received events with EventSpec type and source + static var receivedEvents = ThreadSafeDictionary() + + func onRegistered() { + runtime.registerListener(type: EventType.wildcard, source: EventSource.wildcard, listener: wildcardListenerProcessor) + } + + func onUnregistered() {} + + public func readyForEvent(_ event: Event) -> Bool { + return true + } + + required init?(runtime: ExtensionRuntime) { + self.runtime = runtime + } + + // MARK: Event Processors + func wildcardListenerProcessor(_ event: Event) { + if event.type.lowercased() == FunctionalTestConstant.EventType.INSTRUMENTED_EXTENSION.lowercased() { + // process the shared state request event + if event.source.lowercased() == FunctionalTestConstant.EventSource.SHARED_STATE_REQUEST.lowercased() { + processSharedStateRequest(event) + } + // process the unregister extension event + else if event.source.lowercased() == FunctionalTestConstant.EventSource.UNREGISTER_EXTENSION.lowercased() { + unregisterExtension() + } + + return + } + + // save this event in the receivedEvents dictionary + if InstrumentedExtension.receivedEvents[EventSpec(type: event.type, source: event.source)] != nil { + InstrumentedExtension.receivedEvents[EventSpec(type: event.type, source: event.source)]?.append(event) + } else { + InstrumentedExtension.receivedEvents[EventSpec(type: event.type, source: event.source)] = [event] + } + + // count down if this is an expected event + if InstrumentedExtension.expectedEvents[EventSpec(type: event.type, source: event.source)] != nil { + InstrumentedExtension.expectedEvents[EventSpec(type: event.type, source: event.source)]?.countDown() + } + + if event.source == EventSource.sharedState { + Log.debug(label: InstrumentedExtension.logTag, "Received event with type \(event.type) and source \(event.source), state owner \(event.data?["stateowner"] ?? "unknown")") + } else { + Log.debug(label: InstrumentedExtension.logTag, "Received event with type \(event.type) and source \(event.source)") + } + } + + /// Process `getSharedStateFor` requests + /// - Parameter event: event sent from `getSharedStateFor` which specifies the shared state `stateowner` to retrieve + func processSharedStateRequest(_ event: Event) { + guard let eventData = event.data, !eventData.isEmpty else { return } + guard let owner = eventData[FunctionalTestConstant.EventDataKey.STATE_OWNER] as? String else { return } + + var responseData: [String: Any?] = [FunctionalTestConstant.EventDataKey.STATE_OWNER: owner, FunctionalTestConstant.EventDataKey.STATE: nil] + if let state = runtime.getSharedState(extensionName: owner, event: event, barrier: false) { + responseData[FunctionalTestConstant.EventDataKey.STATE] = state + } + + let responseEvent = event.createResponseEvent(name: "Get Shared State Response", + type: FunctionalTestConstant.EventType.INSTRUMENTED_EXTENSION, + source: FunctionalTestConstant.EventSource.SHARED_STATE_RESPONSE, + data: responseData as [String: Any]) + + Log.debug(label: InstrumentedExtension.logTag, "ProcessSharedStateRequest Responding with shared state \(String(describing: responseData))") + + // dispatch paired response event with shared state data + MobileCore.dispatch(event: responseEvent) + } + + func unregisterExtension() { + Log.debug(label: InstrumentedExtension.logTag, "Unregistering the Instrumented extension from the Event Hub") + runtime.unregisterExtension() + } + + static func reset() { + receivedEvents = ThreadSafeDictionary() + expectedEvents = ThreadSafeDictionary() + } +} diff --git a/Tests/IntegrationTests/Utils/UserDefaults+Test.swift b/Tests/IntegrationTests/Utils/UserDefaults+Test.swift new file mode 100644 index 0000000..c97eebc --- /dev/null +++ b/Tests/IntegrationTests/Utils/UserDefaults+Test.swift @@ -0,0 +1,25 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +extension UserDefaults { + + /// Util function to clean up all the keys from UserDefaults between tests + public static func clearAll() { + for _ in 0 ... 5 { + for key in UserDefaults.standard.dictionaryRepresentation().keys { + UserDefaults.standard.removeObject(forKey: key) + } + } + } +} diff --git a/Tests/TestHelpers/EdgeEventHelper.swift b/Tests/TestHelpers/EdgeEventHelper.swift new file mode 100644 index 0000000..ae50f24 --- /dev/null +++ b/Tests/TestHelpers/EdgeEventHelper.swift @@ -0,0 +1,321 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import AEPServices +import Foundation + +class EdgeEventHelper { + private static let standardMediaMetadataSet: Set = [ + MediaConstants.VideoMetadataKeys.AD_LOAD, + MediaConstants.VideoMetadataKeys.ASSET_ID, + MediaConstants.VideoMetadataKeys.AUTHORIZED, + MediaConstants.VideoMetadataKeys.DAY_PART, + MediaConstants.VideoMetadataKeys.EPISODE, + MediaConstants.VideoMetadataKeys.FEED, + MediaConstants.VideoMetadataKeys.FIRST_AIR_DATE, + MediaConstants.VideoMetadataKeys.FIRST_DIGITAL_DATE, + MediaConstants.VideoMetadataKeys.GENRE, + MediaConstants.VideoMetadataKeys.MVPD, + MediaConstants.VideoMetadataKeys.NETWORK, + MediaConstants.VideoMetadataKeys.ORIGINATOR, + MediaConstants.VideoMetadataKeys.RATING, + MediaConstants.VideoMetadataKeys.SEASON, + MediaConstants.VideoMetadataKeys.SHOW, + MediaConstants.VideoMetadataKeys.SHOW_TYPE, + MediaConstants.VideoMetadataKeys.STREAM_FORMAT, + MediaConstants.AudioMetadataKeys.ALBUM, + MediaConstants.AudioMetadataKeys.ARTIST, + MediaConstants.AudioMetadataKeys.AUTHOR, + MediaConstants.AudioMetadataKeys.LABEL, + MediaConstants.AudioMetadataKeys.PUBLISHER, + MediaConstants.AudioMetadataKeys.STATION + ] + + private static let standardAdMetadataSet: Set = [ + MediaConstants.AdMetadataKeys.ADVERTISER, + MediaConstants.AdMetadataKeys.CAMPAIGN_ID, + MediaConstants.AdMetadataKeys.CREATIVE_ID, + MediaConstants.AdMetadataKeys.CREATIVE_URL, + MediaConstants.AdMetadataKeys.PLACEMENT_ID, + MediaConstants.AdMetadataKeys.SITE_ID + ] + + static func getCustomMetadata(eventType: XDMMediaEventType, metadata: [String: String]) -> [[String: Any]]? { + var metadataList: [XDMCustomMetadata] = [] + for (k, v) in metadata { + if (eventType == XDMMediaEventType.sessionStart && standardMediaMetadataSet.contains(k)) || (eventType == XDMMediaEventType.adStart && standardAdMetadataSet.contains(k)) { + continue + } + + metadataList.append(XDMCustomMetadata(name: k, value: v)) + } + + // sort the list using name values of the custom Metadata + metadataList.sort { $0.name < $1.name } + + // convert the XDMCustomMetadata to a dictionary [String: Any] + var metadataDictList = [[String: Any]]() + for m in metadataList { + if let metadataEntryAsDictionary = m.asDictionary() { + metadataDictList.append(metadataEntryAsDictionary) + } + } + + return metadataDictList + } + + static func getSessionDetailsDictionary(mediaInfo: [String: Any], metadata: [String: String], mediaState: MediaState) -> [String: Any] { + var sessionDetails: [String: Any] = [:] + sessionDetails["name"] = mediaInfo["media.id"] + sessionDetails["friendlyName"] = mediaInfo["media.name"] + sessionDetails["length"] = Int64(mediaInfo["media.length"] as? Double ?? -1) + sessionDetails["streamType"] = mediaInfo["media.type"] + sessionDetails["contentType"] = mediaInfo["media.streamtype"] + sessionDetails["hasResume"] = mediaInfo["media.resumed"] + + for (key, value) in metadata { + if !standardMediaMetadataSet.contains(key) { + continue + } + + switch key { + // Video standard metadata cases + case MediaConstants.VideoMetadataKeys.AD_LOAD: + sessionDetails["adLoad"] = value + case MediaConstants.VideoMetadataKeys.ASSET_ID: + sessionDetails["assetID"] = value + case MediaConstants.VideoMetadataKeys.AUTHORIZED: + sessionDetails["authorized"] = value + case MediaConstants.VideoMetadataKeys.DAY_PART: + sessionDetails["dayPart"] = value + case MediaConstants.VideoMetadataKeys.EPISODE: + sessionDetails["episode"] = value + case MediaConstants.VideoMetadataKeys.FEED: + sessionDetails["feed"] = value + case MediaConstants.VideoMetadataKeys.FIRST_AIR_DATE: + sessionDetails["firstAirDate"] = value + case MediaConstants.VideoMetadataKeys.FIRST_DIGITAL_DATE: + sessionDetails["firstDigitalDate"] = value + case MediaConstants.VideoMetadataKeys.GENRE: + sessionDetails["genre"] = value + case MediaConstants.VideoMetadataKeys.MVPD: + sessionDetails["mvpd"] = value + case MediaConstants.VideoMetadataKeys.NETWORK: + sessionDetails["network"] = value + case MediaConstants.VideoMetadataKeys.ORIGINATOR: + sessionDetails["originator"] = value + case MediaConstants.VideoMetadataKeys.RATING: + sessionDetails["rating"] = value + case MediaConstants.VideoMetadataKeys.SEASON: + sessionDetails["season"] = value + case MediaConstants.VideoMetadataKeys.SHOW: + sessionDetails["show"] = value + case MediaConstants.VideoMetadataKeys.SHOW_TYPE: + sessionDetails["showType"] = value + case MediaConstants.VideoMetadataKeys.STREAM_FORMAT: + sessionDetails["streamFormat"] = value + + // Audio standard metadata cases + case MediaConstants.AudioMetadataKeys.ALBUM: + sessionDetails["album"] = value + case MediaConstants.AudioMetadataKeys.ARTIST: + sessionDetails["artist"] = value + case MediaConstants.AudioMetadataKeys.AUTHOR: + sessionDetails["author"] = value + case MediaConstants.AudioMetadataKeys.LABEL: + sessionDetails["label"] = value + case MediaConstants.AudioMetadataKeys.PUBLISHER: + sessionDetails["publisher"] = value + case MediaConstants.AudioMetadataKeys.STATION: + sessionDetails["station"] = value + default: + break + } + } + + if let channel = mediaState.channel, !channel.isEmpty { + sessionDetails["channel"] = mediaState.channel + } + + if let playerName = mediaState.playerName, !playerName.isEmpty { + sessionDetails["playerName"] = mediaState.playerName + } + + if let appVersion = mediaState.appVersion, !appVersion.isEmpty { + sessionDetails["appVersion"] = mediaState.appVersion + } + + return sessionDetails + } + + static func getAdvertisingDetailsDictionary(adInfo: [String: Any], metadata: [String: String], mediaState: MediaState) -> [String: Any] { + var advertisingDetails: [String: Any] = [:] + advertisingDetails["name"] = adInfo["ad.id"] + advertisingDetails["friendlyName"] = adInfo["ad.name"] + advertisingDetails["length"] = Int64(adInfo["ad.length"] as? Double ?? -1) + advertisingDetails["podPosition"] = Int64(adInfo["ad.position"] as? Int ?? -1) + + for (key, value) in metadata { + if !standardAdMetadataSet.contains(key) { + continue + } + + switch key { + // Video standard metadata cases + case MediaConstants.AdMetadataKeys.ADVERTISER: + advertisingDetails["advertiser"] = value + case MediaConstants.AdMetadataKeys.CAMPAIGN_ID: + advertisingDetails["campaignID"] = value + case MediaConstants.AdMetadataKeys.CREATIVE_ID: + advertisingDetails["creativeID"] = value + case MediaConstants.AdMetadataKeys.CREATIVE_URL: + advertisingDetails["creativeURL"] = value + case MediaConstants.AdMetadataKeys.PLACEMENT_ID: + advertisingDetails["placementID"] = value + case MediaConstants.AdMetadataKeys.SITE_ID: + advertisingDetails["siteID"] = value + default: + break + } + } + + if let playerName = mediaState.playerName, !playerName.isEmpty { + advertisingDetails["playerName"] = mediaState.playerName + } + + return advertisingDetails + } + + static func getAdvertisingPodDetailsDictionary(adBreakInfo: [String: Any]) -> [String: Any] { + var advertisingPodDetails: [String: Any] = [:] + advertisingPodDetails["friendlyName"] = adBreakInfo["adbreak.name"] + advertisingPodDetails["index"] = Int64(adBreakInfo["adbreak.position"] as? Int ?? -1) + advertisingPodDetails["offset"] = Int64(adBreakInfo["adbreak.starttime"] as? Double ?? -1) + + return advertisingPodDetails + } + + static func getChapterDetailsDictionary(chapterInfo: [String: Any], metadata: [String: String]) -> [String: Any] { + var chapterDetails: [String: Any] = [:] + chapterDetails["friendlyName"] = chapterInfo["chapter.name"] + chapterDetails["index"] = Int64(chapterInfo["chapter.position"] as? Int ?? -1) + chapterDetails["offset"] = Int64(chapterInfo["chapter.starttime"] as? Double ?? -1) + chapterDetails["length"] = Int64(chapterInfo["chapter.length"] as? Double ?? -1) + + return chapterDetails + } + + static func getErrorDetailsDictionary(errorInfo: [String: Any]) -> [String: Any] { + var errorDetails: [String: Any] = [:] + errorDetails["name"] = errorInfo["error.id"] + errorDetails["source"] = errorInfo["error.source"] + + return errorDetails + } + + static func getStatesUpdateList(stateInfo: [String: Any]) -> [[String: Any]] { + var statesUpdateList: [[String: Any]] = [] + var stateDetails: [String: Any] = [:] + stateDetails["name"] = stateInfo["state.name"] + + statesUpdateList.append(stateDetails) + return statesUpdateList + } + + static func getQoEDetailsDictionary(qoeInfo: [String: Any]) -> [String: Any] { + var qoeDetails: [String: Any] = [:] + qoeDetails["bitrate"] = Int64(qoeInfo["qoe.bitrate"] as? Double ?? -1) + qoeDetails["droppedFrames"] = Int64(qoeInfo["qoe.droppedframes"] as? Double ?? -1) + qoeDetails["framesPerSecond"] = Int64(qoeInfo["qoe.fps"] as? Double ?? -1) + qoeDetails["timeToStart"] = Int64(qoeInfo["qoe.startuptime"] as? Double ?? -1) + + return qoeDetails + } + + static func generateMediaCollection(eventType: XDMMediaEventType, playhead: Int64, backendSessionId: String?, info: [String: Any], metadata: [String: String]?, mediaState: MediaState?, qoeInfo: [String: Any]? = nil, stateStart: Bool = true) -> [String: Any] { + var mediaCollection: [String: Any] = [:] + + mediaCollection["playhead"] = playhead + + if eventType != XDMMediaEventType.sessionStart, backendSessionId != nil { + mediaCollection["sessionID"] = backendSessionId + } + + if let customMetadata = metadata, !customMetadata.isEmpty { + mediaCollection["customMetadata"] = getCustomMetadata(eventType: eventType, metadata: customMetadata) + } + + if eventType == XDMMediaEventType.sessionStart { + mediaCollection["sessionDetails"] = getSessionDetailsDictionary(mediaInfo: info, metadata: metadata ?? [:], mediaState: mediaState ?? MediaState()) + } else if eventType == XDMMediaEventType.adStart { + mediaCollection["advertisingDetails"] = getAdvertisingDetailsDictionary(adInfo: info, metadata: metadata ?? [:], mediaState: mediaState ?? MediaState()) + } else if eventType == XDMMediaEventType.adBreakStart { + mediaCollection["advertisingPodDetails"] = getAdvertisingPodDetailsDictionary(adBreakInfo: info) + } else if eventType == XDMMediaEventType.chapterStart { + mediaCollection["chapterDetails"] = getChapterDetailsDictionary(chapterInfo: info, metadata: metadata ?? [:]) + } else if eventType == XDMMediaEventType.error { + mediaCollection["errorDetails"] = getErrorDetailsDictionary(errorInfo: info) + } else if eventType == XDMMediaEventType.statesUpdate { + if stateStart { + mediaCollection["statesStart"] = getStatesUpdateList(stateInfo: info) + } else { + mediaCollection["statesEnd"] = getStatesUpdateList(stateInfo: info) + } + } else if eventType == XDMMediaEventType.bitrateChange { + mediaCollection["qoeDataDetails"] = getQoEDetailsDictionary(qoeInfo: info) + } + + if qoeInfo != nil { + // qoe details are attached to any subsequent request after updateQoEObject API is called + mediaCollection["qoeDataDetails"] = getQoEDetailsDictionary(qoeInfo: qoeInfo ?? [:]) + } + + return mediaCollection + } + + static func generateEdgeEvent(eventType: XDMMediaEventType, playhead: Int64, ts: TimeInterval, backendSessionId: String?, info: [String: Any]? = nil, metadata: [String: String]? = nil, mediaState: MediaState? = nil, stateStart: Bool = true) -> Event { + let eventOverwritePath = "/va/v1/" + eventType.rawValue + + var data: [String: Any] = [:] + var xdmData: [String: Any] = [:] + + xdmData["eventType"] = eventType.edgeEventType() + xdmData["timestamp"] = Date(timeIntervalSince1970: ts).getISO8601UTCDateWithMilliseconds() + + let mediaCollection = generateMediaCollection(eventType: eventType, playhead: playhead, backendSessionId: backendSessionId, info: info ?? [:], metadata: metadata, mediaState: mediaState, stateStart: stateStart) + + xdmData["mediaCollection"] = mediaCollection + + data["xdm"] = xdmData + data["request"] = ["path": eventOverwritePath] + + let mediaEdgeEvent = Event(name: "MediaEdge event - \(eventType.edgeEventType())", + type: "com.adobe.eventType.edge", + source: "com.adobe.eventSource.requestContent", + data: data) + return mediaEdgeEvent + } + + static func generateSessionCreatedEvent(trackerSessionId: String, backendSessionId: String) -> Event { + var eventData: [String: Any] = [:] + eventData[MediaConstants.Tracker.BACKEND_SESSION_ID] = backendSessionId + eventData[MediaConstants.Tracker.SESSION_ID] = trackerSessionId + + let sessionCreatedEvent = Event(name: "Media::SessionCreated", + type: "com.adobe.eventtype.edgemedia", + source: "com.adobe.eventsource.edgemedia.sessioncreated", + data: eventData) + return sessionCreatedEvent + } +} diff --git a/Tests/TestHelpers/MediaEventGenerator.swift b/Tests/TestHelpers/MediaEventGenerator.swift new file mode 100644 index 0000000..aad9250 --- /dev/null +++ b/Tests/TestHelpers/MediaEventGenerator.swift @@ -0,0 +1,140 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaEventGenerator: MediaTracker { + + class MediaPublicTrackerMock: MediaPublicTracker { + var mockTimeStamp: Int64 = 0 + var mockCurrentPlayhead: Double = 0 + + override init(dispatch: DispatchFn?, config: [String: Any]?) { + super.init(dispatch: dispatch, config: config) + } + + override func getCurrentTimeStamp() -> Int64 { + return mockTimeStamp + } + + override func updateCurrentPlayhead(time: Double) { + mockCurrentPlayhead = time + super.updateCurrentPlayhead(time: time) + } + } + + let tracker: MediaPublicTrackerMock + let semaphore = DispatchSemaphore(value: 0) + var dispatchedEvent: Event? + var usingProvidedDispatchFn = false + var previousPlayhead: Double = 0 + var coreEventTracker: MediaEventTracking? + + init(config: [String: Any]? = nil, dispatch: ((Event) -> Void)? = nil) { + // if the passed in dispatch function is nil then create one + guard let dispatch = dispatch else { + tracker = MediaPublicTrackerMock(dispatch: nil, config: config) + tracker.dispatch = { (event: Event) in + self.dispatchedEvent = event + self.semaphore.signal() + } + return + } + // otherwise use the passed in dispatch function + usingProvidedDispatchFn = true + tracker = MediaPublicTrackerMock(dispatch: dispatch, config: config) + tracker.dispatch = dispatch + } + + func trackSessionStart(info: [String: Any], metadata: [String: String]? = nil) { + tracker.trackSessionStart(info: info, metadata: metadata) + waitForTrackerRequest() + } + + func trackPlay() { + tracker.trackPlay() + waitForTrackerRequest() + } + + func trackPause() { + tracker.trackPause() + waitForTrackerRequest() + } + + func trackComplete() { + tracker.trackComplete() + waitForTrackerRequest() + } + func trackSessionEnd() { + tracker.trackSessionEnd() + waitForTrackerRequest() + } + + func trackError(errorId: String) { + tracker.trackError(errorId: errorId) + waitForTrackerRequest() + } + + func trackEvent(event: MediaEvent, info: [String: Any]? = nil, metadata: [String: String]? = nil) { + tracker.trackEvent(event: event, info: info, metadata: metadata) + waitForTrackerRequest() + } + + func updateCurrentPlayhead(time: Double) { + tracker.updateCurrentPlayhead(time: time) + waitForTrackerRequest() + self.previousPlayhead = time + } + + func updateQoEObject(qoe: [String: Any]) { + tracker.updateQoEObject(qoe: qoe) + waitForTrackerRequest() + } + + // Methods for testing + func connectCoreTracker(tracker: MediaEventTracking?) { + coreEventTracker = tracker + } + + func getTimeStamp() -> Int64 { + return tracker.mockTimeStamp + } + + func setTimeStamp(value: Int64) { + tracker.mockTimeStamp = value + } + + func incrementTimeStamp(value: Int64) { + tracker.mockTimeStamp += value + } + + func getCurrentPlayhead() -> Double { + return tracker.mockCurrentPlayhead + } + + func incrementCurrentPlayhead(time: Double) { + tracker.mockCurrentPlayhead += time + updateCurrentPlayhead(time: tracker.mockCurrentPlayhead) + } + + private func waitForTrackerRequest() { + if !usingProvidedDispatchFn { + semaphore.wait() + if let dispatchedEvent = dispatchedEvent { + coreEventTracker?.track(event: dispatchedEvent) + } + + } + } +} diff --git a/Tests/TestHelpers/TestUtils.swift b/Tests/TestHelpers/TestUtils.swift new file mode 100644 index 0000000..db46186 --- /dev/null +++ b/Tests/TestHelpers/TestUtils.swift @@ -0,0 +1,72 @@ +// +// Copyright 2022 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// Flatten a multi-level dictionary to a single level where each key is a dotted notation of each nested key. +/// - Parameter dict: the dictionary to flatten +func flattenDictionary(dict: [String: Any]) -> [String: Any] { + var result: [String: Any] = [:] + + func recursive(dict: [String: Any], out: inout [String: Any], currentKey: String = "") { + if dict.isEmpty { + if currentKey.isEmpty { + out = [:] + } else { + out[currentKey] = "isEmpty"} + return + } + for (key, val) in dict { + let resultKey = !currentKey.isEmpty ? currentKey + "." + key : key + process(value: val, out: &out, key: resultKey) + } + } + + func recursive(list: [Any], out: inout [String: Any], currentKey: String) { + if list.isEmpty { + out[currentKey] = "isEmpty" + return + } + for (index, value) in list.enumerated() { + let resultKey = currentKey + "[\(index)]" + process(value: value, out: &out, key: resultKey) + } + } + + func process(value: Any, out: inout [String: Any], key: String) { + if let value = value as? [String: Any] { + recursive(dict: value, out: &out, currentKey: key) + } else if let value = value as? [Any] { + recursive(list: value, out: &out, currentKey: key) + } else { + out[key] = value + } + } + + recursive(dict: dict, out: &result) + return result +} + +/// Attempts to convert provided data to [String: Any] using JSONSerialization. +/// - Parameter data: data to be converted to [String: Any] +/// - Returns: `data` as [String: Any] or empty if an error occured +func asFlattenDictionary(data: Data?) -> [String: Any] { + guard let unwrappedData = data else { + return [:] + } + guard let dataAsDictionary = try? JSONSerialization.jsonObject(with: unwrappedData, options: []) as? [String: Any] else { + print("asFlattenDictionary - Unable to convert to [String: Any], data: \(String(data: unwrappedData, encoding: .utf8) ?? "")") + return [:] + } + + return flattenDictionary(dict: dataAsDictionary) +} diff --git a/Tests/TestHelpers/XDMData+Comparable.swift b/Tests/TestHelpers/XDMData+Comparable.swift new file mode 100644 index 0000000..a8b7ded --- /dev/null +++ b/Tests/TestHelpers/XDMData+Comparable.swift @@ -0,0 +1,20 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia + +extension XDMCustomMetadata: Comparable { + public static func < (lhs: XDMCustomMetadata, rhs: XDMCustomMetadata) -> Bool { + return (lhs.name) < (rhs.name) + } + +} diff --git a/Tests/TestHelpers/XDMData+Equatable.swift b/Tests/TestHelpers/XDMData+Equatable.swift new file mode 100644 index 0000000..010e603 --- /dev/null +++ b/Tests/TestHelpers/XDMData+Equatable.swift @@ -0,0 +1,132 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia + +extension XDMMediaCollection: Equatable { + public static func == (lhs: XDMMediaCollection, rhs: XDMMediaCollection) -> Bool { + return lhs.advertisingDetails == rhs.advertisingDetails && + lhs.advertisingPodDetails == rhs.advertisingPodDetails && + lhs.chapterDetails == rhs.chapterDetails && + lhs.customMetadata == rhs.customMetadata && + lhs.errorDetails == rhs.errorDetails && + lhs.playhead == rhs.playhead && + lhs.sessionDetails == rhs.sessionDetails && + lhs.sessionID == rhs.sessionID && + lhs.statesStart == rhs.statesStart && + rhs.statesEnd == rhs.statesEnd + } + +} + +extension XDMAdvertisingDetails: Equatable { + public static func == (lhs: XDMAdvertisingDetails, rhs: XDMAdvertisingDetails) -> Bool { + return lhs.friendlyName == rhs.friendlyName && + lhs.length == rhs.length && + lhs.name == rhs.name && + lhs.podPosition == rhs.podPosition && + lhs.playerName == rhs.playerName && + lhs.advertiser == rhs.advertiser && + lhs.campaignID == rhs.campaignID && + lhs.creativeID == rhs.creativeID && + lhs.creativeURL == rhs.creativeURL && + lhs.placementID == rhs.placementID && + lhs.siteID == rhs.siteID + } +} + +extension XDMAdvertisingPodDetails: Equatable { + public static func == (lhs: XDMAdvertisingPodDetails, rhs: XDMAdvertisingPodDetails) -> Bool { + return lhs.friendlyName == rhs.friendlyName && + lhs.index == rhs.index && + lhs.offset == rhs.offset + } +} + +extension XDMChapterDetails: Equatable { + public static func == (lhs: XDMChapterDetails, rhs: XDMChapterDetails) -> Bool { + return lhs.friendlyName == rhs.friendlyName && + lhs.index == rhs.index && + lhs.length == rhs.length && + lhs.offset == rhs.offset + } +} + +extension XDMCustomMetadata: Equatable { + public static func == (lhs: XDMCustomMetadata, rhs: XDMCustomMetadata) -> Bool { + return lhs.name == rhs.name && + lhs.value == rhs.value + } +} + +extension XDMErrorDetails: Equatable { + public static func == (lhs: XDMErrorDetails, rhs: XDMErrorDetails) -> Bool { + return lhs.name == rhs.name && + lhs.source == rhs.source + } +} + +extension XDMPlayerStateData: Equatable { + public static func == (lhs: XDMPlayerStateData, rhs: XDMPlayerStateData) -> Bool { + return lhs.name == rhs.name + } +} + +extension XDMSessionDetails: Equatable { + public static func == (lhs: XDMSessionDetails, rhs: XDMSessionDetails) -> Bool { + return lhs.contentType == rhs.contentType && + lhs.friendlyName == rhs.friendlyName && + lhs.hasResume == rhs.hasResume && + lhs.length == rhs.length && + lhs.name == rhs.name && + lhs.streamType == rhs.streamType && + + lhs.channel == rhs.channel && + lhs.playerName == rhs.playerName && + lhs.appVersion == rhs.appVersion && + + lhs.album == rhs.album && + lhs.artist == rhs.artist && + lhs.author == rhs.author && + lhs.label == rhs.label && + lhs.publisher == rhs.publisher && + lhs.station == rhs.station && + + lhs.adLoad == rhs.adLoad && + lhs.authorized == rhs.authorized && + lhs.assetID == rhs.assetID && + lhs.dayPart == rhs.dayPart && + lhs.episode == rhs.episode && + lhs.feed == rhs.feed && + lhs.firstAirDate == rhs.firstAirDate && + lhs.firstDigitalDate == rhs.firstDigitalDate && + lhs.genre == rhs.genre && + lhs.mvpd == rhs.mvpd && + lhs.network == rhs.network && + lhs.originator == rhs.originator && + lhs.rating == rhs.rating && + lhs.season == rhs.season && + lhs.segment == rhs.segment && + lhs.show == rhs.show && + lhs.showType == rhs.showType && + lhs.streamType == rhs.streamType && + lhs.streamFormat == rhs.streamFormat + } +} + +extension MediaXDMEvent: Equatable { + public static func == (lhs: MediaXDMEvent, rhs: MediaXDMEvent) -> Bool { + return lhs.eventType == rhs.eventType && + lhs.mediaCollection == rhs.mediaCollection && + lhs.timestamp == rhs.timestamp + } +} diff --git a/Tests/TestHelpers/XDMDataHelper.swift b/Tests/TestHelpers/XDMDataHelper.swift new file mode 100644 index 0000000..505557f --- /dev/null +++ b/Tests/TestHelpers/XDMDataHelper.swift @@ -0,0 +1,50 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ +@testable import AEPEdgeMedia +import Foundation + +class XDMDataHelper { + static func getSessionStartData() -> XDMMediaCollection { + var mediaCollectionDetails = XDMMediaCollection() + mediaCollectionDetails.sessionDetails = getSessionDetails() + + return mediaCollectionDetails + } + + static func getSessionDetails() -> XDMSessionDetails { + var sessionDetails = XDMSessionDetails(name: "test_mediaId", friendlyName: "name", length: 30, streamType: XDMStreamType.video, contentType: "vod", hasResume: false) + sessionDetails.appVersion = "test_appVersion" + sessionDetails.channel = "test_channel" + sessionDetails.playerName = "test_playerName" + + // Video Standard Metadata + sessionDetails.assetID = "test_assetID" + sessionDetails.episode = "1" + sessionDetails.feed = "test_feed" + sessionDetails.firstAirDate = "test_firstAirDate" + sessionDetails.firstDigitalDate = "test_firstAirDigitalDate" + sessionDetails.genre = "test_genre" + sessionDetails.authorized = "false" + sessionDetails.mvpd = "test_mvpd" + sessionDetails.network = "test_network" + sessionDetails.originator = "test_originator" + sessionDetails.rating = "test_rating" + sessionDetails.season = "1" + sessionDetails.segment = "test_segment" + sessionDetails.show = "test_show" + sessionDetails.showType = "test_showType" + sessionDetails.streamFormat = "test_streamFormat" + + return sessionDetails + } + +} diff --git a/Tests/UnitTests/Media+PublicAPITests.swift b/Tests/UnitTests/Media+PublicAPITests.swift new file mode 100644 index 0000000..fcdd7b8 --- /dev/null +++ b/Tests/UnitTests/Media+PublicAPITests.swift @@ -0,0 +1,293 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +@testable import AEPServices +import XCTest + +class MediaPublicAPITests: XCTestCase { + + override func setUp() { + EventHub.reset() + MockExtension.reset() + EventHub.shared.start() + registerMockExtension(MockExtension.self) + } + + private func registerMockExtension (_ type: T.Type) { + let semaphore = DispatchSemaphore(value: 0) + EventHub.shared.registerExtension(type) { _ in + semaphore.signal() + } + + semaphore.wait() + } + + // MARK: MediaPublicAPI Unit Tests + + // ========================================================================== + // createTracker + // ========================================================================== + func testCreateTracker() { + let expectation = XCTestExpectation(description: "createTracker should dispatch createTracker request an event") + expectation.assertForOverFulfill = true + + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MediaConstants.Media.EVENT_TYPE, source: MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST) { event in + let eventData = event.data + let trackerId = eventData?[MediaConstants.Tracker.ID] as? String + let trackerConfig = eventData?[MediaConstants.Tracker.EVENT_PARAM] as? [String: Any] + + XCTAssertEqual(2, eventData?.count ?? 0) + XCTAssertFalse("" == trackerId) + XCTAssertEqual(0, trackerConfig?.count) + expectation.fulfill() + } + + let mediaTracker = Media.createTracker() + + // verify + wait(for: [expectation], timeout: 1) + XCTAssertNotNil(mediaTracker) + } + + func testCreateTrackerWithConfig() { + let expectation = XCTestExpectation(description: "createTracker should dispatch createTracker request an event") + expectation.assertForOverFulfill = true + + EventHub.shared.getExtensionContainer(MockExtension.self)?.registerListener(type: MediaConstants.Media.EVENT_TYPE, source: MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST) { event in + let eventData = event.data + let trackerId = eventData?[MediaConstants.Tracker.ID] as? String + let trackerConfig = eventData?[MediaConstants.Tracker.EVENT_PARAM] as? [String: Any] + + XCTAssertEqual(2, eventData?.count ?? 0) + XCTAssertFalse("" == trackerId) + XCTAssertTrue(trackerConfig?["downloaded"] as? Bool ?? false) + XCTAssertNotNil(trackerConfig) + expectation.fulfill() + } + + let mediaTracker = Media.createTrackerWith(config: ["downloaded": true]) + + // verify + wait(for: [expectation], timeout: 1) + XCTAssertNotNil(mediaTracker) + } + + // ========================================================================== + // createMediaObjects + // ========================================================================== + func testCreateMediaInfo() { + let infoMap = Media.createMediaObjectWith(name: "testName", id: "testId", length: 30, streamType: "aod", mediaType: MediaType.Audio) + XCTAssertFalse(infoMap?.isEmpty ?? true) + XCTAssertEqual("testId", infoMap?[MediaConstants.MediaInfo.ID] as? String ?? "") + XCTAssertEqual("testName", infoMap?[MediaConstants.MediaInfo.NAME] as? String ?? "") + XCTAssertEqual(30.0, infoMap?[MediaConstants.MediaInfo.LENGTH] as? Double ?? 0.0) + XCTAssertEqual("aod", infoMap?[MediaConstants.MediaInfo.STREAM_TYPE] as? String ?? "") + XCTAssertEqual(MediaType.Audio.rawValue, infoMap?[MediaConstants.MediaInfo.MEDIA_TYPE] as? String ?? "") + XCTAssertEqual(false, infoMap?[MediaConstants.MediaInfo.RESUMED] as? Bool ?? false) + XCTAssertEqual(250, infoMap?[MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME] as? Int ?? 0) + XCTAssertEqual(false, infoMap?[MediaConstants.MediaInfo.GRANULAR_AD_TRACKING] as? Bool ?? true) + } + + func testCreateMediaInfo_Invalid() { + // empty name + var infoMap = Media.createMediaObjectWith(name: "", id: "testId", length: 30, streamType: "aod", mediaType: MediaType.Audio) + XCTAssertNil(infoMap) + + // empty id + infoMap = Media.createMediaObjectWith(name: "testName", id: "", length: 30, streamType: "aod", mediaType: MediaType.Audio) + XCTAssertNil(infoMap) + + // <0 length + infoMap = Media.createMediaObjectWith(name: "testName", id: "testId", length: -1, streamType: "aod", mediaType: MediaType.Audio) + XCTAssertNil(infoMap) + + // empty streamType + infoMap = Media.createMediaObjectWith(name: "testName", id: "testId", length: 30, streamType: "", mediaType: MediaType.Audio) + XCTAssertNil(infoMap) + } + + // ========================================================================== + // createAdBreakObjects + // ========================================================================== + func testCreateAdBreakInfo() { + let infoMap = Media.createAdBreakObjectWith(name: "adBreakName", position: 5, startTime: 0) + XCTAssertFalse(infoMap?.isEmpty ?? true) + XCTAssertEqual("adBreakName", infoMap?[MediaConstants.AdBreakInfo.NAME] as? String ?? "") + XCTAssertEqual(5, infoMap?[MediaConstants.AdBreakInfo.POSITION] as? Int ?? 0) + XCTAssertEqual(0, infoMap?[MediaConstants.AdBreakInfo.START_TIME] as? Double ?? 0.0) + } + + func testCreateAdBreakInfo_Invalid() { + // empty name + var infoMap = Media.createAdBreakObjectWith(name: "", position: 5, startTime: 2.0) + XCTAssertNil(infoMap) + + // <1 position + infoMap = Media.createAdBreakObjectWith(name: "adBreakName", position: 0, startTime: 2.0) + XCTAssertNil(infoMap) + + // <0 start time + infoMap = Media.createAdBreakObjectWith(name: "adBreakName", position: 5, startTime: -1) + XCTAssertNil(infoMap) + } + + // ========================================================================== + // createAdObjects + // ========================================================================== + func testCreateAdInfo() { + let infoMap = Media.createAdObjectWith(name: "adName", id: "AdId", position: 3, length: 20) + XCTAssertFalse(infoMap?.isEmpty ?? true) + XCTAssertEqual("adName", infoMap?[MediaConstants.AdInfo.NAME] as? String ?? "") + XCTAssertEqual("AdId", infoMap?[MediaConstants.AdInfo.ID] as? String ?? "") + XCTAssertEqual(3, infoMap?[MediaConstants.AdInfo.POSITION] as? Int ?? 0) + XCTAssertEqual(20, infoMap?[MediaConstants.AdInfo.LENGTH] as? Double ?? 0.0) + } + + func testCreateAdInfo_Invalid() { + // empty name + var infoMap = Media.createAdObjectWith(name: "", id: "AdId", position: 2, length: 20) + XCTAssertNil(infoMap) + + // empty id name + infoMap = Media.createAdObjectWith(name: "adName", id: "", position: 2, length: 20) + XCTAssertNil(infoMap) + + // < 1 position + infoMap = Media.createAdObjectWith(name: "adName", id: "AdId", position: 0, length: 20) + XCTAssertNil(infoMap) + + // < 0 length + infoMap = Media.createAdObjectWith(name: "adName", id: "AdId", position: 2, length: -1) + XCTAssertNil(infoMap) + } + + // ========================================================================== + // createChapterObjects + // ========================================================================== + + func testCreateChapterInfo() { + let infoMap = Media.createChapterObjectWith(name: "chapterName", position: 2, length: 30, startTime: 5) + XCTAssertFalse(infoMap?.isEmpty ?? true) + XCTAssertEqual("chapterName", infoMap?[MediaConstants.ChapterInfo.NAME] as? String ?? "") + XCTAssertEqual(2, infoMap?[MediaConstants.ChapterInfo.POSITION] as? Int ?? 0) + XCTAssertEqual(30, infoMap?[MediaConstants.ChapterInfo.LENGTH] as? Double ?? 0.0) + XCTAssertEqual(5, infoMap?[MediaConstants.ChapterInfo.START_TIME] as? Double ?? 0.0) + } + + func testCreateChapterInfo_Invalid() { + // empty name + var infoMap = Media.createChapterObjectWith(name: "", position: 2, length: 30, startTime: 50) + XCTAssertNil(infoMap) + + // < 1 position + infoMap = Media.createChapterObjectWith(name: "chapterName", position: 0, length: 0, startTime: 5) + XCTAssertNil(infoMap) + + // < 0 length + infoMap = Media.createChapterObjectWith(name: "chapterName", position: 2, length: -1, startTime: 5 ) + XCTAssertNil(infoMap) + + // < 0 start time + infoMap = Media.createChapterObjectWith(name: "chapterName", position: 2, length: 30, startTime: -2) + XCTAssertNil(infoMap) + } + + // ========================================================================== + // createQoEObjects + // ========================================================================== + + func testCreateQoEInfo() { + let infoMap = Media.createQoEObjectWith(bitrate: 24.0, startupTime: 0.5, fps: 30.0, droppedFrames: 2.0) + XCTAssertFalse(infoMap?.isEmpty ?? true) + XCTAssertEqual(24, infoMap?[MediaConstants.QoEInfo.BITRATE] as? Double ?? 0.0) + XCTAssertEqual(0.5, infoMap?[MediaConstants.QoEInfo.STARTUP_TIME] as? Double ?? 0.0) + XCTAssertEqual(30, infoMap?[MediaConstants.QoEInfo.FPS] as? Double ?? 0.0) + XCTAssertEqual(2, infoMap?[MediaConstants.QoEInfo.DROPPED_FRAMES] as? Double ?? 0.0) + } + + func testCreateQoEInfo_Invalid() { + // < 0 bitrate + var infoMap = Media.createQoEObjectWith(bitrate: -1, startupTime: 0.5, fps: 30.0, droppedFrames: 2.0) + XCTAssertNil(infoMap) + + // < 0 startupTime + infoMap = Media.createQoEObjectWith(bitrate: -2.5, startupTime: 0.5, fps: 30.0, droppedFrames: 2.0) + XCTAssertNil(infoMap) + + // < 0 fps + infoMap = Media.createQoEObjectWith(bitrate: -15, startupTime: 0.5, fps: 30.0, droppedFrames: 2.0) + XCTAssertNil(infoMap) + + // < 0 dropped frame + infoMap = Media.createQoEObjectWith(bitrate: -4.9, startupTime: 0.5, fps: 30.0, droppedFrames: 2.0) + XCTAssertNil(infoMap) + } + + // ========================================================================== + // createStateObjects + // ========================================================================== + + func testCreateStateInfo() { + let infoMap = Media.createStateObjectWith(stateName: "muted") + XCTAssertFalse(infoMap?.isEmpty ?? true) + XCTAssertEqual("muted", infoMap?[MediaConstants.StateInfo.STATE_NAME_KEY] as? String ?? "") + } + + func testCreateStateInfo_Invalid() { + // empty state name + var infoMap = Media.createStateObjectWith(stateName: "") + XCTAssertNil(infoMap) + + // Invalid state name + infoMap = Media.createStateObjectWith(stateName: "mute$$") + XCTAssertNil(infoMap) + } + + func testMediaEventEnum_RawValue() { + XCTAssertEqual(MediaConstants.EventName.CHAPTER_START, MediaEvent.ChapterStart.rawValue) + XCTAssertEqual(MediaConstants.EventName.CHAPTER_SKIP, MediaEvent.ChapterSkip.rawValue) + XCTAssertEqual(MediaConstants.EventName.CHAPTER_COMPLETE, MediaEvent.ChapterComplete.rawValue) + XCTAssertEqual(MediaConstants.EventName.ADBREAK_START, MediaEvent.AdBreakStart.rawValue) + XCTAssertEqual(MediaConstants.EventName.ADBREAK_COMPLETE, MediaEvent.AdBreakComplete.rawValue) + XCTAssertEqual(MediaConstants.EventName.AD_START, MediaEvent.AdStart.rawValue) + XCTAssertEqual(MediaConstants.EventName.AD_COMPLETE, MediaEvent.AdComplete.rawValue) + XCTAssertEqual(MediaConstants.EventName.AD_SKIP, MediaEvent.AdSkip.rawValue) + XCTAssertEqual(MediaConstants.EventName.BUFFER_START, MediaEvent.BufferStart.rawValue) + XCTAssertEqual(MediaConstants.EventName.BUFFER_COMPLETE, MediaEvent.BufferComplete.rawValue) + XCTAssertEqual(MediaConstants.EventName.SEEK_START, MediaEvent.SeekStart.rawValue) + XCTAssertEqual(MediaConstants.EventName.SEEK_COMPLETE, MediaEvent.SeekComplete.rawValue) + XCTAssertEqual(MediaConstants.EventName.BITRATE_CHANGE, MediaEvent.BitrateChange.rawValue) + XCTAssertEqual(MediaConstants.EventName.STATE_START, MediaEvent.StateStart.rawValue) + XCTAssertEqual(MediaConstants.EventName.STATE_END, MediaEvent.StateEnd.rawValue) + } + + func testMediaEventEnum_init() { + XCTAssertEqual(MediaEvent.ChapterStart, MediaEvent(rawValue: MediaConstants.EventName.CHAPTER_START)) + XCTAssertEqual(MediaEvent.ChapterSkip, MediaEvent(rawValue: MediaConstants.EventName.CHAPTER_SKIP)) + XCTAssertEqual(MediaEvent.ChapterComplete, MediaEvent(rawValue: MediaConstants.EventName.CHAPTER_COMPLETE)) + XCTAssertEqual(MediaEvent.AdBreakStart, MediaEvent(rawValue: MediaConstants.EventName.ADBREAK_START)) + XCTAssertEqual(MediaEvent.AdBreakComplete, MediaEvent(rawValue: MediaConstants.EventName.ADBREAK_COMPLETE)) + XCTAssertEqual(MediaEvent.AdStart, MediaEvent(rawValue: MediaConstants.EventName.AD_START)) + XCTAssertEqual(MediaEvent.AdComplete, MediaEvent(rawValue: MediaConstants.EventName.AD_COMPLETE)) + XCTAssertEqual(MediaEvent.AdSkip, MediaEvent(rawValue: MediaConstants.EventName.AD_SKIP)) + XCTAssertEqual(MediaEvent.BufferStart, MediaEvent(rawValue: MediaConstants.EventName.BUFFER_START)) + XCTAssertEqual(MediaEvent.BufferComplete, MediaEvent(rawValue: MediaConstants.EventName.BUFFER_COMPLETE)) + XCTAssertEqual(MediaEvent.SeekStart, MediaEvent(rawValue: MediaConstants.EventName.SEEK_START)) + XCTAssertEqual(MediaEvent.SeekComplete, MediaEvent(rawValue: MediaConstants.EventName.SEEK_COMPLETE)) + XCTAssertEqual(MediaEvent.BitrateChange, MediaEvent(rawValue: MediaConstants.EventName.BITRATE_CHANGE)) + XCTAssertEqual(MediaEvent.StateStart, MediaEvent(rawValue: MediaConstants.EventName.STATE_START)) + XCTAssertEqual(MediaEvent.StateEnd, MediaEvent(rawValue: MediaConstants.EventName.STATE_END)) + XCTAssertNil(MediaEvent(rawValue: "invalid")) + } +} diff --git a/Tests/UnitTests/MediaContextTests.swift b/Tests/UnitTests/MediaContextTests.swift new file mode 100644 index 0000000..698b7d2 --- /dev/null +++ b/Tests/UnitTests/MediaContextTests.swift @@ -0,0 +1,261 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaContextTests: XCTestCase { + let mediaInfo = MediaInfo(id: "mediaID", name: "mediaName", streamType: MediaConstants.StreamType.AOD, mediaType: MediaType.Audio, length: 30.0, prerollWaitingTime: 0)! + let mediaMetadata = ["media.show": "sampleshow", MediaConstants.AudioMetadataKeys.ARTIST: "sampleArtist", "key2": "мểŧẳđαţả"] + let adBreakInfo = AdBreakInfo(name: "adBreakName", position: 1, startTime: 1.1)! + let adInfo = AdInfo(id: "adID", name: "adName", position: 1, length: 15.0)! + let adMetadata = [MediaConstants.AdMetadataKeys.ADVERTISER: "sampleAdvertiser", "key1": "value1", "key2": "мểŧẳđαţả"] + let chapterInfo = ChapterInfo(name: "chapterName", position: 1, startTime: 1.1, length: 30)! + let chapterMetadata = ["media.artist": "sampleArtist", "key1": "value1", "key2": "мểŧẳđαţả"] + var muteStateInfo = StateInfo(stateName: MediaConstants.PlayerState.MUTE)! + var testStateInfo = StateInfo(stateName: "testStateName")! + + func testMediaContextCreation_cachesMediaInfoAndMetadata() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + XCTAssertNotNil(mediaContext) + XCTAssertNotNil(mediaContext.mediaInfo) + XCTAssertEqual("mediaID", mediaContext.mediaInfo.id) + XCTAssertEqual("mediaName", mediaContext.mediaInfo.name) + XCTAssertEqual("aod", mediaContext.mediaInfo.streamType) + XCTAssertEqual("audio", mediaContext.mediaInfo.mediaType.rawValue) + XCTAssertEqual(30.0, mediaContext.mediaInfo.length) + XCTAssertEqual(0, mediaContext.mediaInfo.prerollWaitingTime) + XCTAssertEqual(mediaMetadata, mediaContext.mediaMetadata) + XCTAssertEqual(3, mediaContext.mediaMetadata.count) + } + + func testSetAdInfo_getAdInfoReturnsValidAdInfo() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.setAdInfo(adInfo, metadata: adMetadata) + + XCTAssertNotNil(mediaContext.adInfo) + XCTAssertEqual("adID", mediaContext.adInfo?.id) + XCTAssertEqual("adName", mediaContext.adInfo?.name) + XCTAssertEqual(1, mediaContext.adInfo?.position) + XCTAssertEqual(15, mediaContext.adInfo?.length) + XCTAssertEqual(adMetadata, mediaContext.adMetadata) + XCTAssertEqual(3, mediaContext.adMetadata.count) + } + + func testClearAdInfo_shouldClearAdInfoAndMetadata() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.setAdInfo(adInfo, metadata: adMetadata) + XCTAssertNotNil(mediaContext.adInfo) + XCTAssertEqual(4, mediaContext.adInfo?.toMap().count) + XCTAssertNotNil(mediaContext.adMetadata) + XCTAssertEqual(3, mediaContext.adMetadata.count) + + mediaContext.clearAdInfo() + XCTAssertNil(mediaContext.adInfo) + XCTAssertTrue(mediaContext.adMetadata.isEmpty) + } + + func testSetAdbreakInfo_getAdbreakInfoReturnsValidAdbreakInfo() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.setAdBreakInfo(adBreakInfo) + + XCTAssertNotNil(mediaContext.adBreakInfo) + XCTAssertEqual("adBreakName", mediaContext.adBreakInfo?.name) + XCTAssertEqual(1, mediaContext.adBreakInfo?.position) + XCTAssertEqual(1.1, mediaContext.adBreakInfo?.startTime) + } + + func testClearAdbreakInfo_clearsAdbreakInfo() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.setAdBreakInfo(adBreakInfo) + XCTAssertNotNil(mediaContext.adBreakInfo) + XCTAssertEqual(3, mediaContext.adBreakInfo?.toMap().count) + + mediaContext.clearAdBreakInfo() + XCTAssertNil(mediaContext.adBreakInfo) + } + + func testSetChapterbreakInfo_getChapterInfoReturnsValidChapterInfo() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.setChapterInfo(chapterInfo, metadata: chapterMetadata) + + XCTAssertNotNil(mediaContext.chapterInfo) + XCTAssertEqual("chapterName", mediaContext.chapterInfo?.name) + XCTAssertEqual(1, mediaContext.chapterInfo?.position) + XCTAssertEqual(30, mediaContext.chapterInfo?.length) + XCTAssertEqual(1.1, mediaContext.chapterInfo?.startTime) + } + + func testClearChapterInfo_clearsChapterInfo() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.setChapterInfo(chapterInfo, metadata: chapterMetadata) + XCTAssertNotNil(mediaContext.chapterInfo) + XCTAssertEqual(4, mediaContext.chapterInfo?.toMap().count) + + mediaContext.clearChapterInfo() + XCTAssertNil(mediaContext.chapterInfo) + XCTAssertTrue(mediaContext.chapterMetadata.isEmpty) + } + + func testStartPlayerState_isInStateReturnsTrue() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + XCTAssertTrue(mediaContext.startState(info: muteStateInfo)) + XCTAssertTrue(mediaContext.isInState(info: muteStateInfo)) + } + + func testStartPlayerState_failsAfterMaxNoOfStatesCreated() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + for stateName in 1...10 { + XCTAssertTrue(mediaContext.startState(info: StateInfo(stateName: "State\(stateName)")!)) + } + + XCTAssertFalse(mediaContext.startState(info: StateInfo(stateName: "State11")!)) + XCTAssertFalse(mediaContext.startState(info: StateInfo(stateName: "State12")!)) + } + + func testClearStates_AfterMaxNoOfStatesCreated_clearAStates_allowsNewStates() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + for stateName in 1...10 { + XCTAssertTrue(mediaContext.startState(info: StateInfo(stateName: "State\(stateName)")!)) + } + + mediaContext.clearStates() + + // Now can again track 10 new states + XCTAssertTrue(mediaContext.startState(info: StateInfo(stateName: "State11")!)) + XCTAssertTrue(mediaContext.startState(info: StateInfo(stateName: "State12")!)) + } + + func testEndPlayerState_isInStateReturnsFalse() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + XCTAssertTrue(mediaContext.startState(info: muteStateInfo)) + XCTAssertTrue(mediaContext.startState(info: testStateInfo)) + + mediaContext.endState(info: muteStateInfo) + XCTAssertFalse(mediaContext.isInState(info: muteStateInfo)) + XCTAssertTrue(mediaContext.isInState(info: testStateInfo)) + + XCTAssertTrue(mediaContext.endState(info: testStateInfo)) + XCTAssertFalse(mediaContext.isInState(info: testStateInfo)) + } + + func testGetAllStates_returnsAllStatesWithTheirCurrentStatus() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + for stateName in 1...10 { + XCTAssertTrue(mediaContext.startState(info: StateInfo(stateName: "State\(stateName)")!)) + } + + var activeStatesList = mediaContext.getActiveTrackedStates() + XCTAssertEqual(10, activeStatesList.count) + for stateName in 1...10 { + XCTAssertTrue(activeStatesList.contains(StateInfo(stateName: "State\(stateName)")!)) + } + + for stateName in 6...10 { + XCTAssertTrue(mediaContext.endState(info: StateInfo(stateName: "State\(stateName)")!)) + } + + activeStatesList = mediaContext.getActiveTrackedStates() + XCTAssertEqual(5, activeStatesList.count) + for stateName in 1...5 { + XCTAssertTrue(activeStatesList.contains(StateInfo(stateName: "State\(stateName)")!)) // 1-5 states are active + } + } + + func testEnterPlaybackState_setsStateInContext() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Init) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Init)) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Play)) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Pause) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Pause)) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek)) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Buffer)) + } + + func testEnterPlaybackState_explicitExitPlaybackStateRequiredToExitBufferAndSeek() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek)) + // doesnt switch playback state + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek)) + + // exits seek and switches plabackstate to state set at the beginning + mediaContext.exitPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + XCTAssertFalse(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek)) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Play)) + } + + func testExitPlaybackStateBufferAndSeek_exitsBufferingAndSeekingAndSwitchesToPreviousInitPlayPauseState() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Buffer)) + + mediaContext.exitPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + XCTAssertFalse(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Buffer)) + // assert fallback to state before buffer + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Init)) + + // set to play playback state + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek)) + + mediaContext.exitPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + XCTAssertFalse(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Seek)) + // assert fallback to state before seek + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Play)) + + // set to pause playback state + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Pause) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Buffer)) + + mediaContext.exitPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + XCTAssertFalse(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Buffer)) + // assert fallback to state before pause + XCTAssertTrue(mediaContext.isInMediaPlaybackState(state: MediaContext.MediaPlaybackState.Pause)) + + } + + func testIsIdle_returnsTrueWhenInBufferSeekPause_returnsFalseWhenInPlay() { + let mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: mediaMetadata) + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Pause) + XCTAssertTrue(mediaContext.isIdle()) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + XCTAssertFalse(mediaContext.isIdle()) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + XCTAssertTrue(mediaContext.isIdle()) + mediaContext.exitPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + XCTAssertFalse(mediaContext.isIdle()) + + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + XCTAssertTrue(mediaContext.isIdle()) + } +} diff --git a/Tests/UnitTests/MediaEventProcessorTests.swift b/Tests/UnitTests/MediaEventProcessorTests.swift new file mode 100644 index 0000000..f5aa6b9 --- /dev/null +++ b/Tests/UnitTests/MediaEventProcessorTests.swift @@ -0,0 +1,230 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaEventProcessorTests: XCTestCase { + + static let trackerSessionId = "testTrackerSessionId" + + func testCreateSession() { + + // setup + let emptytrackerConfig = [String: Any]() + + // test + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + guard let sessionId = mediaProcessor.createSession(trackerConfig: emptytrackerConfig, trackerSessionId: Self.trackerSessionId) else { + XCTFail("Could not create session id") + return + } + + // Assert + XCTAssertNotNil(sessionId) + XCTAssertTrue(mediaProcessor.mediaSessions.keys.contains(sessionId)) + XCTAssertNotNil(mediaProcessor.mediaSessions[sessionId]) + + } + + func testProcessEvent_validSesionId() { + let emptytrackerConfig = [String: Any]() + + // Action + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + guard let sessionId = mediaProcessor.createSession(trackerConfig: emptytrackerConfig, trackerSessionId: Self.trackerSessionId) else { + XCTFail("Could not create session id") + return + } + + let mediaSessionSpy = MediaSessionSpy(id: sessionId, trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + mediaProcessor.mediaSessions[sessionId] = mediaSessionSpy + mediaProcessor.processEvent(sessionId: sessionId, event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: Date(timeIntervalSince1970: 0), mediaCollection: XDMMediaCollection())) + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertTrue(mediaSessionSpy.hasQueueEventCalled) + XCTAssertTrue(mediaSessionSpy.events.contains { event in + event.eventType == XDMMediaEventType.sessionStart + }) + } + + func testProcessEvent_invalidSessionId() { + // setup + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + let mediaSessionSpy = MediaSessionSpy(id: "SessionID-1", trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + + // test + mediaProcessor.processEvent(sessionId: "InvalidSessionID", event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: Date(timeIntervalSince1970: 0), mediaCollection: XDMMediaCollection())) + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertFalse(mediaSessionSpy.hasQueueEventCalled) + XCTAssertTrue(mediaSessionSpy.events.isEmpty) + + } + + func testEndSession_validSessionId() { + + // setup + let emptytrackerConfig = [String: Any]() + + // test + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + guard let sessionId = mediaProcessor.createSession(trackerConfig: emptytrackerConfig, trackerSessionId: Self.trackerSessionId) else { + XCTFail("Could not create session id") + return + } + + // Assert + XCTAssertNotNil(sessionId) + XCTAssertTrue(mediaProcessor.mediaSessions.keys.contains(sessionId)) + XCTAssertNotNil(mediaProcessor.mediaSessions[sessionId]) + let mediaSessionSpy = MediaSessionSpy(id: sessionId, trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + mediaProcessor.mediaSessions[sessionId] = mediaSessionSpy + mediaProcessor.endSession(sessionId: sessionId) + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertTrue(mediaSessionSpy.hasSessionEndCalled) + XCTAssertFalse(mediaProcessor.mediaSessions.keys.contains(sessionId)) + + } + + func testEndSession_invalidSessionId() { + // setup + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + let mediaSessionSpy = MediaSessionSpy(id: "SessionID-1", trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + + // test + mediaProcessor.endSession(sessionId: "InvalidSessionID") + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertFalse(mediaSessionSpy.hasSessionEndCalled) + + } + + func testAbortAllSession_validSessionIds() { + + // setup + let emptytrackerConfig = [String: Any]() + + // test + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + guard let sessionId1 = mediaProcessor.createSession(trackerConfig: emptytrackerConfig, trackerSessionId: Self.trackerSessionId) else { + XCTFail("Could not create session id") + return + } + guard let sessionId2 = mediaProcessor.createSession(trackerConfig: emptytrackerConfig, trackerSessionId: Self.trackerSessionId) else { + XCTFail("Could not create session id") + return + } + + // Assert + XCTAssertNotNil(sessionId1) + XCTAssertNotNil(sessionId2) + XCTAssertTrue(mediaProcessor.mediaSessions.keys.contains(sessionId1)) + XCTAssertTrue(mediaProcessor.mediaSessions.keys.contains(sessionId2)) + XCTAssertNotNil(mediaProcessor.mediaSessions[sessionId1]) + XCTAssertNotNil(mediaProcessor.mediaSessions[sessionId2]) + + let mediaSessionSpy1 = MediaSessionSpy(id: sessionId1, trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + let mediaSessionSpy2 = MediaSessionSpy(id: sessionId2, trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + mediaProcessor.mediaSessions[sessionId1] = mediaSessionSpy1 + mediaProcessor.mediaSessions[sessionId2] = mediaSessionSpy2 + + mediaProcessor.abortAllSessions() + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertTrue(mediaSessionSpy1.hasSesionAbortCalled) + XCTAssertTrue(mediaSessionSpy2.hasSesionAbortCalled) + XCTAssertFalse(mediaProcessor.mediaSessions.keys.contains(sessionId1)) + XCTAssertFalse(mediaProcessor.mediaSessions.keys.contains(sessionId2)) + + } + + func testAbortAllSession_WithValidAndInvalidSesions() { + // setup + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + + let mediaSessionSpy1 = MediaSessionSpy(id: "sessionId1", trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + let mediaSessionSpy2 = MediaSessionSpy(id: "SessionId2", trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + + mediaProcessor.mediaSessions["sessionId2"] = mediaSessionSpy2 + + // test + mediaProcessor.abortAllSessions() + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertFalse(mediaSessionSpy1.hasSesionAbortCalled) + XCTAssertTrue(mediaSessionSpy2.hasSesionAbortCalled) + + } + + func testUpdateSessionId() { + // setup + let emptytrackerConfig = [String: Any]() + + // test + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + guard let sessionId = mediaProcessor.createSession(trackerConfig: emptytrackerConfig, trackerSessionId: Self.trackerSessionId) else { + XCTFail("Could not create session id") + return + } + + // Assert + XCTAssertNotNil(sessionId) + XCTAssertTrue(mediaProcessor.mediaSessions.keys.contains(sessionId)) + XCTAssertNotNil(mediaProcessor.mediaSessions[sessionId]) + + let mediaSessionSpy = MediaSessionSpy(id: sessionId, trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + mediaProcessor.mediaSessions[sessionId] = mediaSessionSpy + mediaProcessor.notifyBackendSessionId(requestEventId: "testRequestEventId", backendSessionId: "testSessionId") + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertTrue(mediaSessionSpy.hasHandleSessionUpdateCalled) + XCTAssertEqual("testRequestEventId", mediaSessionSpy.requestEventId) + XCTAssertEqual("testSessionId", mediaSessionSpy.backendSessionId) + } + + func testHandleErrorResponse() { + // setup + let emptytrackerConfig = [String: Any]() + + // test + let mediaProcessor = MediaEventProcessor(dispatcher: nil) + guard let sessionId = mediaProcessor.createSession(trackerConfig: emptytrackerConfig, trackerSessionId: Self.trackerSessionId) else { + XCTFail("Could not create session id") + return + } + + // Assert + XCTAssertNotNil(sessionId) + XCTAssertTrue(mediaProcessor.mediaSessions.keys.contains(sessionId)) + XCTAssertNotNil(mediaProcessor.mediaSessions[sessionId]) + + let mediaSessionSpy = MediaSessionSpy(id: sessionId, trackerSessionId: Self.trackerSessionId, state: MediaState(), dispatchQueue: DispatchQueue(label: "testMediaEventProcessor"), dispatcher: nil) + mediaProcessor.mediaSessions[sessionId] = mediaSessionSpy + mediaProcessor.notifyErrorResponse(requestEventId: "testRequestEventId", data: ["error1": "errorMsg"]) + Thread.sleep(forTimeInterval: 0.25) + + // Assert + XCTAssertTrue(mediaSessionSpy.hasHandleErrorResponseCalled) + XCTAssertEqual("testRequestEventId", mediaSessionSpy.requestEventId) + XCTAssertEqual("errorMsg", mediaSessionSpy.errorData["error1"] as? String ?? "") + } +} diff --git a/Tests/UnitTests/MediaEventTrackerTests.swift b/Tests/UnitTests/MediaEventTrackerTests.swift new file mode 100644 index 0000000..774751c --- /dev/null +++ b/Tests/UnitTests/MediaEventTrackerTests.swift @@ -0,0 +1,1261 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaEventTrackerTests: XCTestCase { + typealias RuleName = MediaEventTracker.RuleName + // Disable preroll logic for tests + static let media = MediaInfo(id: "id", name: "name", streamType: "aod", mediaType: MediaType.Audio, length: 60, resumed: false, prerollWaitingTime: 0, granularAdTracking: false) + + // Default preroll wait time + static let mediaDefaultPrerollWait = MediaInfo(id: "id", name: "name", streamType: "aod", mediaType: MediaType.Audio, length: 60) + + // Custom preroll wait time + static let mediaCustomPrerollWait = MediaInfo(id: "id", name: "name", streamType: "aod", mediaType: MediaType.Audio, length: 60, resumed: false, prerollWaitingTime: 5000, granularAdTracking: false) + + static let metadata = [ + "k1": "v1" + ] + + static let denyListMetadata = [ + "vAlid_keY.12": "valid_value.@$%!2", + "inv@lidKey": "validValue123", + "": "valid_@_Value", + "invalidKey!": "valid_value", + "valid_key": "" + ] + + static let cleanMetadata = [ + "vAlid_keY.12": "valid_value.@$%!2", + "valid_key": "" + ] + + static let KEY_INFO = "key_info" + static let KEY_METADATA = "key_metadata" + static let KEY_EVENT_TS = "key_eventts" + + static let adbreak1 = AdBreakInfo(name: "adbreak1", position: 1, startTime: 10.0) + static let adbreak2 = AdBreakInfo(name: "adbreak2", position: 2, startTime: 20.0) + + static let ad1 = AdInfo(id: "ad1", name: "adname1", position: 1, length: 15.0) + static let ad2 = AdInfo(id: "ad2", name: "adname2", position: 2, length: 15.0) + + static let chapter1 = ChapterInfo(name: "chapter1", position: 1, startTime: 10.0, length: 30.0) + static let chapter2 = ChapterInfo(name: "chapter2", position: 2, startTime: 30.0, length: 30.0) + + static let qoe = QoEInfo(bitrate: 1.1, droppedFrames: 2.2, fps: 3.3, startupTime: 4.4) + + static let stateMute = StateInfo(stateName: "mute") + + static let config: [String: Any] = [:] + var mediaTracker: MediaEventTracker! + var eventGenerator: MediaEventGenerator! + + func handleTrackAPI() -> Bool { + guard let event = eventGenerator.dispatchedEvent else { + return false + } + + return mediaTracker.track(event: event) + } + + func compareRuleNames(list1: [(name: RuleName, context: [String: Any])], list2:[(name: RuleName, context: [String: Any])]) -> Bool { + if list1.count != list2.count { + return false + } + + for i in 0...list1.count - 1 { + let a = list1[i] + let b = list2[i] + + if a.name != b.name { + return false + } + } + + return true + } + + override func setUp() { + eventGenerator = MediaEventGenerator(config: Self.config) + mediaTracker = MediaEventTracker(eventProcessor: MediaEventProcessor(dispatcher: nil), config: Self.config) + } + + // MARK: MediaEventTracker Unit Tests + func testTrackeventHandleAbsentEventName() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + var eventData = eventGenerator.dispatchedEvent?.data + eventData?.removeValue(forKey: MediaConstants.Tracker.EVENT_NAME) + + let event = Event(name: "", + type: MediaConstants.Media.EVENT_TYPE, + source: MediaConstants.Media.EVENT_NAME_TRACK_MEDIA, + data: eventData) + XCTAssertFalse(mediaTracker.track(event: event)) + } + + func testTrackeventHandleIncorrectEventName() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + + var eventData = eventGenerator.dispatchedEvent?.data + eventData?[MediaConstants.Tracker.EVENT_NAME] = "incorrect" + + let event = Event(name: "", + type: MediaConstants.Media.EVENT_TYPE, + source: MediaConstants.Media.EVENT_NAME_TRACK_MEDIA, + data: eventData) + XCTAssertFalse(mediaTracker.track(event: event)) + } + + func testTrackeventHandleAbsentEventTimeStamp() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + + var eventData = eventGenerator.dispatchedEvent?.data + eventData?.removeValue(forKey: MediaConstants.Tracker.EVENT_TIMESTAMP) + + let event = Event(name: "", + type: MediaConstants.Media.EVENT_TYPE, + source: MediaConstants.Media.EVENT_NAME_TRACK_MEDIA, + data: eventData) + XCTAssertFalse(mediaTracker.track(event: event)) + } + + func testTrackSessionStartFailOtherAPIsBeforeStart() { + eventGenerator.trackPlay() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackComplete() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackSessionEnd() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackError(errorId: "error") + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BitrateChange) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.updateCurrentPlayhead(time: 1.0) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.updateQoEObject(qoe: Self.qoe!.toMap()) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackSessionStartPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackSessionStartWithDenyListMetadataPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.denyListMetadata) + XCTAssertTrue(handleTrackAPI()) + let actualMetadata = mediaTracker.mediaContext!.mediaMetadata + XCTAssertEqual(Self.cleanMetadata, actualMetadata) + } + + func testTrackSessionStartAlreadyInSessionStartFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackSessionStartInvalidMediaInfo() { + eventGenerator.trackSessionStart(info: [:], metadata: Self.metadata) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackSessionEndPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackSessionEnd() + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackSessionEndFailOtherCallsAfterEnd() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackSessionEnd() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackComplete() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackSessionEnd() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackError(errorId: "error") + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BitrateChange) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.updateCurrentPlayhead(time: 1.0) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.updateQoEObject(qoe: Self.qoe!.toMap()) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackCompletePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackSessionEnd() + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackCompleteFailOtherCallsAfterComplete() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackComplete() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackComplete() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackSessionEnd() + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackError(errorId: "error") + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BitrateChange) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.updateCurrentPlayhead(time: 1.0) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.updateQoEObject(qoe: Self.qoe!.toMap()) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackError_WithValidString_passes() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackError(errorId: "error") + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackError_WithEmtpyString_fails() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackError(errorId: "") + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackBitrateChangePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BitrateChange) + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackPlayPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackPlayInBufferingPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackPlayInSeekingPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackPausePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackPauseInBufferingPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackPauseInSeekingPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackBufferStartPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferStart) + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackBufferStartInBufferingFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferStart) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackBufferStartInSeekingFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackBufferCompletePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferComplete) + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackBufferCompleteNotBufferingFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferComplete) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackSeekStartPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackSeekStartInBufferingFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.BufferStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackSeekStartInSeekingFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackSeekCompletePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekComplete) + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackSeekCompleteNotSeekingFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekComplete) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdBreakStartPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + let actualAdBreak = mediaTracker.mediaContext!.adBreakInfo + XCTAssertEqual(Self.adbreak1, actualAdBreak) + } + + func testTrackAdBreakStartInvalidInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart) + XCTAssertFalse(handleTrackAPI()) + + let actualAdBreak = mediaTracker.mediaContext!.adBreakInfo + XCTAssertNil(actualAdBreak) + } + + func testTrackAdBreakStartDuplicateInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdBreakStartRepalceAdBreakNotInAdPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak2!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackAdBreakStartReplaceAdBreakInAdPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak2!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + let actualAdBreak = mediaTracker.mediaContext!.adBreakInfo + XCTAssertEqual(Self.adbreak2, actualAdBreak) + } + + func testTrackAdBreakCompleteWithoutAdBreakStartFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakComplete) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdBreakCompleteNotInAdPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakComplete) + XCTAssertTrue(handleTrackAPI()) + + let actualAdBreak = mediaTracker.mediaContext!.adBreakInfo + XCTAssertNil(actualAdBreak) + } + + func testTrackAdBreakCompleteInAdPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakComplete) + XCTAssertTrue(handleTrackAPI()) + + let actualAdBreak = mediaTracker.mediaContext!.adBreakInfo + XCTAssertNil(actualAdBreak) + + let actualAd = mediaTracker.mediaContext!.adInfo + XCTAssertNil(actualAd) + } + + func testTrackAdStartPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + let actualAd = mediaTracker.mediaContext!.adInfo + XCTAssertEqual(Self.ad1, actualAd) + } + + func testTrackAdStartWithDenyListMetadataPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.denyListMetadata) + XCTAssertTrue(handleTrackAPI()) + + let actualMetadata = mediaTracker.mediaContext!.adMetadata + XCTAssertEqual(Self.cleanMetadata, actualMetadata) + } + + func testTrackAdStartInvalidInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdStartDuplicateInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdStartNotInAdBreakFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdStartReplaceAd() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad2!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + let actualAd = mediaTracker.mediaContext!.adInfo + XCTAssertEqual(Self.ad2, actualAd) + } + + func testTrackAdCompleteNoAdBreakFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdComplete) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdCompleteNoAdStartFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdComplete) + XCTAssertFalse(handleTrackAPI()) + } + + func testTrackAdCompletePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdComplete) + XCTAssertTrue(handleTrackAPI()) + + let actualAd = mediaTracker.mediaContext!.adInfo + XCTAssertNil(actualAd) + } + + func testAdSkipNoAdBreakFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdComplete) + XCTAssertFalse(handleTrackAPI()) + + } + + func testAdSkipNoAdFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdSkip) + XCTAssertFalse(handleTrackAPI()) + + } + + func testAdSkipPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdStart, info: Self.ad1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.AdSkip) + XCTAssertTrue(handleTrackAPI()) + + let actualAd = mediaTracker.mediaContext!.adInfo + XCTAssertNil(actualAd) + + } + + func testChapterStartPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart, info: Self.chapter1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + let actualChapter = mediaTracker.mediaContext!.chapterInfo + XCTAssertEqual(Self.chapter1, actualChapter) + + let actualMetadata = mediaTracker.mediaContext!.chapterMetadata + XCTAssertEqual(Self.metadata, actualMetadata) + } + + func testChapterStartInvalidInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart) + XCTAssertFalse(handleTrackAPI()) + } + + func testChapterStartDuplicateInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart, info: Self.chapter1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart, info: Self.chapter1!.toMap(), metadata: Self.metadata) + XCTAssertFalse(handleTrackAPI()) + } + + func testChapterStartReplaceChapterPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart, info: Self.chapter1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart, info: Self.chapter2!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + let actualChapter = mediaTracker.mediaContext!.chapterInfo + XCTAssertEqual(Self.chapter2, actualChapter) + } + + func testChapterCompleteNoChapterStartFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterComplete) + XCTAssertFalse(handleTrackAPI()) + } + + func testChapterCompletePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart, info: Self.chapter1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterSkip) + XCTAssertTrue(handleTrackAPI()) + + let actualChapter = mediaTracker.mediaContext!.chapterInfo + XCTAssertNil(actualChapter) + } + + func testChapterSkipNoChapterSkipFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterComplete) + XCTAssertFalse(handleTrackAPI()) + } + + func testChapterSkipPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterStart, info: Self.chapter1!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.ChapterSkip) + XCTAssertTrue(handleTrackAPI()) + + let actualChapter = mediaTracker.mediaContext!.chapterInfo + XCTAssertNil(actualChapter) + } + + func testUpdatePlayheadPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.updateCurrentPlayhead(time: 1.1) + XCTAssertTrue(handleTrackAPI()) + + let actualPlayhead = mediaTracker.mediaContext!.playhead + XCTAssertEqual(1.1, actualPlayhead) + } + + func testUpdateQoEPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.updateQoEObject(qoe: Self.qoe!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + let actualQoE = mediaTracker.mediaContext!.qoeInfo + XCTAssertEqual(Self.qoe, actualQoE) + } + + func testUpdateQoEFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.updateQoEObject(qoe: [:]) + XCTAssertFalse(handleTrackAPI()) + } + + func testStateStartPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + + func testStateStartInvalidInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart) + XCTAssertFalse(handleTrackAPI()) + } + + func testStateStartSameStateFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertFalse(handleTrackAPI()) + } + + func testStateStartMaxStateLimitReachedFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + for i in 0...9 { + let state = StateInfo(stateName: "state\(i)") + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: state!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertFalse(handleTrackAPI()) + } + + // Track 10 states, track 11th fail, end all 10 states, start all 10 states again + func testStateStartMaxStateLimitReachedAndRetrackPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + for i in 0...9 { + let state = StateInfo(stateName: "state\(i)") + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: state!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertFalse(handleTrackAPI()) + + for i in 0...9 { + let state = StateInfo(stateName: "state\(i)") + eventGenerator.trackEvent(event: MediaEvent.StateEnd, info: state!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: state!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + } + + func testStateEndPass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateEnd, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + + func testStateEndInvalidInfoFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateEnd) + XCTAssertFalse(handleTrackAPI()) + } + + func testStateEndWithoutStateStartFail() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateEnd, info: Self.stateMute!.toMap()) + XCTAssertFalse(handleTrackAPI()) + } + + func testStateTogglePass() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateEnd, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateEnd, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + + func testStateNewSession() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + for i in 0...9 { + let state = StateInfo(stateName: "state\(i)") + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: state!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertFalse(handleTrackAPI()) + + eventGenerator.trackSessionEnd() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + for i in 0...9 { + let state = StateInfo(stateName: "newstate\(i)") + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: state!.toMap()) + XCTAssertTrue(handleTrackAPI()) + } + } + + func testStateIdleExitReTrackStates() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.StateStart, info: Self.stateMute!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.incrementTimeStamp(value: (31 * 60 * 1000)) + eventGenerator.updateCurrentPlayhead(time: 1) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.trackerIdle) + + eventGenerator.trackEvent(event: MediaEvent.SeekComplete) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.trackerIdle) + } + + // Preroll unit tests + + func testPrerollDisabled() { + eventGenerator.trackSessionStart(info: Self.media!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.inPrerollInterval) + } + + func testPrerollEnabledDefaultInterval() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + } + + func testPrerollEnabledCustomInterval() { + eventGenerator.trackSessionStart(info: Self.mediaCustomPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + } + + func testPrerollEnabledExceedDefaultInterval() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + + eventGenerator.incrementTimeStamp(value: 200) + eventGenerator.updateCurrentPlayhead(time: 0.2) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + + eventGenerator.incrementTimeStamp(value: 200) + eventGenerator.updateCurrentPlayhead(time: 0.4) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.inPrerollInterval) + } + + func testPrerollEnabledExceedCustomInterval() { + eventGenerator.trackSessionStart(info: Self.mediaCustomPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + + eventGenerator.incrementTimeStamp(value: 2000) + eventGenerator.updateCurrentPlayhead(time: 2) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + + eventGenerator.incrementTimeStamp(value: 3001) + eventGenerator.updateCurrentPlayhead(time: 5) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.inPrerollInterval) + } + + func testPrerollTrackSessionEnd() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + + eventGenerator.trackSessionEnd() + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.inPrerollInterval) + } + + func testPrerollTrackComplete() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + + eventGenerator.trackComplete() + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.inPrerollInterval) + } + + func testPrerollTrackEventAdBreakStart() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.inPrerollInterval) + + eventGenerator.trackEvent(event: MediaEvent.AdBreakStart, info: Self.adbreak1!.toMap()) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.inPrerollInterval) + } + + func testPrerollReorderNoAdBreak() { + let rules: [(name: RuleName, context: [String: Any])] = [ + (name: RuleName.Play, context: [:]), + (name: RuleName.Pause, context: [:]), + (name: RuleName.ChapterStart, context: [:]) + ] + + let reorderedRules = mediaTracker.prerollReorderRules(rules: rules) + + XCTAssertTrue(compareRuleNames(list1: rules, list2: reorderedRules)) + } + + func testPrerollReorderNoPlay() { + let rules: [(name: RuleName, context: [String: Any])] = [ + (name: RuleName.Pause, context: [:]), + (name: RuleName.AdBreakStart, context: [:]), + (name: RuleName.AdStart, context: [:]) + ] + + let reorderedRules = mediaTracker.prerollReorderRules(rules: rules) + + XCTAssertTrue(compareRuleNames(list1: rules, list2: reorderedRules)) + } + + func testPrerollReorderPlayBeforeAdBreak() { + let rules: [(name: RuleName, context: [String: Any])] = [ + (name: RuleName.Play, context: [:]), + (name: RuleName.AdBreakStart, context: [:]), + (name: RuleName.AdStart, context: [:]) + ] + + let expectedReorderedRules: [(name: RuleName, context: [String: Any])] = [ + (name: RuleName.AdBreakStart, context: [:]), + (name: RuleName.AdStart, context: [:]) + ] + + let reorderedRules = mediaTracker.prerollReorderRules(rules: rules) + + XCTAssertTrue(compareRuleNames(list1: expectedReorderedRules, list2: reorderedRules)) + } + + func testIdleEnter() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.incrementTimeStamp(value: (31 * 60 * 1000)) + eventGenerator.trackPause() + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.trackerIdle) + } + + func testIdleExit() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackEvent(event: MediaEvent.SeekStart) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.incrementTimeStamp(value: (31 * 60 * 1000)) + eventGenerator.updateCurrentPlayhead(time: 1) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.trackerIdle) + + eventGenerator.trackEvent(event: MediaEvent.SeekComplete) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertFalse(mediaTracker.trackerIdle) + } + + func testSessionTimeout() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPlay() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.incrementTimeStamp(value: (24 * 60 * 60 * 1000)) // 24 hours + eventGenerator.updateCurrentPlayhead(time: 1) + XCTAssertTrue(handleTrackAPI()) + + // Tracker is not idle after Media Session Restarted after MediaSessionTimeout(24hrs) + XCTAssertFalse(mediaTracker.trackerIdle) + + eventGenerator.trackPause() + XCTAssertTrue(handleTrackAPI()) + } + + func testTrackerIdleSessionTimeoutFail() { + eventGenerator.trackSessionStart(info: Self.mediaDefaultPrerollWait!.toMap(), metadata: Self.metadata) + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.trackPause() + XCTAssertTrue(handleTrackAPI()) + + eventGenerator.incrementTimeStamp(value: (24 * 60 * 60 * 1000)) // 24 hours + eventGenerator.updateCurrentPlayhead(time: 1) + XCTAssertTrue(handleTrackAPI()) + + XCTAssertTrue(mediaTracker.trackerIdle) + } + + func testHelperGetMetadata() { + var context: [String: Any] = [:] + + XCTAssertEqual([:], mediaTracker.getMetadata(context: context)) + + context[Self.KEY_METADATA] = nil + XCTAssertEqual([:], mediaTracker.getMetadata(context: context)) + + context[Self.KEY_METADATA] = "" + XCTAssertEqual([:], mediaTracker.getMetadata(context: context)) + + context[Self.KEY_METADATA] = ["k1": "v1"] + XCTAssertEqual(["k1": "v1"], mediaTracker.getMetadata(context: context)) + } + + func testHelperGetPlayhead() { + var context: [String: Any] = [:] + + XCTAssertNil(mediaTracker.getPlayhead(context: context)) + + context[Self.KEY_INFO] = nil + XCTAssertNil(mediaTracker.getPlayhead(context: context)) + + context[Self.KEY_INFO] = "" + XCTAssertNil(mediaTracker.getPlayhead(context: context)) + + context[Self.KEY_INFO] = [MediaConstants.Tracker.PLAYHEAD: nil] + XCTAssertNil(mediaTracker.getPlayhead(context: context)) + + context[Self.KEY_INFO] = [MediaConstants.Tracker.PLAYHEAD: ""] + XCTAssertNil(mediaTracker.getPlayhead(context: context)) + + context[Self.KEY_INFO] = [MediaConstants.Tracker.PLAYHEAD: 1.0] + XCTAssertEqual(1.0, mediaTracker.getPlayhead(context: context)) + } + + func testHelperGetRefTS() { + var context: [String: Any] = [:] + + XCTAssertEqual(0, mediaTracker.getRefTS(context: context)) + + context[Self.KEY_EVENT_TS] = nil + XCTAssertEqual(0, mediaTracker.getRefTS(context: context)) + + context[Self.KEY_EVENT_TS] = "" + XCTAssertEqual(0, mediaTracker.getRefTS(context: context)) + + context[Self.KEY_EVENT_TS] = Int64(100) + XCTAssertEqual(100, mediaTracker.getRefTS(context: context)) + } + + func testHelperGetError() { + var context: [String: Any] = [:] + + XCTAssertNil(mediaTracker.getError(context: context)) + + context[Self.KEY_INFO] = nil + XCTAssertNil(mediaTracker.getError(context: context)) + + context[Self.KEY_INFO] = "" + XCTAssertNil(mediaTracker.getError(context: context)) + + context[Self.KEY_INFO] = [MediaConstants.ErrorInfo.ID: nil] + XCTAssertNil(mediaTracker.getError(context: context)) + + context[Self.KEY_INFO] = [MediaConstants.ErrorInfo.ID: 1.0] + XCTAssertNil(mediaTracker.getError(context: context)) + + context[Self.KEY_INFO] = [MediaConstants.ErrorInfo.ID: "error"] + XCTAssertEqual("error", mediaTracker.getError(context: context)) + } +} diff --git a/Tests/UnitTests/MediaObjectTests.swift b/Tests/UnitTests/MediaObjectTests.swift new file mode 100644 index 0000000..f659566 --- /dev/null +++ b/Tests/UnitTests/MediaObjectTests.swift @@ -0,0 +1,928 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaObjectTests: XCTestCase { + static let validMediaInfo: [String: Any] = [ + MediaConstants.MediaInfo.ID: "testId", + MediaConstants.MediaInfo.NAME: "testName", + MediaConstants.MediaInfo.LENGTH: 10.0, + MediaConstants.MediaInfo.STREAM_TYPE: "aod", + MediaConstants.MediaInfo.MEDIA_TYPE: "audio", + MediaConstants.MediaInfo.RESUMED: true, + MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME: 2000, // 2000 milliseconds + MediaConstants.MediaInfo.GRANULAR_AD_TRACKING: true + ] + + static let validAdbreakInfo: [String: Any] = [ + MediaConstants.AdBreakInfo.NAME: "Adbreakname", + MediaConstants.AdBreakInfo.POSITION: 1, + MediaConstants.AdBreakInfo.START_TIME: 0.0 + ] + + static let validAdInfo: [String: Any] = [ + MediaConstants.AdInfo.ID: "AdID", + MediaConstants.AdInfo.NAME: "AdName", + MediaConstants.AdInfo.POSITION: 1, + MediaConstants.AdInfo.LENGTH: 2.5 + ] + + static let validChapterInfo: [String: Any] = [ + MediaConstants.ChapterInfo.NAME: "ChapterName", + MediaConstants.ChapterInfo.POSITION: 3, + MediaConstants.ChapterInfo.START_TIME: 3.0, + MediaConstants.ChapterInfo.LENGTH: 5.0 + ] + + static let validQoEInfo: [String: Any] = [ + MediaConstants.QoEInfo.BITRATE: 24.0, + MediaConstants.QoEInfo.DROPPED_FRAMES: 2.0, + MediaConstants.QoEInfo.FPS: 30.0, + MediaConstants.QoEInfo.STARTUP_TIME: 0.0 + ] + + static let validStateInfo: [String: Any] = [ + MediaConstants.StateInfo.STATE_NAME_KEY: "fullscreen._" + ] + + static let validStateInfo64Long: [String: Any] = [ + MediaConstants.StateInfo.STATE_NAME_KEY: "1234567890123456789012345678901234567890123456789012345678901234" + ] + + static let values: [Any] = [ + 1, + false, + 2.3, + "test", + [:], + [] + ] + + static let valuesOtherThanBool: [Any] = [ + 1, + 2.3, + "test", + [:], + [] + ] + + static let valuesOtherThanString: [Any] = [ + 1, + false, + 2.3, + [:], + [] + ] + + static let valuesOtherThanDouble: [Any] = [ + false, + "test", + [:], + [], + 1, + 0, + -1 + ] + + static let valuesOtherThanInt: [Any] = [ + false, + "test", + [:], + [], + 1.0 + ] + + static let numberLessThan0Int: [Int] = [ + -1 + ] + + static let numberLessThan0: [Double] = [ + -1, + -1.0 + ] + + static let numberLessThan1: [Double] = [ + 0.0, + 0, + -1, + -1.0 + ] + + static let invalidStateName: [String] = [ + "fullscreen!!", + "fullscreen@", + "fullscreen/", + "fullscreen-", + "mu$$te", + "12345678901234567890123456789012345678901234567890123456789012345" + ] + + override func setUp() { + } + + override func tearDown() { + } + + // MARK: MediaObject unit tests + + // ========================================================================== + // MediaInfo + // ========================================================================== + func testMediaInfo_ConvenienceInit_NilInfo() { + XCTAssertNil(MediaInfo(info: nil)) + } + + func testMediaInfo_Init_Valid_WithAllRequiredParams_DefaultOptionalValues() { + let mediaInfo = MediaInfo(id: "testId", name: "testName", streamType: "aod", mediaType: MediaType.Audio, length: 10.1) + XCTAssertNotNil(mediaInfo) + + XCTAssertEqual("testId", mediaInfo?.id) + XCTAssertEqual("testName", mediaInfo?.name) + XCTAssertEqual(10.1, mediaInfo?.length) + XCTAssertEqual("aod", mediaInfo?.streamType) + XCTAssertEqual(MediaType.Audio, mediaInfo?.mediaType) + XCTAssertEqual(false, mediaInfo?.resumed) + XCTAssertEqual(MediaInfo.DEFAULT_PREROLL_WAITING_TIME_IN_MS, mediaInfo?.prerollWaitingTime) + XCTAssertEqual(false, mediaInfo?.granularAdTracking) + } + + func testMediaInfo_Init_Valid_WithAllParams() { + let mediaInfo = MediaInfo(id: "testId", name: "testName", streamType: "aod", mediaType: MediaType.Audio, length: 10.1, resumed: true, prerollWaitingTime: 2000, granularAdTracking: true) + + XCTAssertNotNil(mediaInfo) + XCTAssertEqual("testId", mediaInfo?.id) + XCTAssertEqual("testName", mediaInfo?.name) + XCTAssertEqual(10.1, mediaInfo?.length) + XCTAssertEqual("aod", mediaInfo?.streamType) + XCTAssertEqual(MediaType.Audio, mediaInfo?.mediaType) + XCTAssertEqual(true, mediaInfo?.resumed) + XCTAssertEqual(2000, mediaInfo?.prerollWaitingTime) + XCTAssertEqual(true, mediaInfo?.granularAdTracking) + } + + func testMediaInfo_ConvenienceInit_Valid() { + let mediaInfo = MediaInfo(info: MediaObjectTests.validMediaInfo) + + XCTAssertNotNil(mediaInfo) + XCTAssertEqual("testId", mediaInfo?.id) + XCTAssertEqual("testName", mediaInfo?.name) + XCTAssertEqual(10.0, mediaInfo?.length) + XCTAssertEqual("aod", mediaInfo?.streamType) + XCTAssertEqual(MediaType.Audio, mediaInfo?.mediaType) + XCTAssertEqual(true, mediaInfo?.resumed) + XCTAssertEqual(2000, mediaInfo?.prerollWaitingTime) + XCTAssertEqual(true, mediaInfo?.granularAdTracking) + } + + func testMediaInfo_ConvenienceInit_MissingData() { + let requiredKeys = [ + MediaConstants.MediaInfo.ID, + MediaConstants.MediaInfo.NAME, + MediaConstants.MediaInfo.STREAM_TYPE, + MediaConstants.MediaInfo.MEDIA_TYPE, + MediaConstants.MediaInfo.LENGTH + ] + + for key in requiredKeys { + var info = MediaObjectTests.validMediaInfo + info.removeValue(forKey: key) + XCTAssertNil(MediaInfo(info: info)) + } + } + + func testMediaInfo_Init_Valid_WithAllRequiredParams_EmptyIdValue() { + let mediaInfo = MediaInfo(id: "", name: "testName", streamType: "aod", mediaType: MediaType.Audio, length: 10.1) + XCTAssertNil(mediaInfo) + } + + func testMediaInfo_ConvenienceInit_InvalidID() { + for v in MediaObjectTests.valuesOtherThanString { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.ID] = v + XCTAssertNil(MediaInfo(info: info)) + } + } + + func testMediaInfo_Init_Valid_WithAllRequiredParams_EmptyNameValue() { + let mediaInfo = MediaInfo(id: "testId", name: "", streamType: "aod", mediaType: MediaType.Audio, length: 10.1) + XCTAssertNil(mediaInfo) + } + + func testMediaInfo_ConvenienceInit_InvalidName() { + for v in MediaObjectTests.valuesOtherThanString { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.NAME] = v + XCTAssertNil(MediaInfo(info: info)) + } + } + + func testMediaInfo_Init_Valid_WithAllRequiredParams_InvalidLength() { + for v in MediaObjectTests.numberLessThan0 { + let mediaInfo = MediaInfo(id: "testId", name: "testName", streamType: "", mediaType: MediaType.Audio, length: v) + XCTAssertNil(mediaInfo) + } + + } + + func testMediaInfo_ConvenienceInit_InvalidLength() { + for v in MediaObjectTests.valuesOtherThanDouble { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.LENGTH] = v + XCTAssertNil(MediaInfo(info: info)) + } + + for v in MediaObjectTests.numberLessThan0 { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.LENGTH] = v + XCTAssertNil(MediaInfo(info: info)) + } + } + + func testMediaInfo_Init_Valid_WithAllRequiredParams_EmptyStreamTypeValue() { + let mediaInfo = MediaInfo(id: "testId", name: "testName", streamType: "", mediaType: MediaType.Audio, length: 10.1) + XCTAssertNil(mediaInfo) + } + + func testMediaInfo_ConvenienceInit_InvalidStreamType() { + for v in MediaObjectTests.valuesOtherThanString { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.STREAM_TYPE] = v + XCTAssertNil(MediaInfo(info: info)) + } + } + + func testMediaInfo_ConvenienceInit_InvalidMediaType() { + // non empty string other than audio or video is not valid + for v in MediaObjectTests.values { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.MEDIA_TYPE] = v + XCTAssertNil(MediaInfo(info: info)) + } + } + + func testMediaInfo_ConvenienceInit_InvalidResumed() { + for v in MediaObjectTests.valuesOtherThanBool { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.RESUMED] = v + let mediaInfo = MediaInfo(info: info) + XCTAssertFalse(mediaInfo?.resumed ?? true) + } + } + + func testMediaInfo_ConvenienceInit_InvalidPrerollTrackingWaitTime() { + for v in MediaObjectTests.valuesOtherThanInt { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME] = v + let mediaInfo = MediaInfo(info: info) + XCTAssertEqual(MediaInfo.DEFAULT_PREROLL_WAITING_TIME_IN_MS, mediaInfo?.prerollWaitingTime) + } + } + + func testMediaInfo_ConvenienceInit_InvalidGranularAdTracking() { + for v in MediaObjectTests.valuesOtherThanBool { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.GRANULAR_AD_TRACKING] = v + let mediaInfo = MediaInfo(info: info) + XCTAssertFalse(mediaInfo?.granularAdTracking ?? true) + } + } + + func testCreateMediaObjectWithGranularAdTrackingValueCustom() { + let mediaInfo = MediaInfo(id: "id", name: name, streamType: "vod", mediaType: MediaType.Audio, length: 60.0, prerollWaitingTime: 2000) + + XCTAssertEqual(2000, mediaInfo?.prerollWaitingTime) + } + + func testCreateMediaObjectWithGranularAdTrackingValueDisabled() { + let mediaInfo = MediaInfo(id: "id", name: name, streamType: "vod", mediaType: MediaType.Audio, length: 60.0, granularAdTracking: false) + + XCTAssertFalse(mediaInfo?.granularAdTracking ?? true) + } + + func testCreateMediaObjectWithGranularAdTrackingEnabled() { + let mediaInfo = MediaInfo(id: "id", name: name, streamType: "vod", mediaType: MediaType.Audio, length: 60.0, granularAdTracking: true) + + XCTAssertTrue(mediaInfo?.granularAdTracking ?? false) + } + + func testMediaInfoValidMediaType() { + var info = MediaObjectTests.validMediaInfo + let audioInfo = MediaInfo(info: info) + + XCTAssertEqual(MediaType.Audio, audioInfo?.mediaType ?? MediaType.Video) + + info[MediaConstants.MediaInfo.MEDIA_TYPE] = MediaType.Video.rawValue + + let videoInfo = MediaInfo(info: info) + + XCTAssertEqual(MediaType.Video, videoInfo?.mediaType ?? MediaType.Audio) + } + + func testMediaInfoEqual() { + let mediaInfo1 = MediaInfo(info: MediaObjectTests.validMediaInfo) + + let mediaInfo2 = MediaInfo(id: "testId", name: "testName", streamType: "aod", mediaType: MediaType.Audio, length: 10.0, resumed: true, prerollWaitingTime: 2000, granularAdTracking: true) + + XCTAssertEqual(mediaInfo1, mediaInfo2) + } + + func testMediaInfoToMap() { + let mediaInfo = MediaInfo(info: MediaObjectTests.validMediaInfo) + let mediaInfoMap = mediaInfo?.toMap() + + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.ID] as! String, mediaInfoMap?[MediaConstants.MediaInfo.ID] as? String ?? "") + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.NAME] as! String, mediaInfoMap?[MediaConstants.MediaInfo.NAME] as? String ?? "") + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.LENGTH] as! Double, mediaInfoMap?[MediaConstants.MediaInfo.LENGTH] as? Double ?? 0.0) + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.STREAM_TYPE] as! String, mediaInfoMap?[MediaConstants.MediaInfo.STREAM_TYPE] as? String ?? "") + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.MEDIA_TYPE] as! String, mediaInfoMap?[MediaConstants.MediaInfo.MEDIA_TYPE] as? String ?? "") + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.RESUMED] as! Bool, mediaInfoMap?[MediaConstants.MediaInfo.RESUMED] as? Bool ?? false) + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME] as! Int, mediaInfoMap?[MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME] as? Int ?? 1000) + XCTAssertEqual(Self.validMediaInfo[MediaConstants.MediaInfo.GRANULAR_AD_TRACKING] as! Bool, mediaInfoMap?[MediaConstants.MediaInfo.GRANULAR_AD_TRACKING] as? Bool ?? false) + } + + // ========================================================================== + // AdbreakInfo + // ========================================================================== + func testAdbreakInfo_ConvenienceInit_NilInfo() { + XCTAssertNil(AdBreakInfo(info: nil)) + } + + func testAdBreakInfo_Init_Valid_WithAllRequiredParams() { + let adBreakInfo = AdBreakInfo(name: "adbreakName", position: 3, startTime: 5.5) + XCTAssertNotNil(adBreakInfo) + + XCTAssertEqual("adbreakName", adBreakInfo?.name) + XCTAssertEqual(3, adBreakInfo?.position) + XCTAssertEqual(5.5, adBreakInfo?.startTime) + } + + func testAdBreakInfo_ConvenienceInit_Valid() { + let adBreakInfo = AdBreakInfo(info: MediaObjectTests.validAdbreakInfo) + + XCTAssertNotNil(adBreakInfo) + XCTAssertEqual("Adbreakname", adBreakInfo?.name) + XCTAssertEqual(1, adBreakInfo?.position) + XCTAssertEqual(0.0, adBreakInfo?.startTime) + } + + func testAdBreakInfo_ConvenienceInit_MissingData() { + let requiredKeys = [ + MediaConstants.AdBreakInfo.NAME, + MediaConstants.AdBreakInfo.POSITION, + MediaConstants.AdBreakInfo.START_TIME + ] + + for key in requiredKeys { + var info = MediaObjectTests.validAdbreakInfo + info.removeValue(forKey: key) + XCTAssertNil(AdBreakInfo(info: info)) + } + } + + func testAdBreakInfo_Init_Valid_WithAllRequiredParams_EmptyNameValue() { + let adBreakInfo = AdBreakInfo(name: "", position: 3, startTime: 5.5) + XCTAssertNil(adBreakInfo) + } + + func testAdBreakInfoInvalidName() { + for v in MediaObjectTests.valuesOtherThanString { + var info = MediaObjectTests.validAdbreakInfo + info[MediaConstants.AdBreakInfo.NAME] = v + XCTAssertNil(AdBreakInfo(info: info)) + } + } + + func testAdBreakInfo_Init_Valid_WithAllRequiredParams_InvalidPosition() { + for v in MediaObjectTests.numberLessThan0Int { + let adBreakInfo = AdBreakInfo(name: "adName", position: v, startTime: 5.5) + XCTAssertNil(adBreakInfo) + } + } + + func testMediaInfoInvalidPosition() { + for v in MediaObjectTests.numberLessThan1 { + var info = MediaObjectTests.validAdbreakInfo + info[MediaConstants.AdBreakInfo.POSITION] = v + XCTAssertNil(AdBreakInfo(info: info)) + } + + for v2 in MediaObjectTests.valuesOtherThanInt { + var info = MediaObjectTests.validAdbreakInfo + info[MediaConstants.AdBreakInfo.POSITION] = v2 + XCTAssertNil(AdBreakInfo(info: info)) + } + } + + func testAdBreakInfo_Init_Valid_WithAllRequiredParams_InvalidStartTime() { + for v in MediaObjectTests.numberLessThan0 { + let adBreakInfo = AdBreakInfo(name: "adName", position: 2, startTime: v) + XCTAssertNil(adBreakInfo) + } + } + + func testMediaInfoInvalidStartTime() { + + for v in MediaObjectTests.valuesOtherThanDouble { + var info = MediaObjectTests.validAdbreakInfo + info[MediaConstants.AdBreakInfo.START_TIME] = v + XCTAssertNil(AdBreakInfo(info: info)) + } + + for v2 in MediaObjectTests.numberLessThan0 { + var info = MediaObjectTests.validAdbreakInfo + info[MediaConstants.AdBreakInfo.START_TIME] = v2 + XCTAssertNil(AdBreakInfo(info: info)) + } + } + + func testAdBreakInfoEqual() { + let adBreakInfo1 = AdBreakInfo(info: MediaObjectTests.validAdbreakInfo) + + let adBreakInfo2 = AdBreakInfo(name: "Adbreakname", position: 1, startTime: 0.0) + + XCTAssertEqual(adBreakInfo1, adBreakInfo2) + } + + func testAdBreakInfoToMap() { + let adBreakInfo = AdBreakInfo(info: MediaObjectTests.validAdbreakInfo) + let adBreakInfoMap = adBreakInfo?.toMap() + + XCTAssertEqual(Self.validAdbreakInfo[MediaConstants.AdBreakInfo.NAME] as! String, adBreakInfoMap?[MediaConstants.AdBreakInfo.NAME] as? String ?? "") + XCTAssertEqual(Self.validAdbreakInfo[MediaConstants.AdBreakInfo.POSITION] as! Int, adBreakInfoMap?[MediaConstants.AdBreakInfo.POSITION] as? Int ?? 0) + XCTAssertEqual(Self.validAdbreakInfo[MediaConstants.AdBreakInfo.START_TIME] as! Double, adBreakInfoMap?[MediaConstants.AdBreakInfo.POSITION] as? Double ?? 0.0) + } + + // ========================================================================== + // AdInfo + // ========================================================================== + func testAdInfo_ConvenienceInfo_NilInfo() { + XCTAssertNil(AdInfo(info: nil)) + } + + func testAdInfo_Init_Valid_WithAllRequiredParams() { + let adInfo = AdInfo(id: "testID", name: "adName", position: 3, length: 30.0) + XCTAssertNotNil(adInfo) + + XCTAssertEqual("testID", adInfo?.id) + XCTAssertEqual("adName", adInfo?.name) + XCTAssertEqual(3, adInfo?.position) + XCTAssertEqual(30.0, adInfo?.length) + } + + func testAdInfo_ConvenienceInit_Valid() { + let adInfo = AdInfo(info: MediaObjectTests.validAdInfo) + + XCTAssertNotNil(adInfo) + XCTAssertEqual("AdID", adInfo?.id) + XCTAssertEqual("AdName", adInfo?.name) + XCTAssertEqual(1, adInfo?.position) + XCTAssertEqual(2.5, adInfo?.length) + } + + func testAdInfo_ConvenienceInit_MissingData() { + let requiredKeys = [ + MediaConstants.AdInfo.ID, + MediaConstants.AdInfo.NAME, + MediaConstants.AdInfo.POSITION, + MediaConstants.AdInfo.LENGTH + ] + + for key in requiredKeys { + var info = MediaObjectTests.validAdInfo + info.removeValue(forKey: key) + XCTAssertNil(AdInfo(info: info)) + } + } + + func testAdInfo_Init_Valid_WithAllRequiredParams_EmptyIdValue() { + let adInfo = AdInfo(id: "", name: "adTestName", position: 5, length: 60.0) + XCTAssertNil(adInfo) + } + + func testAdInfo_ConvenienceInit_InvalidId() { + for v in MediaObjectTests.valuesOtherThanString { + var info = MediaObjectTests.validAdInfo + info[MediaConstants.AdInfo.ID] = v + XCTAssertNil(AdInfo(info: info)) + } + } + + func testAdInfo_Init_Valid_WithAllRequiredParams_EmptyNameValue() { + let adInfo = AdInfo(id: "AdId", name: "", position: 5, length: 60.0) + XCTAssertNil(adInfo) + } + + func testAdInfo_ConvenienceInit_InvalidName() { + for v in MediaObjectTests.valuesOtherThanString { + var info = MediaObjectTests.validAdInfo + info[MediaConstants.AdInfo.NAME] = v + XCTAssertNil(AdInfo(info: info)) + } + } + + func testAdInfo_ConvenienceInit_InvalidPosition() { + for v in MediaObjectTests.valuesOtherThanInt { + var adinfo = MediaObjectTests.validAdInfo + adinfo[MediaConstants.AdInfo.POSITION] = v + XCTAssertNil(AdInfo(info: adinfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var info = MediaObjectTests.validMediaInfo + info[MediaConstants.MediaInfo.LENGTH] = v + XCTAssertNil(AdInfo(info: info)) + } + } + + func testAdInfo_Init_Valid_WithAllRequiredParams_InvalidLength() { + for v in MediaObjectTests.numberLessThan0 { + let adInfo = AdInfo(id: "AdId", name: "adName", position: 5, length: v) + XCTAssertNil(adInfo) + } + } + + func testAdInfo_ConvenienceInit_InvalidLength() { + for v in MediaObjectTests.valuesOtherThanDouble { + var adinfo = MediaObjectTests.validAdInfo + adinfo[MediaConstants.AdInfo.LENGTH] = v + XCTAssertNil(AdInfo(info: adinfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var adinfo = MediaObjectTests.validAdInfo + adinfo[MediaConstants.AdInfo.LENGTH] = v + XCTAssertNil(AdInfo(info: adinfo)) + } + } + + func testAdInfoEqual() { + let adInfo1 = AdInfo(info: MediaObjectTests.validAdInfo) + + let adInfo2 = AdInfo(id: "AdID", name: "AdName", position: 1, length: 2.5) + + XCTAssertEqual(adInfo1, adInfo2) + } + + func testAdInfoToMap() { + let adInfo = AdInfo(info: MediaObjectTests.validAdInfo) + let adInfoMap = adInfo?.toMap() + + XCTAssertEqual(Self.validAdInfo[MediaConstants.AdInfo.ID] as! String, adInfoMap?[MediaConstants.AdInfo.ID] as? String ?? "") + XCTAssertEqual(Self.validAdInfo[MediaConstants.AdInfo.NAME] as! String, adInfoMap?[MediaConstants.AdInfo.NAME] as? String ?? "") + XCTAssertEqual(Self.validAdInfo[MediaConstants.AdInfo.POSITION] as! Int, adInfoMap?[MediaConstants.AdInfo.POSITION] as? Int ?? 0) + XCTAssertEqual(Self.validAdInfo[MediaConstants.AdInfo.LENGTH] as! Double, adInfoMap?[MediaConstants.AdInfo.LENGTH] as? Double ?? 0.0) + } + + // ========================================================================== + // ChapterInfo + // ========================================================================== + func testChapterInfo_ConvenienceInfo_NilInfo() { + XCTAssertNil(ChapterInfo(info: nil)) + } + + func testChapterInfo_Init_Valid_WithAllRequiredParams() { + let chapterInfo = ChapterInfo(name: "chapterName", position: 2, startTime: 5.0, length: 60.0) + XCTAssertNotNil(chapterInfo) + + XCTAssertEqual("chapterName", chapterInfo?.name) + XCTAssertEqual(2, chapterInfo?.position) + XCTAssertEqual(5.0, chapterInfo?.startTime) + XCTAssertEqual(60.0, chapterInfo?.length) + } + + func testChapterInfo_ConvenienceInit_Valid() { + let chapterInfo = ChapterInfo(info: MediaObjectTests.validChapterInfo) + + XCTAssertNotNil(chapterInfo) + XCTAssertEqual("ChapterName", chapterInfo?.name) + XCTAssertEqual(3, chapterInfo?.position) + XCTAssertEqual(3.0, chapterInfo?.startTime) + XCTAssertEqual(5.0, chapterInfo?.length) + } + + func testChapterInfo_ConvenienceInit_MissingData() { + let requiredKeys = [ + MediaConstants.ChapterInfo.NAME, + MediaConstants.ChapterInfo.POSITION, + MediaConstants.ChapterInfo.START_TIME, + MediaConstants.ChapterInfo.LENGTH + ] + + for key in requiredKeys { + var info = MediaObjectTests.validChapterInfo + info.removeValue(forKey: key) + XCTAssertNil(ChapterInfo(info: info)) + } + } + + func testChapterInfo_Init_Valid_WithAllRequiredParams_EmptyNameValue() { + let chapterInfo = ChapterInfo(name: "", position: 2, startTime: 5.0, length: 60.0) + XCTAssertNil(chapterInfo) + } + + func testChapterInfo_ConvenienceInit_InvalidName() { + for v in MediaObjectTests.valuesOtherThanString { + var info = MediaObjectTests.validChapterInfo + info[MediaConstants.ChapterInfo.NAME] = v + XCTAssertNil(ChapterInfo(info: info)) + } + } + + func testChapterInfo_ConvenienceInit_InvalidPosition() { + for v in MediaObjectTests.valuesOtherThanDouble { + var chapterInfo = MediaObjectTests.validAdInfo + chapterInfo[MediaConstants.ChapterInfo.POSITION] = v + XCTAssertNil(ChapterInfo(info: chapterInfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var info = MediaObjectTests.validChapterInfo + info[MediaConstants.ChapterInfo.POSITION] = v + XCTAssertNil(ChapterInfo(info: info)) + } + } + + func testChapterInfo_Init_Valid_WithAllRequiredParams_InvalidStartTimeValue() { + for v in MediaObjectTests.numberLessThan0 { + let chapterInfo = ChapterInfo(name: "chapterName", position: 2, startTime: v, length: 60.0) + XCTAssertNil(chapterInfo) + } + } + + func testChapterInfo_ConvenienceInit_InvalidStartTimeValue() { + for v in MediaObjectTests.valuesOtherThanDouble { + var chapterinfo = MediaObjectTests.validChapterInfo + chapterinfo[MediaConstants.ChapterInfo.START_TIME] = v + XCTAssertNil(ChapterInfo(info: chapterinfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var chapterinfo = MediaObjectTests.validAdInfo + chapterinfo[MediaConstants.ChapterInfo.START_TIME] = v + XCTAssertNil(ChapterInfo(info: chapterinfo)) + } + } + + func testChapterInfo_Init_Valid_WithAllRequiredParams_InvalidLength() { + for v in MediaObjectTests.numberLessThan0 { + let chapterInfo = ChapterInfo(name: "chapterName", position: 2, startTime: v, length: 60.0) + XCTAssertNil(chapterInfo) + } + } + + func testChapterInfo_ConvenienceInit_InvalidLength() { + for v in MediaObjectTests.valuesOtherThanDouble { + var chapterinfo = MediaObjectTests.validChapterInfo + chapterinfo[MediaConstants.ChapterInfo.LENGTH] = v + XCTAssertNil(ChapterInfo(info: chapterinfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var chapterinfo = MediaObjectTests.validChapterInfo + chapterinfo[MediaConstants.ChapterInfo.LENGTH] = v + XCTAssertNil(ChapterInfo(info: chapterinfo)) + } + } + + func testChapterInfoEqual() { + let chapterInfo1 = ChapterInfo(info: MediaObjectTests.validChapterInfo) + + let chapterInfo2 = ChapterInfo(name: "ChapterName", position: 3, startTime: 3.0, length: 5.0) + + XCTAssertEqual(chapterInfo1, chapterInfo2) + } + + func testChapterInfoToMap() { + let chapterInfo = ChapterInfo(info: MediaObjectTests.validChapterInfo) + let chapterInfoMap = chapterInfo?.toMap() + + XCTAssertEqual(Self.validChapterInfo[MediaConstants.ChapterInfo.NAME] as! String, chapterInfoMap?[MediaConstants.ChapterInfo.NAME] as? String ?? "") + XCTAssertEqual(Self.validChapterInfo[MediaConstants.ChapterInfo.POSITION] as! Int, chapterInfoMap?[MediaConstants.ChapterInfo.POSITION] as? Int ?? 0) + XCTAssertEqual(Self.validChapterInfo[MediaConstants.ChapterInfo.START_TIME] as! Double, chapterInfoMap?[MediaConstants.ChapterInfo.START_TIME] as? Double ?? 0.0) + XCTAssertEqual(Self.validChapterInfo[MediaConstants.ChapterInfo.LENGTH] as! Double, chapterInfoMap?[MediaConstants.ChapterInfo.LENGTH] as? Double ?? 0.0) + } + + // ========================================================================== + // QoEInfo + // ========================================================================== + func testQoEInfo_ConvenienceInfo_NilInfo() { + XCTAssertNil(QoEInfo(info: nil)) + } + + func testQoEInfo_Init_Valid_WithAllRequiredParams() { + let qoeInfo = QoEInfo(bitrate: 30.0, droppedFrames: 20.0, fps: 5.0, startupTime: 10.0) + XCTAssertNotNil(qoeInfo) + + XCTAssertEqual(30.0, qoeInfo?.bitrate) + XCTAssertEqual(20.0, qoeInfo?.droppedFrames) + XCTAssertEqual(5.0, qoeInfo?.fps) + XCTAssertEqual(10.0, qoeInfo?.startupTime) + } + + func testQoEInfo_ConvenienceInit_Valid() { + let qoeInfo = QoEInfo(info: MediaObjectTests.validQoEInfo) + XCTAssertNotNil(qoeInfo) + + XCTAssertEqual(24.0, qoeInfo?.bitrate) + XCTAssertEqual(2.0, qoeInfo?.droppedFrames) + XCTAssertEqual(30.0, qoeInfo?.fps) + XCTAssertEqual(0.0, qoeInfo?.startupTime) + } + + func testQoEInfo_ConvenienceInit_MissingData() { + let requiredKeys = [ + MediaConstants.QoEInfo.BITRATE, + MediaConstants.QoEInfo.DROPPED_FRAMES, + MediaConstants.QoEInfo.FPS, + MediaConstants.QoEInfo.STARTUP_TIME + ] + + for key in requiredKeys { + var info = MediaObjectTests.validQoEInfo + info.removeValue(forKey: key) + XCTAssertNil(QoEInfo(info: info)) + } + } + + func testQoEInfo_Init_Valid_WithAllRequiredParams_InvalidBitrare() { + for v in MediaObjectTests.numberLessThan0 { + let qoeInfo = QoEInfo(bitrate: v, droppedFrames: 20.0, fps: 5.0, startupTime: 10.0) + XCTAssertNil(qoeInfo) + } + } + + func testQoEInfo_ConvenienceInit_InvalidBitrate() { + for v in MediaObjectTests.valuesOtherThanDouble { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.BITRATE] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.BITRATE] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + } + + func testQoEInfo_Init_Valid_WithAllRequiredParams_InvalidDroppedFramaes() { + for v in MediaObjectTests.numberLessThan0 { + let qoeInfo = QoEInfo(bitrate: 30.0, droppedFrames: v, fps: 5.0, startupTime: 10.0) + XCTAssertNil(qoeInfo) + } + } + + func testQoEInfo_ConvenienceInit_InvalidDroppedFrames() { + for v in MediaObjectTests.valuesOtherThanDouble { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.DROPPED_FRAMES] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.DROPPED_FRAMES] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + } + + func testQoEInfo_Init_Valid_WithAllRequiredParams_InvalidFPS() { + for v in MediaObjectTests.numberLessThan0 { + let qoeInfo = QoEInfo(bitrate: 30.0, droppedFrames: 20.0, fps: v, startupTime: 10.0) + XCTAssertNil(qoeInfo) + } + } + + func testQoEInfo_ConvenienceInit_InvalidDroppedFPS() { + for v in MediaObjectTests.valuesOtherThanDouble { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.FPS] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.FPS] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + } + + func testQoEInfo_Init_Valid_WithAllRequiredParams_InvalidStartupTime() { + for v in MediaObjectTests.numberLessThan0 { + let qoeInfo = QoEInfo(bitrate: 30.0, droppedFrames: 20.0, fps: 5.0, startupTime: v) + XCTAssertNil(qoeInfo) + } + } + + func testQoEInfo_ConvenienceInit_InvalidStartupTime() { + for v in MediaObjectTests.valuesOtherThanDouble { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.STARTUP_TIME] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + + for v in MediaObjectTests.numberLessThan0 { + var qoeInfo = MediaObjectTests.validQoEInfo + qoeInfo[MediaConstants.QoEInfo.STARTUP_TIME] = v + XCTAssertNil(QoEInfo(info: qoeInfo)) + } + } + + func testQoEInfoEqual() { + let qoeInfo1 = QoEInfo(info: MediaObjectTests.validQoEInfo) + + let qoeInfo2 = QoEInfo(bitrate: 24.0, droppedFrames: 2.0, fps: 30.0, startupTime: 0.0) + + XCTAssertEqual(qoeInfo1, qoeInfo2) + } + + func testQoEInfoToMap() { + let qoeInfo = QoEInfo(info: MediaObjectTests.validQoEInfo) + let qoeInfoMap = qoeInfo?.toMap() + + XCTAssertEqual(Self.validQoEInfo[MediaConstants.QoEInfo.BITRATE] as! Double, qoeInfoMap?[MediaConstants.QoEInfo.BITRATE] as? Double ?? 0.0) + XCTAssertEqual(Self.validQoEInfo[MediaConstants.QoEInfo.DROPPED_FRAMES] as! Double, qoeInfoMap?[MediaConstants.QoEInfo.DROPPED_FRAMES] as? Double ?? 0.0) + XCTAssertEqual(Self.validQoEInfo[MediaConstants.QoEInfo.FPS] as! Double, qoeInfoMap?[MediaConstants.QoEInfo.FPS] as? Double ?? 0.0) + XCTAssertEqual(Self.validQoEInfo[MediaConstants.QoEInfo.STARTUP_TIME] as! Double, qoeInfoMap?[MediaConstants.QoEInfo.STARTUP_TIME] as? Double ?? 0.0) + } + + // ========================================================================== + // StateInfo + // ========================================================================== + func testStateInfo_NilInfo() { + XCTAssertNil(StateInfo(info: nil)) + } + + func testStateInfo_Init_Valid_WithAllRequiredParams() { + let stateInfo = StateInfo(stateName: "fullscreen") + XCTAssertNotNil(stateInfo) + + XCTAssertEqual("fullscreen", stateInfo?.stateName) + } + + func testStateInfo_ConvenienceInit_Valid() { + let stateInfo = StateInfo(info: MediaObjectTests.validStateInfo) + + XCTAssertNotNil(stateInfo) + XCTAssertEqual("fullscreen._", stateInfo?.stateName) + } + + func testStateInfo_ConvenienceInit_Valid64LongValue() { + let stateInfo = StateInfo(info: MediaObjectTests.validStateInfo64Long) + + XCTAssertNotNil(stateInfo) + XCTAssertEqual("1234567890123456789012345678901234567890123456789012345678901234", stateInfo?.stateName) + } + + func testStateInfo_ConvenienceInit_MissingData() { + let requiredKeys = [ + MediaConstants.StateInfo.STATE_NAME_KEY + ] + + for key in requiredKeys { + var info = MediaObjectTests.validStateInfo + info.removeValue(forKey: key) + XCTAssertNil(StateInfo(info: info)) + } + } + + func testStateInfo_EmptyNameValue() { + let mediaInfo = StateInfo(stateName: "") + XCTAssertNil(mediaInfo) + } + + func testStateInfo_InvalidName() { + for v in MediaObjectTests.invalidStateName { + var info = MediaObjectTests.validStateInfo + info[MediaConstants.StateInfo.STATE_NAME_KEY] = v + XCTAssertNil(StateInfo(info: info)) + } + } + + func testStateInfoEqual() { + let stateInfo1 = StateInfo(info: MediaObjectTests.validStateInfo) + + let stateInfo2 = StateInfo(stateName: "fullscreen._") + + XCTAssertEqual(stateInfo1, stateInfo2) + } + + func testStateInfoToMap() { + let stateInfo = StateInfo(info: MediaObjectTests.validStateInfo) + let stateInfoMap = stateInfo?.toMap() + + XCTAssertEqual(Self.validStateInfo[MediaConstants.StateInfo.STATE_NAME_KEY] as! String, stateInfoMap?[MediaConstants.StateInfo.STATE_NAME_KEY] as? String ?? "") + } +} diff --git a/Tests/UnitTests/MediaPublicTrackerTests.swift b/Tests/UnitTests/MediaPublicTrackerTests.swift new file mode 100644 index 0000000..5da3a93 --- /dev/null +++ b/Tests/UnitTests/MediaPublicTrackerTests.swift @@ -0,0 +1,317 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaPublicTrackerTests: XCTestCase { + static let testConfig: [String: Any] = ["test": "value"] + static let metadata: [String: String] = ["key": "value"] + static let validMediaInfo: [String: Any] = [ + MediaConstants.MediaInfo.ID: "testId", + MediaConstants.MediaInfo.NAME: "testName", + MediaConstants.MediaInfo.LENGTH: 10.0, + MediaConstants.MediaInfo.STREAM_TYPE: "aod", + MediaConstants.MediaInfo.MEDIA_TYPE: "audio", + MediaConstants.MediaInfo.RESUMED: true, + MediaConstants.MediaInfo.PREROLL_TRACKING_WAITING_TIME: 2000.0, // 2000 milliseconds + MediaConstants.MediaInfo.GRANULAR_AD_TRACKING: true + ] + + static let validQoeInfo: [String: Any] = [ + MediaConstants.QoEInfo.BITRATE: 1.1, + MediaConstants.QoEInfo.DROPPED_FRAMES: 2.2, + MediaConstants.QoEInfo.FPS: 3.3, + MediaConstants.QoEInfo.STARTUP_TIME: 4.4 + ] + + static let validAdBreakInfo: [String: Any] = [ + MediaConstants.AdBreakInfo.NAME: "testAdBreakName", + MediaConstants.AdBreakInfo.POSITION: 2, + MediaConstants.AdBreakInfo.START_TIME: 1.1 + ] + + static let validAdInfo: [String: Any] = [ + MediaConstants.AdInfo.ID: "testAdId", + MediaConstants.AdInfo.NAME: "testAdName", + MediaConstants.AdInfo.POSITION: 1, + MediaConstants.AdInfo.LENGTH: 16.0 + ] + + static let validChapterInfo: [String: Any] = [ + MediaConstants.ChapterInfo.NAME: "testChapterName", + MediaConstants.ChapterInfo.POSITION: 1, + MediaConstants.ChapterInfo.START_TIME: 0.2, + MediaConstants.ChapterInfo.LENGTH: 30.0 + ] + + static let validStateInfo: [String: Any] = [ + MediaConstants.StateInfo.STATE_NAME_KEY: "testStateName" + ] + + func isEqual(map1: [String: Any]?, map2: [String: Any]?) -> Bool { + if map1 == nil && map2 == nil { + return true + } + + guard let map1 = map1, let map2 = map2 else { + return false + } + + guard map1.count == map2.count else { + return false + } + + for (k1, v1) in map1 { + guard let v2 = map2[k1] else { return false } + switch (v1, v2) { + case (let v1 as Double, let v2 as Double): if !v1.isAlmostEqual(v2) {return false} + case (let v1 as Int, let v2 as Int): if v1 != v2 { return false } + case (let v1 as String, let v2 as String): if v1 != v2 { return false } + case (let v1 as Bool, let v2 as Bool): if v1 != v2 { return false } + default: return false + } + } + return true + } + + func assertTrackEvent(event: Event?, expectedEventName: String, expectedParam: [String: Any] = [:], expectedMetadata: [String: Any] = [:], expectedTimestamp: Int64 = 0, expectedEventInternal: Bool = false) { + + guard let event = event else { + XCTFail("Event cannot be null!") + return + } + + XCTAssertEqual(event.source, MediaConstants.Media.EVENT_SOURCE_TRACK_MEDIA) + XCTAssertEqual(event.type, MediaConstants.Media.EVENT_TYPE) + + let actualEventName = event.data?[MediaConstants.Tracker.EVENT_NAME] as? String ?? "" + XCTAssertEqual(actualEventName, expectedEventName) + + let actualParam = event.data?[MediaConstants.Tracker.EVENT_PARAM] as? [String: Any] ?? [:] + XCTAssertTrue(isEqual(map1: actualParam, map2: expectedParam)) + + let actualMetadata = event.data?[MediaConstants.Tracker.EVENT_METADATA] as? [String: Any] ?? [:] + XCTAssertTrue(isEqual(map1: actualMetadata, map2: expectedMetadata)) + + let actualTimestamp = event.data?[MediaConstants.Tracker.EVENT_TIMESTAMP] as? Int64 ?? 0 + XCTAssertEqual(actualTimestamp, expectedTimestamp) + + let actualEventInternal = event.data?[MediaConstants.Tracker.EVENT_INTERNAL] as? Bool ?? false + XCTAssertEqual(actualEventInternal, expectedEventInternal) + + XCTAssertFalse((event.data?[MediaConstants.Tracker.ID] as? String ?? "").isEmpty) + XCTAssertFalse((event.data?[MediaConstants.Tracker.SESSION_ID] as? String ?? "").isEmpty) + } + + // MARK: MediaTracker Unit Tests + // ========================================================================== + // create + // ========================================================================== + func testCreateTracker() { + var capturedEvent: Event? + _ = MediaPublicTracker(dispatch: {(event: Event) in + capturedEvent = event + }, config: nil) + + XCTAssertEqual(MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST, capturedEvent?.source) + XCTAssertEqual(MediaConstants.Media.EVENT_TYPE, capturedEvent?.type) + + let data = capturedEvent?.data + XCTAssertFalse((data?[MediaConstants.Tracker.ID] as? String ?? "").isEmpty) + + let actualParam = data?[MediaConstants.Tracker.EVENT_PARAM] as? [String: Any] ?? [:] + XCTAssertTrue(isEqual(map1: actualParam, map2: [:])) + } + + func testCreateTrackerWithConfig() { + var capturedEvent: Event? + _ = MediaPublicTracker(dispatch: {(event: Event) in + capturedEvent = event + }, config: Self.testConfig) + + XCTAssertEqual(MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST, capturedEvent?.source) + XCTAssertEqual(MediaConstants.Media.EVENT_TYPE, capturedEvent?.type) + + let data = capturedEvent?.data + XCTAssertFalse((data?[MediaConstants.Tracker.ID] as? String ?? "").isEmpty) + + let actualParam = data?[MediaConstants.Tracker.EVENT_PARAM] as? [String: Any] ?? [:] + XCTAssertTrue(isEqual(map1: actualParam, map2: Self.testConfig)) + } + + // ========================================================================== + // Event extension for Media + // ========================================================================== + + func testEventExtension_TrackerIdAndConfig() { + var capturedEvent: Event? + _ = MediaPublicTracker(dispatch: {(event: Event) in + capturedEvent = event + }, config: Self.testConfig) + + XCTAssertEqual(MediaConstants.Media.EVENT_SOURCE_TRACKER_REQUEST, capturedEvent?.source) + XCTAssertEqual(MediaConstants.Media.EVENT_TYPE, capturedEvent?.type) + + let trackerId = capturedEvent?.trackerId + XCTAssertFalse((trackerId ?? "").isEmpty) + + let trackerConfig = capturedEvent?.trackerConfig + XCTAssertTrue(isEqual(map1: trackerConfig, map2: Self.testConfig)) + } + + func testEventExtension_MissingTrackerIdAndConfig() { + let event = Event(name: "newEvent", type: EventType.custom, source: EventSource.none, data: nil) + + let trackerId = event.trackerId + XCTAssertNil(trackerId) + + let trackerConfig = event.trackerConfig + XCTAssertNil(trackerConfig) + } + + // ========================================================================== + // trackAPIs + // ========================================================================== + func test_trackSessionStart() { + let tracker = MediaEventGenerator() + tracker.trackSessionStart(info: Self.validMediaInfo) + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.SESSION_START, expectedParam: Self.validMediaInfo) + } + + func test_trackSessionStartWithMetadata() { + let tracker = MediaEventGenerator() + tracker.trackSessionStart(info: Self.validMediaInfo, metadata: Self.metadata) + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.SESSION_START, expectedParam: Self.validMediaInfo, expectedMetadata: Self.metadata) + } + + func test_trackComplete() { + let tracker = MediaEventGenerator() + tracker.setTimeStamp(value: 100) + tracker.trackComplete() + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.COMPLETE, expectedTimestamp: 100) + } + + func test_trackSessionEnd() { + let tracker = MediaEventGenerator() + tracker.setTimeStamp(value: 100) + tracker.trackSessionEnd() + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.SESSION_END, expectedTimestamp: 100) + } + + func test_trackPlay() { + let tracker = MediaEventGenerator() + tracker.trackPlay() + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.PLAY ) + } + + func test_trackPause() { + let tracker = MediaEventGenerator() + tracker.trackPause() + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.PAUSE ) + } + + func test_trackError() { + let tracker = MediaEventGenerator() + tracker.trackError(errorId: "testError") + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.ERROR, expectedParam: [ + MediaConstants.ErrorInfo.ID: "testError" + ]) + } + + func test_updateCurrentPlayhead() { + let tracker = MediaEventGenerator() + tracker.updateCurrentPlayhead(time: 1.23) + + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.PLAYHEAD_UPDATE, expectedParam: [ + MediaConstants.Tracker.PLAYHEAD: 1.23 + ]) + } + + func test_updateQoEObject() { + let tracker = MediaEventGenerator() + tracker.updateQoEObject(qoe: Self.validQoeInfo) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.QOE_UPDATE, expectedParam: Self.validQoeInfo) + } + + func test_trackAdBreak() { + let tracker = MediaEventGenerator() + + tracker.trackEvent(event: MediaEvent.AdBreakStart, info: Self.validAdBreakInfo, metadata: Self.metadata) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.ADBREAK_START, expectedParam: Self.validAdBreakInfo, expectedMetadata: Self.metadata) + + tracker.trackEvent(event: MediaEvent.AdBreakComplete) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.ADBREAK_COMPLETE) + } + + func test_trackAd() { + let tracker = MediaEventGenerator() + + tracker.trackEvent(event: MediaEvent.AdStart, info: Self.validAdInfo, metadata: Self.metadata) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.AD_START, expectedParam: Self.validAdInfo, expectedMetadata: Self.metadata) + + tracker.trackEvent(event: MediaEvent.AdComplete) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.AD_COMPLETE) + + tracker.trackEvent(event: MediaEvent.AdSkip) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.AD_SKIP) + } + + func test_trackChapter() { + let tracker = MediaEventGenerator() + + tracker.trackEvent(event: MediaEvent.ChapterStart, info: Self.validChapterInfo, metadata: Self.metadata) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.CHAPTER_START, expectedParam: Self.validChapterInfo, expectedMetadata: Self.metadata) + + tracker.trackEvent(event: MediaEvent.ChapterComplete) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.CHAPTER_COMPLETE) + + tracker.trackEvent(event: MediaEvent.ChapterSkip) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.CHAPTER_SKIP) + } + + func test_trackState() { + let tracker = MediaEventGenerator() + tracker.trackEvent(event: MediaEvent.StateStart, info: Self.validStateInfo) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.STATE_START, expectedParam: Self.validStateInfo) + + tracker.trackEvent(event: MediaEvent.StateEnd, info: Self.validStateInfo) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.STATE_END, expectedParam: Self.validStateInfo) + } + + func test_trackEvents() { + let tracker = MediaEventGenerator() + + tracker.trackEvent(event: MediaEvent.BufferStart) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.BUFFER_START) + + tracker.trackEvent(event: MediaEvent.BufferComplete) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.BUFFER_COMPLETE) + + tracker.trackEvent(event: MediaEvent.SeekStart) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.SEEK_START) + + tracker.trackEvent(event: MediaEvent.SeekComplete) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.SEEK_COMPLETE) + + tracker.trackEvent(event: MediaEvent.BitrateChange) + assertTrackEvent(event: tracker.dispatchedEvent, expectedEventName: MediaConstants.EventName.BITRATE_CHANGE) + } +} diff --git a/Tests/UnitTests/MediaRealTimeSessionTests.swift b/Tests/UnitTests/MediaRealTimeSessionTests.swift new file mode 100644 index 0000000..7d58dca --- /dev/null +++ b/Tests/UnitTests/MediaRealTimeSessionTests.swift @@ -0,0 +1,700 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaRealTimeSessionTests: XCTestCase { + + let dispatchQueue = DispatchQueue(label: "test.DispatchQueue") + var mediaState = MediaState() + var dispatchedEvents: [Event] = [] + static let trackerSessionId = "testTrackerSessionId" + + let errorResponseDataFromEdgeExtension: [String: Any] = ["status": Int64(400), "type": "https://ns.adobe.com/aep/errors/va-edge-0400-400"] + let errorResponseDataWithExtraFields: [String: Any] = ["status": Int64(400), "type": "https://ns.adobe.com/aep/errors/va-edge-0400-400", "extra": "error message"] + let invalidErrorResponses: [[String: Any]] = [ + [:], + ["status": Int64(500), "type": "https://ns.adobe.com/aep/errors/edge-0400-400"], + ["status": 400, "type": "https://ns.adobe.com/aep/errors/va-edge-0400-400"], + ["status": Int64(400), "type": "https://ns.adobe.com/aëp/ërrors/va-ëdgë-0400-400"], + ["status": Int64(500), "type": "https://ns.adobe.com/aep/errors/va-edge-0400-400"], + ["status": Int64(404), "type": "https://ns.adobe.com/aep/errors/va-edge-0400-400"], + ["type": "https://ns.adobe.com/aep/errors/va-edge-0400-400"], + ["status": Int64(400)] + ] + + var config = [MediaConstants.Configuration.MEDIA_CHANNEL: "testChannel", + MediaConstants.Configuration.MEDIA_APP_VERSION: "testAppVersion", + MediaConstants.Configuration.MEDIA_PLAYER_NAME: "testPlayerName"] + + func fakeDispatcher(_ event: Event) { + dispatchedEvents.append(event) + } + + func testTrackerSessionId_isValidStringWhenSetValidStringOnCreateSession() { + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + XCTAssertEqual("testTrackerSessionId", session.trackerSessionId) + } + + func testTrackerSessionId_isNilWhenSetNilOnCreateSession() { + let session = MediaRealTimeSession(id: "testId", trackerSessionId: nil, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + XCTAssertNil(session.trackerSessionId) + } + + func testQueueMediaEvents_withoutChannelConfig_doesNotDispatchEvent() { + // setup + mediaState.updateConfigurationSharedState([MediaConstants.Configuration.MEDIA_APP_VERSION: "testAppVersion", + MediaConstants.Configuration.MEDIA_PLAYER_NAME: "testPlayerName"]) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMDataHelper.getSessionStartData())) + + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.pauseStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(3, session.events.count) + } + + func testQueueMediaEvents_withoutPlayerNameConfig_doesNotDispatchEvent() { + // setup + mediaState.updateConfigurationSharedState([MediaConstants.Configuration.MEDIA_CHANNEL: "testChannel", MediaConstants.Configuration.MEDIA_APP_VERSION: "testAppVersion"]) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMDataHelper.getSessionStartData())) + + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.pauseStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(3, session.events.count) + } + + func testQueueSessionStart() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMDataHelper.getSessionStartData())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.sessionStart.edgeEventType(), expectedPath: "/va/v1/sessionStart") + } + + func testQueueMediaEvents_withoutBackendSessionId_doesNotDispatchEventsOtherThanSessionStart() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.pauseStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.statesUpdate, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.bufferStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.bitrateChange, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.error, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.ping, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adSkip, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adBreakStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adBreakComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.chapterStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.chapterSkip, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.chapterComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionEnd, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + XCTAssertEqual(17, session.events.count) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.sessionStart.edgeEventType(), expectedPath: "/va/v1/sessionStart") + } + + func testQueueMediaEvents_withoutBackendSessionId_dispatchesConsecutiveSessionStartEvents() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(4, dispatchedEvents.count) + XCTAssertEqual(0, session.events.count) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.sessionStart.edgeEventType(), expectedPath: "/va/v1/sessionStart") + } + + // The session start ping present at the top of the event queue will be dispatched but the other sessionStart events are blocked by low level events waiting for backendSessionId + func testQueueMediaEvents_withoutBackendSessionId_dispatchesSessionStartEventAtTopOfEventQueue() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + var mediaCollection1 = XDMMediaCollection() + mediaCollection1.sessionID = "session1" + + var mediaCollection2 = XDMMediaCollection() + mediaCollection2.sessionID = "session2" + + var mediaCollection3 = XDMMediaCollection() + mediaCollection3.sessionID = "session3" + + var mediaCollection4 = XDMMediaCollection() + mediaCollection4.sessionID = "session4" + + // test + // session start 1 + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: mediaCollection1)) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + // session start 2 + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: mediaCollection2)) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.pauseStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + // session start 3 + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: mediaCollection3)) + // session start 4 + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: mediaCollection4)) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + XCTAssertEqual(5, session.events.count) + assertBackendSessionId(expectedBackendSessionId: "session1", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.sessionStart.edgeEventType(), expectedPath: "/va/v1/sessionStart") + } + + func testQueue_sessionComplete() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.sessionComplete.edgeEventType(), expectedPath: "/va/v1/sessionComplete") + } + + func testQueue_sessionEnd() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionEnd, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.sessionEnd.edgeEventType(), expectedPath: "/va/v1/sessionEnd") + } + + func testQueue_play() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.play.edgeEventType(), expectedPath: "/va/v1/play") + } + + func testQueue_pause() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.pauseStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.pauseStart.edgeEventType(), expectedPath: "/va/v1/pauseStart") + } + + func testQueue_ping() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.ping, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.ping.edgeEventType(), expectedPath: "/va/v1/ping") + } + + func testQueue_error() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.error, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.error.edgeEventType(), expectedPath: "/va/v1/error") + } + + func testQueue_bufferStart() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.bufferStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.bufferStart.edgeEventType(), expectedPath: "/va/v1/bufferStart") + } + + func testQueue_bitrateChange() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.bitrateChange, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.bitrateChange.edgeEventType(), expectedPath: "/va/v1/bitrateChange") + } + + func testQueue_adBreakStart() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adBreakStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.adBreakStart.edgeEventType(), expectedPath: "/va/v1/adBreakStart") + } + + func testQueue_adBreakComplete() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adBreakComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.adBreakComplete.edgeEventType(), expectedPath: "/va/v1/adBreakComplete") + } + + func testQueue_adStart() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.adStart.edgeEventType(), expectedPath: "/va/v1/adStart") + } + + func testQueue_adSkip() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adSkip, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.adSkip.edgeEventType(), expectedPath: "/va/v1/adSkip") + } + + func testQueue_adComplete() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.adComplete.edgeEventType(), expectedPath: "/va/v1/adComplete") + } + + func testQueue_chapterSkip() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.chapterSkip, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.chapterSkip.edgeEventType(), expectedPath: "/va/v1/chapterSkip") + } + + func testQueue_chapterStart() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.chapterStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.chapterStart.edgeEventType(), expectedPath: "/va/v1/chapterStart") + } + + func testQueue_chapterComplete() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.chapterComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.chapterComplete.edgeEventType(), expectedPath: "/va/v1/chapterComplete") + } + + func testQueue_statesUpdate() { + // setup + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake backendSessionId + session.mediaBackendSessionId = "testBackendSessionId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.statesUpdate, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + // verify + XCTAssertEqual(1, dispatchedEvents.count) + assertBackendSessionId(expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + assertEventTypeAndPath(actualEvent: dispatchedEvents[0], expectedEventType: XDMMediaEventType.statesUpdate.edgeEventType(), expectedPath: "/va/v1/statesUpdate") + } + + func testHandleSessionUpdate_updatesBackendSessionIdAndDispatchesEvents() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.ping, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + session.handleSessionUpdate(requestEventId: "testSessionStartEdgeRequestId", backendSessionId: "testBackendSessionId") + + // verify + XCTAssertTrue(session.isSessionActive) + XCTAssertEqual(session.mediaBackendSessionId, "testBackendSessionId") + // 1 sessionCreated event and the 5 queued media events + XCTAssertEqual(6, dispatchedEvents.count) + assertSessionCreatedEvent(expectedTrackerSessionId: Self.trackerSessionId, expectedBackendSessionId: "testBackendSessionId", actualEvent: dispatchedEvents[0]) + XCTAssertEqual(0, session.events.count) + } + + func testHandleSessionUpdate_withDifferentEdgeRequestId_ignoresTheEventAndWaitsForBackendSessionIdToDispatchQueuedEvents() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.ping, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + session.handleSessionUpdate(requestEventId: "testDifferentSessionStartEdgeRequestId", backendSessionId: "testBackendSessionId") + + // verify + XCTAssertTrue(session.isSessionActive) + XCTAssertEqual(5, session.events.count) + } + + func testHandleSessionUpdate_withEmptyBackendId_abortsMediaSession() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.ping, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + session.handleSessionUpdate(requestEventId: "testSessionStartEdgeRequestId", backendSessionId: "") + + // verify + XCTAssertFalse(session.isSessionActive) + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(0, session.events.count) + } + + func testHandleSessionUpdate_withNilBackendId_abortsMediaSession() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adStart, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.adComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.ping, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.sessionComplete, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + session.handleSessionUpdate(requestEventId: "testSessionStartEdgeRequestId", backendSessionId: nil) + + // verify + XCTAssertFalse(session.isSessionActive) + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(0, session.events.count) + } + + func testHandleErrorResponse_withVAEdge400Error_abortsSession() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.handleErrorResponse(requestEventId: "testSessionStartEdgeRequestId", data: errorResponseDataFromEdgeExtension) + + // verify + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(0, session.events.count) + XCTAssertFalse(session.isSessionActive) + } + + func testHandleErrorResponse_withExtraFieldsInErrorData_abortsSession() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.handleErrorResponse(requestEventId: "testSessionStartEdgeRequestId", data: errorResponseDataWithExtraFields) + + // verify + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(0, session.events.count) + XCTAssertFalse(session.isSessionActive) + } + + func testHandleErrorResponse_withDifferentEdgeRequestIdAndValidError_doesNotAbortSession() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + session.handleErrorResponse(requestEventId: "testDifferentSessionStartEdgeRequestId", data: errorResponseDataFromEdgeExtension) + + // verify + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(1, session.events.count) + XCTAssertTrue(session.isSessionActive) + } + + func testHandleErrorResponse_withInvalidErrorData_doesNotAbortSession() { + mediaState.updateConfigurationSharedState(config) + let session = MediaRealTimeSession(id: "testId", trackerSessionId: Self.trackerSessionId, state: mediaState, dispatchQueue: dispatchQueue, dispatcher: fakeDispatcher) + + // set fake sessionStartEdgeRequestId + session.sessionStartEdgeRequestId = "testSessionStartEdgeRequestId" + + // test + session.queue(event: MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(1), mediaCollection: XDMMediaCollection())) + + for errorData in invalidErrorResponses { + session.handleErrorResponse(requestEventId: "testSessionStartEdgeRequestId", data: errorData) + } + + // verify + XCTAssertEqual(0, dispatchedEvents.count) + XCTAssertEqual(1, session.events.count) + XCTAssertTrue(session.isSessionActive) + } + + // Test Helper + + private func assertSessionCreatedEvent(expectedTrackerSessionId: String, expectedBackendSessionId: String, actualEvent: Event) { + XCTAssertEqual("Media::SessionCreated", actualEvent.name) + XCTAssertEqual("com.adobe.eventtype.edgemedia", actualEvent.type) + XCTAssertEqual("com.adobe.eventsource.edgemedia.sessioncreated", actualEvent.source) + + guard let data = actualEvent.data else { + XCTFail("Event data cannot be null for Media::SessionCreated event") + return + } + + XCTAssertEqual(expectedBackendSessionId, data["mediaservice.sessionid"] as? String ?? "") + XCTAssertEqual(expectedTrackerSessionId, data["sessionid"] as? String ?? "") + + } + + private func assertBackendSessionId(expectedBackendSessionId: String, actualEvent: Event) { + guard let eventData = actualEvent.data else { + XCTFail("Event data should not be null") + return + } + + guard let xdmData = eventData["xdm"] as? [String: Any] else { + XCTFail("XDM field for the event should not be null") + return + } + + guard let mediaCollection = xdmData["mediaCollection"] as? [String: Any] else { + XCTFail("MediaCollection field inside the XDM Data should not be null") + return + } + + XCTAssertEqual(expectedBackendSessionId, mediaCollection["sessionID"] as? String ?? "") + } + + private func assertEventTypeAndPath(actualEvent: Event, expectedEventType: String, expectedPath: String) { + guard let eventData = actualEvent.data else { + XCTFail("Event data should not be null") + return + } + + guard let xdmData = eventData["xdm"] as? [String: Any] else { + XCTFail("XDM field for the event should not be null") + return + } + + let actualEventType = xdmData["eventType"] as? String ?? "" + XCTAssertEqual(expectedEventType, actualEventType, "Expected eventType:(\(expectedEventType)) does not match the actual eventType:(\(actualEventType))") + + guard let requestData = eventData["request"] as? [String: String] else { + XCTFail("Request field for the event should not be null") + return + } + + let actualPath = requestData["path"] ?? "" + XCTAssertEqual(expectedPath, actualPath, "Expected path:(\(expectedPath)) does not match the actual eventType(\(actualPath))") + } + + private func getDateFormattedTimestampFor(_ value: Int64) -> Date { + return Date(timeIntervalSince1970: Double(value / 1000)) + } +} diff --git a/Tests/UnitTests/MediaRuleEngineTests.swift b/Tests/UnitTests/MediaRuleEngineTests.swift new file mode 100644 index 0000000..7a37681 --- /dev/null +++ b/Tests/UnitTests/MediaRuleEngineTests.swift @@ -0,0 +1,302 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class MediaRuleEngineTests: XCTestCase { + let RULE_NOT_FOUND_MSG = "Matching rule not found" + var action1CalledCount = 0 + var action2CalledCount = 0 + + override func setUp() { + } + + override func tearDown() { + reset() + } + + func reset() { + action1CalledCount = 0 + action2CalledCount = 0 + } + + func actionHelper1(rule: MediaRule, context: [String: Any]) { + action1CalledCount += 1 + } + + func actionHelper2(rule: MediaRule, context: [String: Any]) { + action2CalledCount += 1 + } + + // MARK: MediaRuleEngine Unit Tests + + // ========================================================================== + // addRule + // ========================================================================== + func testAddRule_Happy() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + + // test and verify + XCTAssertTrue(ruleEngine.add(rule: testRule)) + } + + func testAddDuplicateRule_Fail() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + + // test and verify + XCTAssertTrue(ruleEngine.add(rule: testRule)) + // Should fail when trying to add same rule + XCTAssertFalse(ruleEngine.add(rule: testRule)) + } + + // ========================================================================== + // processRule + // ========================================================================== + func testProcessRule_NoRuleFound_Fail() { + // setup + let ruleEngine = MediaRuleEngine() + + // test + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertFalse(res.0) + XCTAssertEqual(RULE_NOT_FOUND_MSG, res.1) + } + + func testProcessRule_NoPredicates() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + testRule.addAction { rule, context -> Bool in + self.actionHelper1(rule: rule, context: context) + return true + } + + // test + XCTAssertTrue(ruleEngine.add(rule: testRule)) + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertEqual(1, action1CalledCount) + XCTAssertTrue(res.0) + } + + func testProcessRule_NoAction() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + testRule.addPredicate(predicateFn: { _, _ -> Bool in + return true + }, expectedValue: true, errorMsg: "test error") + + // test + XCTAssertTrue(ruleEngine.add(rule: testRule)) + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertTrue(res.0) + } + + func testProcessRule_PredicateFailure() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + + // test + let test1 = "Test error 1" + testRule.addPredicate(predicateFn: { _, _ -> Bool in + return true + }, expectedValue: true, errorMsg: test1) + + let test2 = "Test error 2" + testRule.addPredicate(predicateFn: { _, _ -> Bool in + return true + }, expectedValue: false, errorMsg: test2) + + ruleEngine.add(rule: testRule) + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertFalse(res.0) + XCTAssertEqual(test2, res.1) + } + + func testProcessRule_ExecuteAction() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + + // test + let test1 = "Test error 1" + testRule.addPredicate(predicateFn: { _, _ -> Bool in + return true + }, expectedValue: true, errorMsg: test1) + + testRule.addAction { rule, context -> Bool in + self.actionHelper1(rule: rule, context: context) + return true + } + + testRule.addAction { rule, context -> Bool in + self.actionHelper1(rule: rule, context: context) + return true + } + + ruleEngine.add(rule: testRule) + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertEqual(2, action1CalledCount) + XCTAssertTrue(res.0) + } + + func testProcessRule_StopAfterFailingAction() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + + // test + let test1 = "Test error 1" + testRule.addPredicate(predicateFn: { _, _ -> Bool in + return true + }, expectedValue: true, errorMsg: test1) + + testRule.addAction { rule, context -> Bool in + self.actionHelper1(rule: rule, context: context) + return false + } + + testRule.addAction { rule, context -> Bool in + self.actionHelper2(rule: rule, context: context) + return true + } + + ruleEngine.add(rule: testRule) + + // test + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertEqual(1, action1CalledCount) + XCTAssertEqual(0, action2CalledCount) + XCTAssertTrue(res.0) + } + + func testProcessRule_EnterExitAction() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + + // test + testRule.addPredicate(predicateFn: { _, _ -> Bool in + return true + }, expectedValue: true, errorMsg: "test1") + + testRule.addAction { _, _ -> Bool in + return true + } + + ruleEngine.add(rule: testRule) + + ruleEngine.onEnterRule { rule, context -> Bool in + self.actionHelper1(rule: rule, context: context) + return true + } + + ruleEngine.onExitRule { rule, context -> Bool in + self.actionHelper2(rule: rule, context: context) + return true + } + + // test + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertEqual(1, action1CalledCount) + XCTAssertEqual(1, action2CalledCount) + XCTAssertTrue(res.0) + } + + func testProcessRule_StopAfterFailingEnterAction() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + + // test + testRule.addPredicate(predicateFn: { _, _ -> Bool in + return true + }, expectedValue: true, errorMsg: "test1") + + testRule.addAction { rule, context -> Bool in + // should not be called + self.actionHelper2(rule: rule, context: context) + return true + } + + ruleEngine.add(rule: testRule) + + ruleEngine.onEnterRule { rule, context -> Bool in + // should be called + self.actionHelper1(rule: rule, context: context) + return false + } + + ruleEngine.onExitRule { rule, context -> Bool in + // should not be called + self.actionHelper2(rule: rule, context: context) + return true + } + + // test + let res = ruleEngine.processRule(name: 1, context: [:]) + + // verify + XCTAssertEqual(1, action1CalledCount) + XCTAssertEqual(0, action2CalledCount) + XCTAssertTrue(res.0) + } + + func testProcessRule_PassContextData() { + // setup + let ruleEngine = MediaRuleEngine() + let testRule = MediaRule(name: 1, description: "rule1") + let contextData: [String: Any] = ["k1": "v1"] + + // test + let test1 = "Test error 1" + testRule.addPredicate(predicateFn: { _, context -> Bool in + XCTAssertNotNil(context["k1"]) + XCTAssertEqual("v1", context["k1"] as? String ?? "") + return true + }, expectedValue: true, errorMsg: test1) + + testRule.addAction { rule, context -> Bool in + XCTAssertNotNil(context["k1"]) + XCTAssertEqual("v1", context["k1"] as? String ?? "") + self.actionHelper1(rule: rule, context: context) + return true + } + + ruleEngine.add(rule: testRule) + let res = ruleEngine.processRule(name: 1, context: contextData) + + // verify + XCTAssertEqual(1, action1CalledCount) + XCTAssertTrue(res.0) + } +} diff --git a/Tests/UnitTests/MediaStateTests.swift b/Tests/UnitTests/MediaStateTests.swift new file mode 100644 index 0000000..a3d9031 --- /dev/null +++ b/Tests/UnitTests/MediaStateTests.swift @@ -0,0 +1,141 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class MediaStateTests: XCTestCase { + + static let validConfigurationSharedState = [ + TestConstants.Configuration.MEDIA_CHANNEL: "test_channel", + TestConstants.Configuration.MEDIA_PLAYER_NAME: "test_player_name", + TestConstants.Configuration.MEDIA_APP_VERSION: "test_app_version" + ] + + static let invalidValues: [Any] = [ + 1, + true, + [:], + 2.2, + [] + ] + + static let emptyConfigurationSharedState = [String: Any]() + + func testUpdateConfigurationSharedStateUpdate_withValidConfig_populatesMediaConfig() throws { + let mediaState = MediaState() + mediaState.updateConfigurationSharedState(Self.validConfigurationSharedState) + XCTAssertEqual("test_channel", mediaState.channel) + XCTAssertEqual("test_player_name", mediaState.playerName) + XCTAssertEqual("test_app_version", mediaState.appVersion) + } + + func testUpdateConfigurationSharedStateUpdate_withEmptyConfig_hasNilMediaConfig() throws { + let mediaState = MediaState() + mediaState.updateConfigurationSharedState(Self.emptyConfigurationSharedState) + XCTAssertNil(mediaState.channel) + XCTAssertNil(mediaState.playerName) + XCTAssertNil(mediaState.appVersion) + } + + func testUpdateConfigurationSharedStateUpdate_withInvalidConfig_hasNilMediaConfig() throws { + let mediaState = MediaState() + + for val in Self.invalidValues { + let invalidConfigurationSharedState = [ + TestConstants.Configuration.MEDIA_CHANNEL: val, + TestConstants.Configuration.MEDIA_PLAYER_NAME: val, + TestConstants.Configuration.MEDIA_APP_VERSION: val + ] + + mediaState.updateConfigurationSharedState(invalidConfigurationSharedState) + XCTAssertNil(mediaState.channel) + XCTAssertNil(mediaState.playerName) + XCTAssertNil(mediaState.appVersion) + } + } + + func testHasRequiredConfiguration_withValidConfig_returnsTrue() throws { + let mediaState = MediaState() + mediaState.updateConfigurationSharedState(Self.validConfigurationSharedState) + XCTAssertTrue(mediaState.hasRequiredConfiguration()) + } + + func testHasRequiredConfiguration_withValidPlayerNameAndValidChannel_returnsTrue() throws { + let mediaState = MediaState() + let configurationSharedState = [ + TestConstants.Configuration.MEDIA_CHANNEL: "test_channel", + TestConstants.Configuration.MEDIA_PLAYER_NAME: "test_player_name"] + mediaState.updateConfigurationSharedState(configurationSharedState) + XCTAssertTrue(mediaState.hasRequiredConfiguration()) + } + + func testHasRequiredConfiguration_withValidPlayerNameAndInvalidChannel_returnsFalse() throws { + let mediaState = MediaState() + let configurationSharedState = [ + TestConstants.Configuration.MEDIA_PLAYER_NAME: "test_player_name", + TestConstants.Configuration.MEDIA_APP_VERSION: "test_app_version"] + mediaState.updateConfigurationSharedState(configurationSharedState) + XCTAssertFalse(mediaState.hasRequiredConfiguration()) + } + + func testHasRequiredConfiguration_withValidChannelAndInvalidPlayerName_returnsFalse() throws { + let mediaState = MediaState() + let configurationSharedState = [ + TestConstants.Configuration.MEDIA_CHANNEL: "test_channel", + TestConstants.Configuration.MEDIA_APP_VERSION: "test_app_version"] + mediaState.updateConfigurationSharedState(configurationSharedState) + XCTAssertFalse(mediaState.hasRequiredConfiguration()) + } + + func testHasRequiredConfiguration_withEmptyConfig_returnsFalse() throws { + let mediaState = MediaState() + mediaState.updateConfigurationSharedState(Self.emptyConfigurationSharedState) + XCTAssertFalse(mediaState.hasRequiredConfiguration()) + } + + func testHasRequiredConfiguration_withEmptyChannel_returnsFalse() throws { + let mediaState = MediaState() + let configurationSharedState = [ + TestConstants.Configuration.MEDIA_CHANNEL: "", + TestConstants.Configuration.MEDIA_PLAYER_NAME: "test_player_name", + TestConstants.Configuration.MEDIA_APP_VERSION: "test_app_version"] + mediaState.updateConfigurationSharedState(configurationSharedState) + XCTAssertFalse(mediaState.hasRequiredConfiguration()) + } + + func testHasRequiredConfiguration_withEmptyPlayerName_returnsFalse() throws { + let mediaState = MediaState() + let configurationSharedState = [ + TestConstants.Configuration.MEDIA_CHANNEL: "test_channel", + TestConstants.Configuration.MEDIA_PLAYER_NAME: "", + TestConstants.Configuration.MEDIA_APP_VERSION: "test_app_version"] + mediaState.updateConfigurationSharedState(configurationSharedState) + XCTAssertFalse(mediaState.hasRequiredConfiguration()) + } + + func testhasRequiredConfiguration_withInvalidConfig_returnsFalse() throws { + let mediaState = MediaState() + + for val in Self.invalidValues { + let invalidConfigurationSharedState = [ + TestConstants.Configuration.MEDIA_CHANNEL: val, + TestConstants.Configuration.MEDIA_PLAYER_NAME: val, + TestConstants.Configuration.MEDIA_APP_VERSION: val + ] + + mediaState.updateConfigurationSharedState(invalidConfigurationSharedState) + XCTAssertFalse(mediaState.hasRequiredConfiguration()) + } + } + +} diff --git a/Tests/UnitTests/MediaTests.swift b/Tests/UnitTests/MediaTests.swift new file mode 100644 index 0000000..204882b --- /dev/null +++ b/Tests/UnitTests/MediaTests.swift @@ -0,0 +1,88 @@ +/* + Copyright 2023 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +import AEPServices +import XCTest + +class MediaTests: XCTestCase { + var media: Media! + var mockRuntime: TestableExtensionRuntime! + var fakeMediaProcessor: FakeMediaEventProcessor! + + override func setUp() { + ServiceProvider.shared.namedKeyValueService = MockDataStore() + mockRuntime = TestableExtensionRuntime() + media = Media(runtime: mockRuntime) + media.onRegistered() + + fakeMediaProcessor = FakeMediaEventProcessor() + media.mediaEventProcessor = fakeMediaProcessor + } + + func testExtensionVersion() { + XCTAssertEqual(MediaConstants.EXTENSION_VERSION, Media.extensionVersion) + } + + func testReadyForEvent() { + let event = Event(name: "test-event", type: "test-type", source: "test-source", data: nil) + XCTAssertTrue(media.readyForEvent(event)) + } + + func testHandleEdgeErrorResponse_WithoutRequestEventId_doesNotNotifyMediaEventProcessor() { + let event = Event(name: "test-event", type: "test-type", source: "test-source", data: ["key": "value"]) + media.handleEdgeErrorResponse(event) + XCTAssertFalse(fakeMediaProcessor.notifyErrorResponseCalled) + } + + func testHandleEdgeErrorResponse_WithNilEventData_doesNotNotifyMediaEventProcessor() { + let event = Event(name: "test-event", type: "test-type", source: "test-String", data: nil) + media.handleEdgeErrorResponse(event) + XCTAssertFalse(fakeMediaProcessor.notifyErrorResponseCalled) + } + + func testHandleEdgeErrorResponse_WithRequestEventId_notifiesMediaEventProcessor() { + let event = Event(name: "test-event", type: "test-type", source: "test-String", data: [MediaConstants.Edge.EventData.REQUEST_EVENT_ID: "testRequestId", MediaConstants.Edge.ErrorKeys.STATUS: Int64(400), MediaConstants.Edge.ErrorKeys.TYPE: "https://ns.adobe.com/aep/errors/va-edge-0400-400"]) + media.handleEdgeErrorResponse(event) + XCTAssertTrue(fakeMediaProcessor.notifyErrorResponseCalled) + XCTAssertEqual(3, fakeMediaProcessor.notifyErrorResponseCalledWithData.count) + XCTAssertEqual("testRequestId", fakeMediaProcessor.notifyErrorResponseCalledWithRequestEventId) + XCTAssertEqual(400, fakeMediaProcessor.notifyErrorResponseCalledWithData[MediaConstants.Edge.ErrorKeys.STATUS] as? Int64 ?? 0) + XCTAssertEqual("https://ns.adobe.com/aep/errors/va-edge-0400-400", fakeMediaProcessor.notifyErrorResponseCalledWithData[MediaConstants.Edge.ErrorKeys.TYPE] as? String ?? "") + } + + func testHandleMediaEdgeSessionDetails_WithoutRequestEventId_doesNotNotifyMediaEventProcessor() { + let event = Event(name: "test-event", type: "test-type", source: "test-source", data: ["key": "value"]) + media.handleMediaEdgeSessionDetails(event) + XCTAssertFalse(fakeMediaProcessor.notifyBackendSessionIdCalled) + } + + func testHandleMediaEdgeSessionDetails_WithNilEventData_doesNotNotifyMediaEventProcessor() { + let event = Event(name: "test-event", type: "test-type", source: "test-String", data: nil) + media.handleMediaEdgeSessionDetails(event) + XCTAssertFalse(fakeMediaProcessor.notifyBackendSessionIdCalled) + } + + func testHandleMediaEdgeSessionDetails_WithRequestEventId_notifiesMediaEventProcessor() { + let payload: [[String: Any?]] = [[MediaConstants.Edge.EventData.SESSION_ID: "testBackendSessionId"]] + let data: [String: Any] = [MediaConstants.Edge.EventData.REQUEST_EVENT_ID: "testRequestId", + MediaConstants.Edge.EventData.PAYLOAD: payload] + + let event = Event(name: "test-event", type: "test-type", source: "test-String", data: data) + media.handleMediaEdgeSessionDetails(event) + XCTAssertTrue(fakeMediaProcessor.notifyBackendSessionIdCalled) + XCTAssertEqual("testRequestId", fakeMediaProcessor.notifyBackendSessionIdCalledWithRequestEventId) + XCTAssertEqual("testBackendSessionId", fakeMediaProcessor.notifyBackendSessionIdCalledWithBackendSessionId) + + } +} diff --git a/Tests/UnitTests/MediaXDMEventGeneratorTests.swift b/Tests/UnitTests/MediaXDMEventGeneratorTests.swift new file mode 100644 index 0000000..587ab2f --- /dev/null +++ b/Tests/UnitTests/MediaXDMEventGeneratorTests.swift @@ -0,0 +1,678 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +import XCTest + +class MediaXDMEventGeneratorTests: XCTestCase { + private var mediaInfo: MediaInfo! + private var mediaContext: MediaContext! + private var eventProcessor: FakeMediaEventProcessor! + private var eventGenerator: MediaXDMEventGenerator! + + private var mockTimestamp = Int64(0) + private var mockPlayhead = Int64(0) + static let trackerSessionId = "clientSessionId" + static let refEvent = Event(name: MediaConstants.Media.EVENT_NAME_TRACK_MEDIA, + type: MediaConstants.Media.EVENT_TYPE, + source: MediaConstants.Media.EVENT_SOURCE_TRACK_MEDIA, + data: [MediaConstants.Tracker.SESSION_ID: trackerSessionId]) + + override func setUp() { + mediaInfo = MediaInfo(id: "id", name: "name", streamType: "vod", mediaType: MediaType.Video, length: 30) + let metadata = ["k1": "v1", MediaConstants.VideoMetadataKeys.SHOW: "show"] + self.mediaContext = MediaContext(mediaInfo: mediaInfo, metadata: metadata) + createXDMEventGeneratorWith([:]) + } + + override func tearDown() { + self.mediaContext = nil + self.eventProcessor = nil + self.eventGenerator = nil + } + + // MARK: MediaXDMEventGenerator Unit Tests + func testProcessSessionStart() { + // setup + var sessionDetails = MediaXDMEventHelper.generateSessionDetails(mediaInfo: mediaContext.mediaInfo, metadata: mediaContext.mediaMetadata) + // add standard metadata + sessionDetails.show = "show" + + let customMetadata = [XDMCustomMetadata(name: "k1", value: "v1")] + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.sessionDetails = sessionDetails + mediaCollectionXDM.customMetadata = customMetadata + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processSessionStart() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessSessionComplete() { + // setup + mediaContext.playhead = 10 + eventGenerator.setRefTS(ts: 10) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.sessionComplete, timestamp: getDateFormattedTimestampFor(10), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processSessionComplete() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessSessionEnd() { + // setup + mediaContext.playhead = 10 + eventGenerator.setRefTS(ts: 10) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.sessionEnd, timestamp: getDateFormattedTimestampFor(10), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processSessionEnd() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessAdBreakStart() { + // setup + let adBreakInfo = AdBreakInfo(name: "adBreak", position: 1, startTime: 2) + mediaContext.setAdBreakInfo(adBreakInfo!) + let adBreakDetails = MediaXDMEventHelper.generateAdvertisingPodDetails(adBreakInfo: mediaContext.adBreakInfo) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.advertisingPodDetails = adBreakDetails + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.adBreakStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processAdBreakStart() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessAdBreakComplete() { + // setup + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.adBreakComplete, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processAdBreakComplete() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessAdBreakSkip() { + // setup + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.adBreakComplete, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processAdBreakSkip() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessAdStart() { + // setup + let adInfo = AdInfo(id: "id", name: "ad", position: 1, length: 15) + let metadata = [MediaConstants.AdMetadataKeys.SITE_ID: "testSiteID", + "key": "value" + ] + mediaContext.setAdInfo(adInfo!, metadata: metadata) + + let adDetails = MediaXDMEventHelper.generateAdvertisingDetails(adInfo: adInfo, adMetadata: metadata) + let adMetadata = MediaXDMEventHelper.generateAdCustomMetadataDetails(metadata: metadata) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.advertisingDetails = adDetails + mediaCollectionXDM.customMetadata = adMetadata + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.adStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processAdStart() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessAdSkip() { + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.adSkip, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processAdSkip() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessAdComplete() { + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.adComplete, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processAdComplete() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessChapterStart() { + // setup + let chapterInfo = ChapterInfo(name: "name", position: 1, startTime: 2, length: 10) + + mediaContext.setChapterInfo(chapterInfo!, metadata: ["key1": "value1"]) + + let chapterDetails = MediaXDMEventHelper.generateChapterDetails(chapterInfo: chapterInfo) + let chapterMetadata = MediaXDMEventHelper.generateChapterMetadata(metadata: mediaContext.chapterMetadata) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.chapterDetails = chapterDetails + mediaCollectionXDM.customMetadata = chapterMetadata + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.chapterStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processChapterStart() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessChapterSkip() { + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.chapterSkip, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processChapterSkip() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessChapterComplete() { + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.chapterComplete, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processChapterComplete() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessSessionAbort() { + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.sessionEnd, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processSessionAbort() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessSessionRestart() { + // setup + var sessionDetails = MediaXDMEventHelper.generateSessionDetails(mediaInfo: mediaContext.mediaInfo, metadata: mediaContext.mediaMetadata, forceResume: true) + // add standard metadata + sessionDetails.show = "show" + + let customMetadata = [XDMCustomMetadata(name: "k1", value: "v1")] + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.sessionDetails = sessionDetails + mediaCollectionXDM.customMetadata = customMetadata + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processSessionRestart() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessBitrateChange() { + // setup + let qoeInfo = QoEInfo(bitrate: 123.4, droppedFrames: 10, fps: 120, startupTime: 1) + mediaContext.qoeInfo = qoeInfo + + let qoeDetails = MediaXDMEventHelper.generateQoEDataDetails(qoeInfo: qoeInfo) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.qoeDataDetails = qoeDetails + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.bitrateChange, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processBitrateChange() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessError() { + // setup + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.errorDetails = MediaXDMEventHelper.generateErrorDetails(errorID: "errorID") + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.error, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processError(errorId: "errorID") + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessPlaybackPlay() { + // setup + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Play) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.play, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processPlayback() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessPlaybackPause() { + // setup + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Pause) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.pauseStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processPlayback() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessPlaybackSeek() { + // setup + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Seek) + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.pauseStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processPlayback() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessPlaybackBuffer() { + // setup + mediaContext.enterPlaybackState(state: MediaContext.MediaPlaybackState.Buffer) + + // test + eventGenerator.processPlayback() + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.bufferStart, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessPlaybackWithDoFlushSetToTrue() { + // setup + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.ping, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + eventGenerator.processPlayback(doFlush: true) + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessStateStart() { + // setup + let playerStates = [XDMPlayerStateData(name: MediaConstants.PlayerState.FULLSCREEN)] + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.statesStart = playerStates + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.statesUpdate, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + let stateInfo = StateInfo(stateName: MediaConstants.PlayerState.FULLSCREEN) + eventGenerator.processStateStart(stateInfo: stateInfo!) + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testProcessStateEnd() { + // setup + let playerStates = [XDMPlayerStateData(name: MediaConstants.PlayerState.FULLSCREEN)] + + var mediaCollectionXDM = XDMMediaCollection() + mediaCollectionXDM.playhead = getPlayhead() + mediaCollectionXDM.statesEnd = playerStates + + let expectedMediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.statesUpdate, timestamp: getDateFormattedTimestampFor(0), mediaCollection: mediaCollectionXDM) + + // test + let stateInfo = StateInfo(stateName: MediaConstants.PlayerState.FULLSCREEN) + eventGenerator.processStateEnd(stateInfo: stateInfo!) + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + XCTAssertEqual(expectedMediaXDMEvent, generatedEvent) + } + + func testCustomMainPingInterval_validRange_sendsPingWithCustomValue() { + let validIntervals: [Int64] = [10, 11, 22, 33, 44, 50] + + for interval in validIntervals { + // setup + let intervalMS = interval * 1000 + let trackerConfig = [MediaConstants.TrackerConfig.MAIN_PING_INTERVAL: interval] + createXDMEventGeneratorWith(trackerConfig) + updateTs(interval: intervalMS, reset: true) + + // test + eventGenerator.processPlayback() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + let result = verifyPing(event: generatedEvent, expectedTS: getDate((interval)), expectedPlayhead: (interval)) + XCTAssertTrue(result.success, result.errors) + } + } + + func testCustomMainPingInterval_InvalidRange_sendsPingWithDefaultValue() { + let invalidIntervals = [0, 1, 2, 5, 9, 51, 100, 400, 100000000000000] + + for interval in invalidIntervals { + // setup + let trackerConfig = [MediaConstants.TrackerConfig.MAIN_PING_INTERVAL: interval] + createXDMEventGeneratorWith(trackerConfig) + updateTs(interval: MediaConstants.PingInterval.REALTIME_TRACKING_MS, reset: true) + + // test + eventGenerator.processPlayback() + + // verify ping will be sent after default interval + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + let result = verifyPing(event: generatedEvent, expectedTS: getDate((MediaConstants.PingInterval.REALTIME_TRACKING_MS / 1000)), expectedPlayhead: (MediaConstants.PingInterval.REALTIME_TRACKING_MS / 1000)) + XCTAssertTrue(result.success, result.errors) + } + } + + func testCustomAdPingInterval_validRange_sendsPingWithCustomValue() { + let validIntervals = [1, 3, 9, 10] + + for interval in validIntervals { + // setup + let intervalMS = interval * 1000 + let trackerConfig = [MediaConstants.TrackerConfig.AD_PING_INTERVAL: interval] + createXDMEventGeneratorWith(trackerConfig) + updateTs(interval: Int64(intervalMS), reset: true) + // mock adStart + mediaContext.setAdInfo(AdInfo(id: "testId", name: "name", position: 1, length: 10)!, metadata: [:]) + + // test + eventGenerator.processPlayback() + + // verify + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + let result = verifyPing(event: generatedEvent, expectedTS: getDate(Int64(interval)), expectedPlayhead: Int64(interval)) + XCTAssertTrue(result.success, result.errors) + } + } + + func testCustomAdPingInterval_InvalidRange_sendsPingWithDefaultValue() { + let invalidIntervals = [0, 11, 100, 400, 100000000000000] + + for interval in invalidIntervals { + let trackerConfig = [MediaConstants.TrackerConfig.AD_PING_INTERVAL: interval] + createXDMEventGeneratorWith(trackerConfig) + updateTs(interval: MediaConstants.PingInterval.REALTIME_TRACKING_MS, reset: true) + eventGenerator.processPlayback() + + // ping will be sent after default interval + let generatedEvent = eventProcessor.getEventFromActiveSession(index: 0) + let result = verifyPing(event: generatedEvent, expectedTS: getDate(MediaConstants.PingInterval.REALTIME_TRACKING_MS / 1000), expectedPlayhead: MediaConstants.PingInterval.REALTIME_TRACKING_MS / 1000) + XCTAssertTrue(result.success, result.errors) + } + } + + func testCustomMainPingIntervalAndCustomAdPingInterval_validRange_sendsPingWithCustomValue() { + // setup + let trackerConfig = [MediaConstants.TrackerConfig.MAIN_PING_INTERVAL: 15, MediaConstants.TrackerConfig.AD_PING_INTERVAL: 3] + createXDMEventGeneratorWith(trackerConfig) + + // test + updateTs(interval: (15 * 1000)) + eventGenerator.processPlayback() + + mediaContext.setAdInfo(AdInfo(id: "testId", name: "name", position: 1, length: 10)!, metadata: [:]) // mock adStart + updateTs(interval: (3 * 1000)) + eventGenerator.processPlayback() + + // verify main ping + let generatedMainPingEvent = eventProcessor.getEventFromActiveSession(index: 0) + let result1 = verifyPing(event: generatedMainPingEvent, expectedTS: getDate((15 * 1000) / 1000), expectedPlayhead: 15) + XCTAssertTrue(result1.success, result1.errors) + + let generatedAdPingEvent = eventProcessor.getEventFromActiveSession(index: 1) + let result2 = verifyPing(event: generatedAdPingEvent, expectedTS: getDate(((15 + 3) * 1000) / 1000), expectedPlayhead: (15 + 3)) + XCTAssertTrue(result2.success, result2.errors) + } + + func testDefaultMainPingIntervalCustomAdPingInterval() { + let trackerConfig = [MediaConstants.TrackerConfig.AD_PING_INTERVAL: 3] + createXDMEventGeneratorWith(trackerConfig) + + updateTs(interval: MediaConstants.PingInterval.REALTIME_TRACKING_MS) + eventGenerator.processPlayback() + + mediaContext.setAdInfo(AdInfo(id: "testId", name: "name", position: 1, length: 10)!, metadata: [:]) // mock adStart + updateTs(interval: (3 * 1000)) + eventGenerator.processPlayback() + + mediaContext.clearAdInfo() // mock adComplete, adSkip + updateTs(interval: MediaConstants.PingInterval.REALTIME_TRACKING_MS) + eventGenerator.processPlayback() + + // verify reporting interval for main content is 10 seconds + let mainPingEvent1 = eventProcessor.getEventFromActiveSession(index: 0) + var intervalMS = MediaConstants.PingInterval.REALTIME_TRACKING_MS + let result1 = verifyPing(event: mainPingEvent1, expectedTS: getDate(intervalMS / 1000), expectedPlayhead: (intervalMS / 1000)) + XCTAssertTrue(result1.success, result1.errors) + + // verify reporting interval for ad content is 3 seconds + let adPingEvent1 = eventProcessor.getEventFromActiveSession(index: 1) + intervalMS += (3 * 1000) + let result2 = verifyPing(event: adPingEvent1, expectedTS: getDate(intervalMS / 1000), expectedPlayhead: (intervalMS / 1000)) + XCTAssertTrue(result2.success, result2.errors) + + // verify reporting interval for main content is 10 seconds + let mainPingEvent2 = eventProcessor.getEventFromActiveSession(index: 2) + intervalMS += MediaConstants.PingInterval.REALTIME_TRACKING_MS + let result3 = verifyPing(event: mainPingEvent2, expectedTS: getDate((intervalMS / 1000)), expectedPlayhead: (intervalMS / 1000)) + XCTAssertTrue(result3.success, result3.errors) + } + + func testCustomMainPingIntervalDefaultAdPingInterval() { + let trackerConfig = [MediaConstants.TrackerConfig.MAIN_PING_INTERVAL: 21] + createXDMEventGeneratorWith(trackerConfig) + + updateTs(interval: (21 * 1000)) + eventGenerator.processPlayback() + + mediaContext.setAdInfo(AdInfo(id: "testId", name: "name", position: 1, length: 10)!, metadata: [:]) // mock adStart + updateTs(interval: (MediaConstants.PingInterval.REALTIME_TRACKING_MS)) + eventGenerator.processPlayback() + + mediaContext.clearAdInfo() // mock adComplete, adSkip + updateTs(interval: (21 * 1000)) + eventGenerator.processPlayback() + + // verify reporting interval for main content is 21 seconds + let mainPingEvent1 = eventProcessor.getEventFromActiveSession(index: 0) + var intervalMS = (21 * 1000) + let result1 = verifyPing(event: mainPingEvent1, expectedTS: getDate(Int64(intervalMS / 1000)), expectedPlayhead: Int64((intervalMS / 1000))) + XCTAssertTrue(result1.success, result1.errors) + + // verify reporting interval for ad content is 10 seconds + let adPingEvent1 = eventProcessor.getEventFromActiveSession(index: 1) + intervalMS += Int(MediaConstants.PingInterval.REALTIME_TRACKING_MS) + let result2 = verifyPing(event: adPingEvent1, expectedTS: getDate(Int64(intervalMS / 1000)), expectedPlayhead: Int64((intervalMS / 1000))) + XCTAssertTrue(result2.success, result2.errors) + + // verify reporting interval for main content is 21 seconds + let mainPingEvent2 = eventProcessor.getEventFromActiveSession(index: 2) + intervalMS += (21 * 1000) + let result3 = verifyPing(event: mainPingEvent2, expectedTS: getDate(Int64((intervalMS / 1000))), expectedPlayhead: Int64((intervalMS / 1000))) + XCTAssertTrue(result3.success, result3.errors) + } + + // Utils + private func verifyPing(event: MediaXDMEvent?, expectedTS: Date, expectedPlayhead: Int64) -> (success: Bool, errors: String) { + var errorString = "" + guard let event = event else { + return (success: false, "Event should not be null") + } + XCTAssertEqual(XDMMediaEventType.ping, event.eventType, "Error::EventTypeMismatch expected(\(XDMMediaEventType.ping.rawValue)) != actual(\(event.eventType.rawValue))") + + if XDMMediaEventType.ping != event.eventType { + errorString.append("\nError::EventTypeMismatch expected(\(XDMMediaEventType.ping.rawValue)) != actual(\(event.eventType.rawValue))") + } + if expectedTS != event.timestamp { + errorString.append("\nError::TimeStampMismatch (\(expectedTS)) != actual(\(event.timestamp))") + } + if expectedPlayhead != event.mediaCollection.playhead { + errorString.append("\nError::PlayheadMismatch expected(\(expectedPlayhead)) != actual(\(event.mediaCollection.playhead ?? -1))") + } + + return (success: errorString.isEmpty, errorString) + } + + private func createXDMEventGeneratorWith(_ trackerConfig: [String: Any]) { + mockPlayhead = 0 + mockTimestamp = 0 + eventProcessor = FakeMediaEventProcessor() + eventGenerator = MediaXDMEventGenerator(context: mediaContext, eventProcessor: eventProcessor, config: trackerConfig, refEvent: Self.refEvent, refTS: mockTimestamp) + } + + private func getDate(_ ts: Int64) -> Date { + return Date(timeIntervalSince1970: Double(ts)) + } + + private func updateTs(interval: Int64, updatePlayhead: Bool = true, reset: Bool = false) { + if reset { + mockPlayhead = 0 + mockTimestamp = 0 + } + mockTimestamp += interval + if updatePlayhead { + mockPlayhead += (interval / 1000) + mediaContext.playhead = Double(mockPlayhead) + } + eventGenerator.setRefTS(ts: mockTimestamp) + } + + private func getPlayhead() -> Int64 { + return Int64(mediaContext.playhead) + } + + private func setPlayhead(value: Int64) { + mediaContext.playhead = Double(value) + } + + private func getDateFormattedTimestampFor(_ value: Int64) -> Date { + return Date(timeIntervalSince1970: Double(value / 1000)) + } +} diff --git a/Tests/UnitTests/MediaXDMEventHelperTests.swift b/Tests/UnitTests/MediaXDMEventHelperTests.swift new file mode 100644 index 0000000..5f7e5e5 --- /dev/null +++ b/Tests/UnitTests/MediaXDMEventHelperTests.swift @@ -0,0 +1,193 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class MediaXDMEventHelperTests: XCTestCase { + let mediaInfo = MediaInfo(id: "id", name: "name", streamType: "vod", mediaType: MediaType.Video, length: 10) + let mediaStandardMetadata = [ + MediaConstants.VideoMetadataKeys.AD_LOAD: "adLoad", + MediaConstants.VideoMetadataKeys.ASSET_ID: "assetID", + MediaConstants.VideoMetadataKeys.AUTHORIZED: "authorized", + MediaConstants.VideoMetadataKeys.DAY_PART: "dayPart", + MediaConstants.VideoMetadataKeys.EPISODE: "episode", + MediaConstants.VideoMetadataKeys.FEED: "feed", + MediaConstants.VideoMetadataKeys.FIRST_AIR_DATE: "firstAirDate", + MediaConstants.VideoMetadataKeys.FIRST_DIGITAL_DATE: "firstDigitalDate", + MediaConstants.VideoMetadataKeys.GENRE: "genre", + MediaConstants.VideoMetadataKeys.MVPD: "mvpd", + MediaConstants.VideoMetadataKeys.NETWORK: "network", + MediaConstants.VideoMetadataKeys.ORIGINATOR: "originator", + MediaConstants.VideoMetadataKeys.RATING: "rating", + MediaConstants.VideoMetadataKeys.SEASON: "season", + MediaConstants.VideoMetadataKeys.SHOW: "show", + MediaConstants.VideoMetadataKeys.SHOW_TYPE: "showType", + MediaConstants.VideoMetadataKeys.STREAM_FORMAT: "streamFormat", + + MediaConstants.AudioMetadataKeys.ALBUM: "album", + MediaConstants.AudioMetadataKeys.ARTIST: "artist", + MediaConstants.AudioMetadataKeys.AUTHOR: "author", + MediaConstants.AudioMetadataKeys.LABEL: "label", + MediaConstants.AudioMetadataKeys.PUBLISHER: "publisher", + MediaConstants.AudioMetadataKeys.STATION: "station" + ] + var mediaMetadata: [String: String] = ["key1": "value1", "key2": "value2"] + + var adInfo = AdInfo(id: "id", name: "name", position: 1, length: 10) + let adStandardMetadata = [ + MediaConstants.AdMetadataKeys.ADVERTISER: "advertiser", + MediaConstants.AdMetadataKeys.CAMPAIGN_ID: "campaignID", + MediaConstants.AdMetadataKeys.CREATIVE_ID: "creativeID", + MediaConstants.AdMetadataKeys.CREATIVE_URL: "creativeURL", + MediaConstants.AdMetadataKeys.PLACEMENT_ID: "placementID", + MediaConstants.AdMetadataKeys.SITE_ID: "siteID" + ] + var adMetadata: [String: String] = ["key1": "value1", "key2": "value2"] + + var qoeInfo = QoEInfo(bitrate: 1.1, droppedFrames: 2.2, fps: 3.3, startupTime: 4.4) + + var muteStateInfo = StateInfo(stateName: MediaConstants.PlayerState.MUTE)! + var testStateInfo = StateInfo(stateName: "testStateName")! + + override func setUp() { + mediaMetadata.merge(mediaStandardMetadata) { current, _ in current } + adMetadata.merge(adStandardMetadata) { current, _ in current } + } + + func testGenerateSessionDetails() { + // setup + var expectedSessionDetails = XDMSessionDetails(name: "id", friendlyName: "name", length: 10, streamType: XDMStreamType.video, contentType: "vod", hasResume: false) + + // Standard metadata + expectedSessionDetails.adLoad = "adLoad" + expectedSessionDetails.assetID = "assetID" + expectedSessionDetails.authorized = "authorized" + expectedSessionDetails.dayPart = "dayPart" + expectedSessionDetails.episode = "episode" + expectedSessionDetails.feed = "feed" + expectedSessionDetails.firstAirDate = "firstAirDate" + expectedSessionDetails.firstDigitalDate = "firstDigitalDate" + expectedSessionDetails.genre = "genre" + expectedSessionDetails.mvpd = "mvpd" + expectedSessionDetails.network = "network" + expectedSessionDetails.originator = "originator" + expectedSessionDetails.rating = "rating" + expectedSessionDetails.season = "season" + expectedSessionDetails.show = "show" + expectedSessionDetails.showType = "showType" + expectedSessionDetails.streamFormat = "streamFormat" + + expectedSessionDetails.album = "album" + expectedSessionDetails.artist = "artist" + expectedSessionDetails.author = "author" + expectedSessionDetails.label = "label" + expectedSessionDetails.publisher = "publisher" + expectedSessionDetails.station = "station" + + // test + let sessionDetails = MediaXDMEventHelper.generateSessionDetails(mediaInfo: mediaInfo!, metadata: mediaMetadata) + + // verify + XCTAssertTrue(AssertUtils.compareSizeAndKeys(expectedSessionDetails.asDictionary(), sessionDetails.asDictionary())) + XCTAssertEqual(expectedSessionDetails, sessionDetails) + } + + func testGenerateMediaCustomMetadataDetails() { + // setup + let expectedMetadata = [XDMCustomMetadata(name: "key1", value: "value1"), XDMCustomMetadata(name: "key2", value: "value2")] + + // test + let customMediaMetadata = MediaXDMEventHelper.generateMediaCustomMetadataDetails(metadata: mediaMetadata) + + // verify + XCTAssertTrue(verifyMetadata(expectedMetadata, customMediaMetadata), "Error: expected metadata does not match actual metadata.") + } + + func testGenerateAdvertisingDetails() { + // setup + var expectedAdDetails = XDMAdvertisingDetails(name: "id", friendlyName: "name", length: 10, podPosition: 1) + expectedAdDetails.advertiser = "advertiser" + expectedAdDetails.campaignID = "campaignID" + expectedAdDetails.creativeID = "creativeID" + expectedAdDetails.creativeURL = "creativeURL" + expectedAdDetails.placementID = "placementID" + expectedAdDetails.siteID = "siteID" + + // test + let advertisingDetails = MediaXDMEventHelper.generateAdvertisingDetails(adInfo: adInfo, adMetadata: adMetadata) + + // verify + XCTAssertTrue(AssertUtils.compareSizeAndKeys(expectedAdDetails.asDictionary(), advertisingDetails?.asDictionary())) + XCTAssertEqual(expectedAdDetails, advertisingDetails) + } + + func testGenerateAdCustomMetadataDetails() { + // setup + let expectedMetadata = [XDMCustomMetadata(name: "key1", value: "value1"), XDMCustomMetadata(name: "key2", value: "value2")] + + // test + let customMediaMetadata = MediaXDMEventHelper.generateAdCustomMetadataDetails(metadata: adMetadata) + + // verify + XCTAssertTrue(verifyMetadata(expectedMetadata, customMediaMetadata), "Error: expected metadata does not match actual metadata.") + } + + func testGenerateQoEDetails() { + // setup + let expectedQoEDetails = XDMQoeDataDetails(bitrate: 1, droppedFrames: 2, framesPerSecond: 3, timeToStart: 4) + + // test + let qoeDetails = MediaXDMEventHelper.generateQoEDataDetails(qoeInfo: qoeInfo) + + // verify + XCTAssertTrue(AssertUtils.compareSizeAndKeys(expectedQoEDetails.asDictionary(), qoeDetails?.asDictionary())) + XCTAssertEqual(expectedQoEDetails, qoeDetails) + } + + func testGenerateErrorDetails() { + // setup + let expectedErrorDetails = XDMErrorDetails(name: "testName", source: "player") + + // test + let errorDetails = MediaXDMEventHelper.generateErrorDetails(errorID: "testName") + + // verify + XCTAssertTrue(AssertUtils.compareSizeAndKeys(expectedErrorDetails.asDictionary(), errorDetails.asDictionary())) + XCTAssertEqual(expectedErrorDetails, errorDetails) + } + + func testGenerateStateDetails() { + // setup + let expectedTestStateDetails = XDMPlayerStateData(name: "testStateName") + let expectedMuteStateDetails = XDMPlayerStateData(name: "mute") + let expectedStateDetailsList = [expectedTestStateDetails, expectedMuteStateDetails] + + // test + let stateDetails = MediaXDMEventHelper.generateStateDetails(states: [testStateInfo, muteStateInfo]) + + // verify + XCTAssertEqual(expectedStateDetailsList, stateDetails) + } + + // test helper + private func verifyMetadata(_ expected: [XDMCustomMetadata], _ actual: [XDMCustomMetadata]) -> Bool { + if expected.count != actual.count { + XCTFail("Expected metadata size:(\(expected.count) does not match actual metadata size:(\(actual.count)") + } + + let sortedExpected = expected.sorted() + let sortedActual = actual.sorted() + + return sortedExpected == sortedActual + } +} diff --git a/Tests/UnitTests/MediaXDMEventTests.swift b/Tests/UnitTests/MediaXDMEventTests.swift new file mode 100644 index 0000000..1226b91 --- /dev/null +++ b/Tests/UnitTests/MediaXDMEventTests.swift @@ -0,0 +1,78 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import AEPServices +import XCTest + +class MediaXDMEventTests: XCTestCase { + + func testCreateMediaXDMEvent() { + // Setup + var sessionDetails = XDMSessionDetails(name: "test_mediaId", friendlyName: "name", length: 30, streamType: XDMStreamType.video, contentType: "vod", hasResume: false) + sessionDetails.appVersion = "test_appVersion" + sessionDetails.channel = "test_channel" + sessionDetails.playerName = "test_playerName" + sessionDetails.assetID = "test_assetID" + + var mediaCollection = XDMMediaCollection() + mediaCollection.sessionDetails = sessionDetails + + // Test + let mediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: Date(timeIntervalSince1970: 2), mediaCollection: mediaCollection) + + // Verify + XCTAssertEqual(XDMMediaEventType.sessionStart, mediaXDMEvent.eventType) + XCTAssertEqual(Date(timeIntervalSince1970: 2), mediaXDMEvent.timestamp) + XCTAssertEqual(mediaCollection, mediaXDMEvent.mediaCollection) + } + + func testToXDMData() { + // Setup + var sessionDetails = XDMSessionDetails(name: "id", friendlyName: "name", length: 30, streamType: XDMStreamType.video, contentType: "vod", hasResume: false) + sessionDetails.appVersion = "test_appVersion" + sessionDetails.channel = "test_channel" + sessionDetails.playerName = "test_playerName" + sessionDetails.assetID = "test_assetID" + + var mediaCollection = XDMMediaCollection() + mediaCollection.sessionDetails = sessionDetails + + let mediaXDMEvent = MediaXDMEvent(eventType: XDMMediaEventType.sessionStart, timestamp: Date(timeIntervalSince1970: 2), mediaCollection: mediaCollection) + + // Test + let xdmEventData = mediaXDMEvent.toXDMData() + let xdmMap = xdmEventData["xdm"] as? [String: Any] ?? [:] + + XCTAssertFalse(xdmMap.isEmpty) + XCTAssertEqual("media.sessionStart", xdmMap["eventType"] as? String ?? "") + XCTAssertEqual(Date(timeIntervalSince1970: 2).getISO8601UTCDateWithMilliseconds(), xdmMap["timestamp"] as? String) + let actualMediaCollection = xdmMap["mediaCollection"] as? [String: Any] ?? [:] + XCTAssertFalse(actualMediaCollection.isEmpty) + + let actualSessionDetails = actualMediaCollection["sessionDetails"] as? [String: Any] ?? [:] + XCTAssertEqual(10, actualSessionDetails.count) + + XCTAssertEqual("name", actualSessionDetails["friendlyName"] as! String) + XCTAssertEqual("id", actualSessionDetails["name"] as! String) + XCTAssertEqual(Int64(30), actualSessionDetails["length"] as! Int64) + XCTAssertEqual("video", actualSessionDetails["streamType"] as! String) + XCTAssertEqual("vod", actualSessionDetails["contentType"] as! String) + XCTAssertEqual(false, actualSessionDetails["hasResume"] as! Bool) + XCTAssertEqual("test_appVersion", actualSessionDetails["appVersion"] as! String) + XCTAssertEqual("test_channel", actualSessionDetails["channel"] as! String) + XCTAssertEqual("test_playerName", actualSessionDetails["playerName"] as! String) + XCTAssertEqual("test_assetID", actualSessionDetails["assetID"] as! String) + } + +} diff --git a/Tests/UnitTests/Utils/AssertUtils.swift b/Tests/UnitTests/Utils/AssertUtils.swift new file mode 100644 index 0000000..f637120 --- /dev/null +++ b/Tests/UnitTests/Utils/AssertUtils.swift @@ -0,0 +1,59 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import XCTest + +class AssertUtils: XCTestCase { + /// Compares the size of dictionary and checks for keys + /// - Parameters: + /// - expected: expected Dictionary + /// - actual: actual Dictinoary + /// + static func compareSizeAndKeys(_ expected: [String: Any]?, _ actual: [String: Any]?) -> Bool { + if expected == nil && actual == nil { + return true + } + + guard let expected = expected else { + return false + } + + guard let actual = actual else { + return false + } + + if expected.count != actual.count { + XCTFail("expected dictionary size:(\(expected.count) does not match actual dictionary size:(\(actual.count)") + return checkKeys(expected, actual) + } + + return checkKeys(expected, actual) + } + + static func checkKeys(_ expected: [String: Any], _ actual: [String: Any]) -> Bool { + for k in expected.keys { + if actual[k] == nil { + XCTFail("key:(\(k)) present in expected but not in actual object") + return false + } + } + + for k in actual.keys { + if expected[k] == nil { + XCTFail("key:(\(k)) present in actual but not in expected object") + return false + } + } + + return true + } +} diff --git a/Tests/UnitTests/Utils/FakeMediaEventProcessor.swift b/Tests/UnitTests/Utils/FakeMediaEventProcessor.swift new file mode 100644 index 0000000..9d35e40 --- /dev/null +++ b/Tests/UnitTests/Utils/FakeMediaEventProcessor.swift @@ -0,0 +1,97 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +import Foundation + +class FakeMediaEventProcessor: MediaEventProcessor { + + private var sessionEnded = false + private var processedEvents: [String: [MediaXDMEvent]] = [:] + private var currentSessionId: String = "-1" + private var isSessionStartCalled = false + var dispatcher: ((_ event: Event) -> Void)? + var notifyErrorResponseCalled = false + var notifyErrorResponseCalledWithRequestEventId = "" + var notifyErrorResponseCalledWithData = [String: Any?]() + var notifyBackendSessionIdCalled = false + var notifyBackendSessionIdCalledWithBackendSessionId = "" + var notifyBackendSessionIdCalledWithRequestEventId = "" + + init() { + super.init(dispatcher: dispatcher) + } + + override func createSession(trackerConfig: [String: Any], trackerSessionId: String?) -> String? { + isSessionStartCalled = true + var intSessionId = (Int(currentSessionId) ?? 0) + intSessionId += 1 + currentSessionId = "\(intSessionId)" + processedEvents[currentSessionId] = [] + // for testing failed session creation + if let forcedFail = trackerConfig["testFail"] as? Bool, forcedFail == true { + return nil + } + return currentSessionId + } + + override func endSession(sessionId: String) { + sessionEnded = true + } + + override func processEvent(sessionId: String, event: MediaXDMEvent) { + processedEvents[sessionId]?.append(event) + } + + override func notifyErrorResponse(requestEventId: String, data: [String: Any?]) { + notifyErrorResponseCalled = true + notifyErrorResponseCalledWithData = data + notifyErrorResponseCalledWithRequestEventId = requestEventId + } + + override func notifyBackendSessionId(requestEventId: String, backendSessionId: String?) { + notifyBackendSessionIdCalled = true + notifyBackendSessionIdCalledWithBackendSessionId = backendSessionId ?? "unknown" + notifyBackendSessionIdCalledWithRequestEventId = requestEventId + } + + func getEventFromActiveSession(index: Int) -> MediaXDMEvent? { + return getEvent(sessionId: currentSessionId, index: index) + } + + func getEvent(sessionId: String, index: Int) -> MediaXDMEvent? { + guard let events = processedEvents[sessionId], events.count != 0 else { + return nil + } + + if index >= events.count { + return nil + } + + return events[index] + } + + func getEventCountFromActiveSession() -> Int { + return getEventCount(sessionId: currentSessionId) + } + + func getEventCount(sessionId: String) -> Int { + return processedEvents[sessionId]?.count ?? 0 + } + + func clearEventsFromActiveSession() { + if processedEvents[currentSessionId] != nil { + processedEvents[currentSessionId]?.removeAll() + } + } +} diff --git a/Tests/UnitTests/Utils/MediaSessionSpy.swift b/Tests/UnitTests/Utils/MediaSessionSpy.swift new file mode 100644 index 0000000..b557897 --- /dev/null +++ b/Tests/UnitTests/Utils/MediaSessionSpy.swift @@ -0,0 +1,55 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import AEPCore +@testable import AEPEdgeMedia +import Foundation + +class MediaSessionSpy: MediaSession { + + var events: [MediaXDMEvent] = [] + var hasQueueEventCalled = false + var hasSessionEndCalled = false + var hasSesionAbortCalled = false + var hasHandleSessionUpdateCalled = false + var hasHandleErrorResponseCalled = false + var backendSessionId = "" + var requestEventId = "" + var errorData = [String: Any?]() + + override func handleSessionEnd() { + hasSessionEndCalled = true + sessionEndHandler?() + } + + override func handleSessionAbort() { + hasSesionAbortCalled = true + sessionEndHandler?() + } + + override func handleQueueEvent(_ event: MediaXDMEvent) { + hasQueueEventCalled = true + events.append(event) + } + + override func handleSessionUpdate(requestEventId: String, backendSessionId: String?) { + hasHandleSessionUpdateCalled = true + self.requestEventId = requestEventId + self.backendSessionId = backendSessionId ?? "" + } + + override func handleErrorResponse(requestEventId: String, data: [String: Any?]) { + hasHandleErrorResponseCalled = true + self.requestEventId = requestEventId + self.errorData = data + } +} diff --git a/Tests/UnitTests/Utils/MockDataStore.swift b/Tests/UnitTests/Utils/MockDataStore.swift new file mode 100644 index 0000000..351a796 --- /dev/null +++ b/Tests/UnitTests/Utils/MockDataStore.swift @@ -0,0 +1,42 @@ +// +// Copyright 2021 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import AEPServices +import Foundation + +public class MockDataStore: NamedCollectionProcessing { + private var appGroup: String? + + public func getAppGroup() -> String? { + return appGroup + } + + public func setAppGroup(_ appGroup: String?) { + self.appGroup = appGroup + } + + public var dict = [String: Any?]() + + public init() {} + + public func set(collectionName _: String, key: String, value: Any?) { + dict[key] = value + } + + public func get(collectionName _: String, key: String) -> Any? { + return dict[key] as Any? + } + + public func remove(collectionName _: String, key: String) { + dict.removeValue(forKey: key) + } +} diff --git a/Tests/UnitTests/Utils/MockExtension.swift b/Tests/UnitTests/Utils/MockExtension.swift new file mode 100644 index 0000000..e532561 --- /dev/null +++ b/Tests/UnitTests/Utils/MockExtension.swift @@ -0,0 +1,60 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +@testable import AEPCore + +class MockExtension: NSObject, Extension { + var name = "mockExtension" + var friendlyName = "mockExtension" + static var extensionVersion = "0.0.1" + var metadata: [String: String]? + + static var registrationClosure: (() -> Void)? + static var unregistrationClosure: (() -> Void)? + static var eventReceivedClosure: ((Event) -> Void)? + + let runtime: ExtensionRuntime + + required init(runtime: ExtensionRuntime) { + self.runtime = runtime + } + + static func reset() { + registrationClosure = nil + unregistrationClosure = nil + eventReceivedClosure = nil + } + + func onRegistered() { + registerListener(type: EventType.wildcard, source: EventSource.wildcard) { event in + if let closure = type(of: self).eventReceivedClosure { + closure(event) + } + } + + if let closure = type(of: self).registrationClosure { + closure() + } + } + + func onUnregistered() { + if let closure = type(of: self).unregistrationClosure { + closure() + } + } + + func readyForEvent(_: Event) -> Bool { + return true + } +} diff --git a/Tests/UnitTests/Utils/TestConstants.swift b/Tests/UnitTests/Utils/TestConstants.swift new file mode 100644 index 0000000..2eb04e3 --- /dev/null +++ b/Tests/UnitTests/Utils/TestConstants.swift @@ -0,0 +1,22 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +import Foundation + +enum TestConstants { + enum Configuration { + static let SHARED_STATE_NAME = "com.adobe.module.configuration" + static let MEDIA_CHANNEL = "edgemedia.channel" + static let MEDIA_PLAYER_NAME = "edgemedia.playerName" + static let MEDIA_APP_VERSION = "edgemedia.appVersion" + } +} diff --git a/Tests/UnitTests/Utils/TestHelpers.swift b/Tests/UnitTests/Utils/TestHelpers.swift new file mode 100644 index 0000000..3522b9c --- /dev/null +++ b/Tests/UnitTests/Utils/TestHelpers.swift @@ -0,0 +1,34 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPCore +@testable import AEPEdgeMedia +import AEPServices +import XCTest + +extension EventHub { + static func reset() { + shared = EventHub() + } +} + +extension FileManager { + func clearCache() { + if self.urls(for: .cachesDirectory, in: .userDomainMask).first != nil { + do { + try self.removeItem(at: URL(fileURLWithPath: "Library/Caches/\(MediaConstants.DATABASE_NAME)")) + } catch { + print("ERROR DESCRIPTION: \(error)") + } + } + } +} diff --git a/Tests/UnitTests/Utils/TestableExtensionRuntime.swift b/Tests/UnitTests/Utils/TestableExtensionRuntime.swift new file mode 100644 index 0000000..12f86bc --- /dev/null +++ b/Tests/UnitTests/Utils/TestableExtensionRuntime.swift @@ -0,0 +1,102 @@ +// +// Copyright 2021 Adobe. All rights reserved. +// This file is licensed to you under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may obtain a copy +// of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +// OF ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +@testable import AEPCore +import Foundation + +class TestableExtensionRuntime: ExtensionRuntime { + func getHistoricalEvents(_ requests: [EventHistoryRequest], enforceOrder: Bool, handler: @escaping ([EventHistoryResult]) -> Void) { + // no-op + } + + var listeners: [String: EventListener] = [:] + var dispatchedEvents: [Event] = [] + var createdSharedStates: [[String: Any]?] = [] + public var createdXdmSharedStates: [[String: Any]?] = [] + var otherSharedStates: [String: SharedStateResult] = [:] + var otherXDMSharedStates: [String: SharedStateResult] = [:] + + func getListener(type: String, source: String) -> EventListener? { + return listeners["\(type)-\(source)"] + } + + func simulateComingEvent(event: Event) { + listeners["\(event.type)-\(event.source)"]?(event) + listeners["\(EventType.wildcard)-\(EventSource.wildcard)"]?(event) + } + + func unregisterExtension() { + // no-op + } + + func registerListener(type: String, source: String, listener: @escaping EventListener) { + listeners["\(type)-\(source)"] = listener + } + + func dispatch(event: Event) { + dispatchedEvents += [event] + } + + func createSharedState(data: [String: Any], event _: Event?) { + createdSharedStates += [data] + } + + func createPendingSharedState(event _: Event?) -> SharedStateResolver { + return { data in + self.createdSharedStates += [data] + } + } + + public func getSharedState(extensionName: String, event: Event?, barrier: Bool) -> SharedStateResult? { + getSharedState(extensionName: extensionName, event: event, barrier: barrier, resolution: .any) + } + + public func getSharedState(extensionName: String, event: Event?, barrier: Bool, resolution: SharedStateResolution) -> SharedStateResult? { + return otherSharedStates["\(extensionName)-\(String(describing: event?.id))"] ?? nil + } + + public func createXDMSharedState(data: [String: Any], event: Event?) { + createdXdmSharedStates += [data] + } + + func createPendingXDMSharedState(event: Event?) -> SharedStateResolver { + return { data in + self.createdXdmSharedStates += [data] + } + } + + public func getXDMSharedState(extensionName: String, event: Event?, barrier: Bool) -> SharedStateResult? { + getXDMSharedState(extensionName: extensionName, event: event, barrier: barrier, resolution: .any) + } + + public func getXDMSharedState(extensionName: String, event: Event?, barrier: Bool, resolution: SharedStateResolution) -> SharedStateResult? { + return otherXDMSharedStates["\(extensionName)-\(String(describing: event?.id))"] ?? nil + } + + func simulateSharedState(extensionName: String, event: Event?, data: (value: [String: Any]?, status: SharedStateStatus)) { + otherSharedStates["\(extensionName)-\(String(describing: event?.id))"] = SharedStateResult(status: data.status, value: data.value) + } + + public func simulateXDMSharedState(for extensionName: String, data: (value: [String: Any]?, status: SharedStateStatus)) { + otherXDMSharedStates["\(extensionName)"] = SharedStateResult(status: data.status, value: data.value) + } + + /// clear the events and shared states that have been created by the current extension + public func resetDispatchedEventAndCreatedSharedStates() { + dispatchedEvents = [] + createdSharedStates = [] + } + + func startEvents() {} + + func stopEvents() {} +} diff --git a/Tests/UnitTests/XDMAdvertisingDetailsTests.swift b/Tests/UnitTests/XDMAdvertisingDetailsTests.swift new file mode 100644 index 0000000..581d36b --- /dev/null +++ b/Tests/UnitTests/XDMAdvertisingDetailsTests.swift @@ -0,0 +1,50 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class XDMAdvertisingDetailsTests: XCTestCase { + + // MARK: Encodable tests + func testEncode() throws { + // setup + var adDetails = XDMAdvertisingDetails(name: "id", friendlyName: "name", length: 10, podPosition: 1) + adDetails.playerName = "test_playerName" + + // Standard Metadata + adDetails.advertiser = "test_advertiser" + adDetails.campaignID = "test_campaignID" + adDetails.creativeID = "test_creativeID" + adDetails.creativeURL = "test_creativeURL" + adDetails.placementID = "test_placementID" + adDetails.siteID = "test_siteID" + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(adDetails)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("id", map["name"] as! String) + XCTAssertEqual("name", map["friendlyName"] as! String) + XCTAssertEqual(10, map["length"] as! Int64) + XCTAssertEqual(1, map["podPosition"] as! Int64) + XCTAssertEqual("test_playerName", map["playerName"] as! String) + XCTAssertEqual("test_advertiser", map["advertiser"] as! String) + XCTAssertEqual("test_campaignID", map["campaignID"] as! String) + XCTAssertEqual("test_creativeID", map["creativeID"] as! String) + XCTAssertEqual("test_creativeURL", map["creativeURL"] as! String) + XCTAssertEqual("test_placementID", map["placementID"] as! String) + XCTAssertEqual("test_siteID", map["siteID"] as! String) + } +} diff --git a/Tests/UnitTests/XDMAdvertisingPodDetailsTests.swift b/Tests/UnitTests/XDMAdvertisingPodDetailsTests.swift new file mode 100644 index 0000000..6bfb682 --- /dev/null +++ b/Tests/UnitTests/XDMAdvertisingPodDetailsTests.swift @@ -0,0 +1,33 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class XDMAdvertisingPodDetailsTests: XCTestCase { + + // MARK: Encodable tests + func testEncode() throws { + // setup + let adBreakDetails = XDMAdvertisingPodDetails(friendlyName: "name", index: 1, offset: 2) + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(adBreakDetails)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("name", map["friendlyName"] as! String) + XCTAssertEqual(2, map["offset"] as! Int64) + XCTAssertEqual(1, map["index"] as! Int64) + } +} diff --git a/Tests/UnitTests/XDMChapterDetailsTests.swift b/Tests/UnitTests/XDMChapterDetailsTests.swift new file mode 100644 index 0000000..774debc --- /dev/null +++ b/Tests/UnitTests/XDMChapterDetailsTests.swift @@ -0,0 +1,34 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class XDMChapterDetailsTests: XCTestCase { + + // MARK: Encodable tests + func testEncode() throws { + // setup + let chapterDetails = XDMChapterDetails(friendlyName: "name", index: 1, length: 10, offset: 2) + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(chapterDetails)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("name", map["friendlyName"] as! String) + XCTAssertEqual(1, map["index"] as! Int64) + XCTAssertEqual(10, map["length"] as! Int64) + XCTAssertEqual(2, map["offset"] as! Int64) + } +} diff --git a/Tests/UnitTests/XDMErrorDetailsTests.swift b/Tests/UnitTests/XDMErrorDetailsTests.swift new file mode 100644 index 0000000..b7d13fa --- /dev/null +++ b/Tests/UnitTests/XDMErrorDetailsTests.swift @@ -0,0 +1,32 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class XDMErrorDetailsTests: XCTestCase { + + // MARK: Encodable tests + func testEncode() throws { + // setup + let errorDetails = XDMErrorDetails(name: "test_errorID", source: "test_errorSource") + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(errorDetails)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("test_errorID", map["name"] as! String) + XCTAssertEqual("test_errorSource", map["source"] as! String) + } +} diff --git a/Tests/UnitTests/XDMMediaCollectionTests.swift b/Tests/UnitTests/XDMMediaCollectionTests.swift new file mode 100644 index 0000000..b77f5ac --- /dev/null +++ b/Tests/UnitTests/XDMMediaCollectionTests.swift @@ -0,0 +1,193 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class XDMMediaCollectionTests: XCTestCase { + + // MARK: Encodable tests + func testEncode_sessionStart() throws { + // setup + var sessionDetails = XDMSessionDetails(name: "id", friendlyName: "name", length: 30, streamType: XDMStreamType.video, contentType: "vod", hasResume: false) + sessionDetails.appVersion = "test_appVersion" + sessionDetails.channel = "test_channel" + sessionDetails.playerName = "test_playerName" + + // Video Standard Metadata + sessionDetails.assetID = "test_assetID" + sessionDetails.authorized = "false" + sessionDetails.episode = "1" + sessionDetails.feed = "test_feed" + sessionDetails.firstAirDate = "test_firstAirDate" + sessionDetails.firstDigitalDate = "test_firstAirDigitalDate" + sessionDetails.genre = "test_genre" + sessionDetails.mvpd = "test_mvpd" + sessionDetails.network = "test_network" + sessionDetails.originator = "test_originator" + sessionDetails.rating = "test_rating" + sessionDetails.season = "1" + sessionDetails.segment = "test_segment" + sessionDetails.show = "test_show" + sessionDetails.showType = "test_showType" + sessionDetails.streamFormat = "test_streamFormat" + + var mediaCollection = XDMMediaCollection() + mediaCollection.sessionDetails = sessionDetails + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(mediaCollection)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("id", map["sessionDetails.name"] as! String) + XCTAssertEqual("name", map["sessionDetails.friendlyName"] as! String) + XCTAssertEqual(30, map["sessionDetails.length"] as! Int64) + XCTAssertEqual("video", map["sessionDetails.streamType"] as! String) + XCTAssertEqual("test_appVersion", map["sessionDetails.appVersion"] as! String) + XCTAssertEqual("test_channel", map["sessionDetails.channel"] as! String) + XCTAssertEqual("test_playerName", map["sessionDetails.playerName"] as! String) + + XCTAssertEqual("test_assetID", map["sessionDetails.assetID"] as! String) + XCTAssertEqual("1", map["sessionDetails.episode"] as! String) + XCTAssertEqual("test_feed", map["sessionDetails.feed"] as! String) + XCTAssertEqual("test_firstAirDate", map["sessionDetails.firstAirDate"] as! String) + XCTAssertEqual("test_firstAirDigitalDate", map["sessionDetails.firstDigitalDate"] as! String) + XCTAssertEqual("test_genre", map["sessionDetails.genre"] as! String) + XCTAssertEqual("false", map["sessionDetails.authorized"] as! String) + XCTAssertEqual("test_mvpd", map["sessionDetails.mvpd"] as! String) + XCTAssertEqual("test_network", map["sessionDetails.network"] as! String) + XCTAssertEqual("test_originator", map["sessionDetails.originator"] as! String) + XCTAssertEqual("test_rating", map["sessionDetails.rating"] as! String) + XCTAssertEqual("1", map["sessionDetails.season"] as! String) + XCTAssertEqual("test_segment", map["sessionDetails.segment"] as! String) + XCTAssertEqual("test_show", map["sessionDetails.show"] as! String) + XCTAssertEqual("test_showType", map["sessionDetails.showType"] as! String) + XCTAssertEqual("test_streamFormat", map["sessionDetails.streamFormat"] as! String) + } + + func testEncode_adBreakStart() throws { + // setup + let adBreakDetails = XDMAdvertisingPodDetails(friendlyName: "name", index: 1, offset: 2) + + var mediaCollection = XDMMediaCollection() + mediaCollection.advertisingPodDetails = adBreakDetails + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(mediaCollection)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("name", map["advertisingPodDetails.friendlyName"] as! String) + XCTAssertEqual(2, map["advertisingPodDetails.offset"] as! Int64) + XCTAssertEqual(1, map["advertisingPodDetails.index"] as! Int64) + } + + func testEncode_adStart() throws { + // setup + + // setup + var adDetails = XDMAdvertisingDetails(name: "id", friendlyName: "name", length: 10, podPosition: 1) + adDetails.playerName = "test_playerName" + + // Standard Metadata + adDetails.advertiser = "test_advertiser" + adDetails.campaignID = "test_campaignID" + adDetails.creativeID = "test_creativeID" + adDetails.creativeURL = "test_creativeURL" + adDetails.placementID = "test_placementID" + adDetails.siteID = "test_siteID" + + var mediaCollection = XDMMediaCollection() + mediaCollection.advertisingDetails = adDetails + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(mediaCollection)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("id", map["advertisingDetails.name"] as! String) + XCTAssertEqual("name", map["advertisingDetails.friendlyName"] as! String) + XCTAssertEqual(10, map["advertisingDetails.length"] as! Int64) + XCTAssertEqual(1, map["advertisingDetails.podPosition"] as! Int64) + XCTAssertEqual("test_playerName", map["advertisingDetails.playerName"] as! String) + XCTAssertEqual("test_advertiser", map["advertisingDetails.advertiser"] as! String) + XCTAssertEqual("test_campaignID", map["advertisingDetails.campaignID"] as! String) + XCTAssertEqual("test_creativeID", map["advertisingDetails.creativeID"] as! String) + XCTAssertEqual("test_creativeURL", map["advertisingDetails.creativeURL"] as! String) + XCTAssertEqual("test_placementID", map["advertisingDetails.placementID"] as! String) + XCTAssertEqual("test_siteID", map["advertisingDetails.siteID"] as! String) + } + + func testEncode_chapterStart() throws { + // setup + let chapterDetails = XDMChapterDetails(friendlyName: "name", index: 1, length: 10, offset: 2) + + var mediaCollection = XDMMediaCollection() + mediaCollection.chapterDetails = chapterDetails + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(mediaCollection)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("name", map["chapterDetails.friendlyName"] as! String) + XCTAssertEqual(1, map["chapterDetails.index"] as! Int64) + XCTAssertEqual(10, map["chapterDetails.length"] as! Int64) + XCTAssertEqual(2, map["chapterDetails.offset"] as! Int64) + } + + func testEncode_stateStart() throws { + // setup + let muteState = XDMPlayerStateData(name: "test_mute") + let fullscreenState = XDMPlayerStateData(name: "test_fullscreen") + + let states = [muteState, fullscreenState] + + var mediaCollection = XDMMediaCollection() + mediaCollection.statesStart = states + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(mediaCollection)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("test_mute", map["statesStart[0].name"] as! String) + XCTAssertEqual("test_fullscreen", map["statesStart[1].name"] as! String) + } + + func testEncode_stateEnd() throws { + // setup + + let muteState = XDMPlayerStateData(name: "test_mute") + let fullscreenState = XDMPlayerStateData(name: "test_fullscreen") + + let states = [muteState, fullscreenState] + + var mediaCollection = XDMMediaCollection() + mediaCollection.statesEnd = states + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(mediaCollection)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("test_mute", map["statesEnd[0].name"] as! String) + XCTAssertEqual("test_fullscreen", map["statesEnd[1].name"] as! String) + } +} diff --git a/Tests/UnitTests/XDMSessionDetailsTests.swift b/Tests/UnitTests/XDMSessionDetailsTests.swift new file mode 100644 index 0000000..3294e4d --- /dev/null +++ b/Tests/UnitTests/XDMSessionDetailsTests.swift @@ -0,0 +1,121 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. + */ + +@testable import AEPEdgeMedia +import XCTest + +class XDMSessionDetailsTests: XCTestCase { + + // MARK: Encodable tests + func testEncode_streamTypeVideo() throws { + // setup + var sessionDetails = XDMSessionDetails(name: "id", friendlyName: "name", length: 30, streamType: XDMStreamType.video, contentType: "vod", hasResume: false) + sessionDetails.appVersion = "test_appVersion" + sessionDetails.channel = "test_channel" + sessionDetails.playerName = "test_playerName" + + // Video Standard Metadata + sessionDetails.adLoad = "preroll" + sessionDetails.assetID = "test_assetID" + sessionDetails.authorized = "false" + sessionDetails.dayPart = "evening" + sessionDetails.episode = "1" + sessionDetails.feed = "test_feed" + sessionDetails.firstAirDate = "test_firstAirDate" + sessionDetails.firstDigitalDate = "test_firstAirDigitalDate" + sessionDetails.genre = "test_genre" + sessionDetails.mvpd = "test_mvpd" + sessionDetails.network = "test_network" + sessionDetails.originator = "test_originator" + sessionDetails.rating = "test_rating" + sessionDetails.season = "1" + sessionDetails.segment = "test_segment" + sessionDetails.show = "test_show" + sessionDetails.showType = "test_showType" + sessionDetails.streamFormat = "test_streamFormat" + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(sessionDetails)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("id", map["name"] as! String) + XCTAssertEqual("name", map["friendlyName"] as! String) + XCTAssertEqual(30, map["length"] as! Int64) + XCTAssertEqual("video", map["streamType"] as! String) + XCTAssertEqual("vod", map["contentType"] as! String) + XCTAssertFalse(map["hasResume"] as! Bool) + + XCTAssertEqual("test_appVersion", map["appVersion"] as! String) + XCTAssertEqual("test_channel", map["channel"] as! String) + XCTAssertEqual("test_playerName", map["playerName"] as! String) + + XCTAssertEqual("preroll", map["adLoad"] as! String) + XCTAssertEqual("test_assetID", map["assetID"] as! String) + XCTAssertEqual("evening", map["dayPart"] as! String) + XCTAssertEqual("1", map["episode"] as! String) + XCTAssertEqual("test_feed", map["feed"] as! String) + XCTAssertEqual("test_firstAirDate", map["firstAirDate"] as! String) + XCTAssertEqual("test_firstAirDigitalDate", map["firstDigitalDate"] as! String) + XCTAssertEqual("test_genre", map["genre"] as! String) + XCTAssertEqual("false", map["authorized"] as! String) + XCTAssertEqual("test_mvpd", map["mvpd"] as! String) + XCTAssertEqual("test_network", map["network"] as! String) + XCTAssertEqual("test_originator", map["originator"] as! String) + XCTAssertEqual("test_rating", map["rating"] as! String) + XCTAssertEqual("1", map["season"] as! String) + XCTAssertEqual("test_segment", map["segment"] as! String) + XCTAssertEqual("test_show", map["show"] as! String) + XCTAssertEqual("test_showType", map["showType"] as! String) + XCTAssertEqual("test_streamFormat", map["streamFormat"] as! String) + } + + func testEncode_streamTypeAudio() throws { + // setup + var sessionDetails = XDMSessionDetails(name: "id", friendlyName: "name", length: 30, streamType: XDMStreamType.audio, contentType: "aod", hasResume: false) + sessionDetails.appVersion = "test_appVersion" + sessionDetails.channel = "test_channel" + sessionDetails.playerName = "test_playerName" + + sessionDetails.album = "test_album" + sessionDetails.artist = "test_artist" + sessionDetails.author = "test_author" + sessionDetails.label = "test_label" + sessionDetails.publisher = "test_publisher" + sessionDetails.station = "test_station" + + // test + let encoder = JSONEncoder() + let data = try XCTUnwrap(encoder.encode(sessionDetails)) + + let map = asFlattenDictionary(data: data) + + XCTAssertEqual("id", map["name"] as! String) + XCTAssertEqual("name", map["friendlyName"] as! String) + XCTAssertEqual(30, map["length"] as! Int64) + XCTAssertEqual("audio", map["streamType"] as! String) + XCTAssertEqual("aod", map["contentType"] as! String) + XCTAssertFalse(map["hasResume"] as! Bool) + + XCTAssertEqual("test_appVersion", map["appVersion"] as! String) + XCTAssertEqual("test_channel", map["channel"] as! String) + XCTAssertEqual("test_playerName", map["playerName"] as! String) + + XCTAssertEqual("test_album", map["album"] as! String) + XCTAssertEqual("test_artist", map["artist"] as! String) + XCTAssertEqual("test_author", map["author"] as! String) + XCTAssertEqual("test_label", map["label"] as! String) + XCTAssertEqual("test_publisher", map["publisher"] as! String) + XCTAssertEqual("test_station", map["station"] as! String) + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..8368ca8 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,36 @@ +codecov: + notify: + require_ci_to_pass: yes + +coverage: + precision: 2 + round: nearest + range: "70...90" + + status: + project: yes + patch: + default: + target: 90% + threshold: 5% + changes: no + + ignore: + - "./Tests/**/*" + - "./Tests/.*" + - "./TestApps" + - "./build" + - "./Documentation" + +parsers: + gcov: + branch_detection: + conditional: yes + loop: yes + method: no + macro: no + +comment: + layout: "header, diff" + behavior: default + require_changes: no